src="https://api.eyabc.cn/api/picture/scenery/?k=2e9d9d6e&u=https%3A%2F%2Fmmbiz.qpic.cn%2Fsz_mmbiz_jpg%2FmeG6Vo0MevhEchribIuTYVahGlviaQOKbibDeclXobGENDViaYlcCnnT2A7MbWZWbnuWEOqUafjsTvoCkayj93FiarQ%2F0%3Fwx_fmt%3Djpeg">
前言
探讨了 JavaScript 中 await fetch() 可能导致应用变慢的原因,并提供了相应的解决方案。今日前端早读课文章由 @anliberant 分享,@飘飘编译。
译文从这开始~~
在开发 JavaScript 应用时,很多人容易忽视与 fetch() 相关的性能问题。即使是像 await fetch() 这样简单的代码,也可能会无意中拖慢应用的速度,导致网络请求延迟,让用户体验变差。本文将探讨 fetch() 导致性能下降的原因,并提供对应的解决方案。
1. 冷启动 TCP 连接:突然出现的 200ms 延迟
现象:
-
第一次请求某个 API 总是比之后的请求慢。
-
批量操作时,第 95 百分位的请求耗时(t₉₅)会飙升。
每次调用 fetch() 都会创建一个新的套接字连接,这包括以下几个步骤:
-
DNS 解析:将域名转换成 IP 地址
-
TCP 握手:建立 TCP 连接
-
TLS 握手:通过 HTTPS 建立安全连接
以欧洲为例,平均一个往返延迟(RTT)大约为 50 毫秒,意味着每次新连接可能多花好几百毫秒。
解决方法:
import { Agent } from 'undici';
const apiAgent = new Agent({
keepAliveTimeout: 30_000, // 保持连接 30 秒
connections: 100, // 连接池大小
});
const fetchOrders = async () => {
const response = await fetch('https://api.payments.local/v1/orders', {
dispatcher: apiAgent
});
return response.json();
};
在此代码中:
apiAgent负责管理 Keep-Alive 的连接。
fetchOrders()使用
undici来复用已有的连接,提高请求效率。
2. DNS 和 TLS:隐藏的性能杀手
即使使用了 Keep-Alive,第一次访问一个新域名时仍然会慢,原因是:
-
DNS 查询:会阻塞 JavaScript 线程,在移动网络下可能耗时 100 毫秒。
-
TLS 握手:相比 TCP 的一次往返,TLS 需要三次往返。
解决方法:
-
DNS 缓存:缓存 DNS 查询结果,避免重复解析。
-
增加 maxSockets:支持同时处理多个域名。
Nginx 示例:
resolver 9.9.9.9 valid=300s; // 缓存 DNS 响应 300 秒
Node.js 示例:
const agentWithDnsCache = new Agent({
connect: { lookup: dnsCache.lookup }, // 使用 DNS 缓存加速查询
});
const fetchFromNewDomain = async () => {
const response = await fetch('https://newapi.domain.com/v1/data', {
dispatcher: agentWithDnsCache,
});
return response.json();
};
替代方案:使用 QUIC/HTTP-3
借助 QUIC 或 HTTP/3 的 0-RTT 特性,可以绕过这些连接延迟,实现更快的通信。
3. response.json () 阻塞事件循环
当服务器返回大量 JSON 数据(例如 5-10MB 或更多)时,response.json() 会阻塞事件循环,占满 100% 的 CPU 资源。
解决方法:使用流式解析
import { parse } from 'stream-json';
import { chain } from 'stream-chain';
import { finished } from 'stream';
const streamJsonResponse = async (response) => {
const pipeline = chain([
response.body, // fetch 返回的 ReadableStream
parse(), // 流式解析 JSON
({ key, value }) => { /* 处理每个数据片段 */ },
]);
await finished(pipeline);
};
这段代码会在数据流到达时就处理,避免一次性加载整个大文件到内存中,提高效率。
4. 优化响应体大小:压缩与数据格式
如果网络速度没问题,但加载依然缓慢,通常是因为数据格式效率底下,或者没有启用压缩。
解决方法:
前端启用压缩请求(如 Brotli):
const fetchDataWithCompression = async (url) => {
const response = await fetch(url, {
headers: { 'Accept-Encoding': 'br, gzip' },
});
return response.json();
};
后端(Fastify)启用 Brotli 压缩:
const fastify = require('fastify')();
fastify.register(require('@fastify/compress'), {
brotliOptions: { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 } }
});
Brotli 相比 Gzip 最多可以节省约 25% 的数据量。
5. 在循环中使用 await:并发性能杀手
在循环中执行多个异步任务时,如果使用 await,它们会按顺序一个一个执行,即使它们本可以并行完成。
常见错误:
for (const id of ids) {
const res = await fetch(`/api/item/${id}`);
items.push(await res.json());
}
这种写法会让请求一个接一个发出,严重拖慢速度。
正确写法:限制并发数量
const maxConcurrency = 10; // 最大并发请求数
const requestQueue = [...ids]; // API 请求 ID 队列
const results = [];
const fetchItemsConcurrently = async () => {
await Promise.all(
Array.from({ length: maxConcurrency }, async () => {
while (requestQueue.length) {
const itemId = requestQueue.pop();
const response = await fetch(`/api/item/${itemId}`);
const itemData = await response.json();
results.push(itemData);
}
})
);
};
这样可以控制同时进行的请求数量,防止后台被瞬间请求压垮。
6. 使用 undici.request 提高请求速度
为了获得更好的性能,建议使用 undici 的 request 方法来替代内建的 fetch,因为它更快、开销更小。
import { request } from 'undici';
const postDataWithUndici = async (url, payload) => {
const { body } = await request(url, {
method: 'POST',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
});
return await body.json();
};
💡 fetch() 优化的适用环境说明
在本文中,大多数优化策略(如使用 undici、自定义 Agent、DNS 缓存、HTTP/2 配置)主要适用于 Node.js 环境,尤其是在服务端使用 fetch() 发起 API 请求的场景下。
而在浏览器端,由于运行环境受限:
-
浏览器自身会进行连接复用(Keep-Alive)、DNS 缓存、TLS Session 重用等优化;
-
不支持 undici、Node.js 的 Agent、流式 JSON 处理等接口;
-
浏览器的
fetch()无法手动指定连接池或 dispatcher。
因此:浏览器端建议关注的数据优化点:
-
CORS 预检减少
-
压缩响应内容
-
控制并发请求
-
减少大 JSON 解析阻塞(转为分页、懒加载等)
Node.js 环境建议全面采用本文提到的所有策略,特别是高并发场景下的后端接口请求服务。
7. 通过服务器配置简化 CORS 预检请求
CORS(跨域资源共享)的预检请求会增加额外的往返延迟。简化请求方式,并缓存预检响应,可以显著降低这类开销。
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors({
origin: 'https://your-frontend.com', // 允许的前端域名
credentials: true,
maxAge: 86400, // 缓存预检响应 24 小时
}));
app.listen(3000, () => {
console.log('Server running on port 3000');
});
8. HTTP/2 的 HOL 阻塞:大文件上传影响所有请求
在 HTTP/2 中,所有请求共用一个 TCP 连接,如果上传大文件,会因为连接阻塞(HOL Blocking)影响其他请求。为了解决这个问题,可以将大文件请求和小请求分离。
const { Agent } = require('undici');
const largeUploadAgent = new Agent({ maxStreamsPerConnection: 1 });
const uploadLargeFile = async (largeFileUrl, bigFile) => {
await fetch(largeFileUrl, { dispatcher: largeUploadAgent, body: bigFile });
};
为了获得更好的性能,可以使用 HTTP/3,它基于 UDP,不会受到 TCP 的阻塞限制。
9. 在发送前使用 JSON.stringify () 的性能问题
对大型数据使用 JSON.stringify() 进行序列化,会阻塞事件循环。更高效的做法是使用流式 multipart 上传 或其他更轻量的序列化方法。
import { Readable } from 'node:stream';
const streamPayload = async (largeData) => {
const encoder = new TextEncoder();
const stream = Readable.from(
largeData.map(item => encoder.encode(JSON.stringify(item) + '\n'))
);
await fetch('/bulk/ingest', { method: 'POST', body: stream });
};
这种方法在处理大数据集时可以显著降低内存使用。
🧩 浏览器端 fetch 性能优化建议
尽管浏览器环境受限,仍可以从以下角度优化 fetch () 请求:
-
避免重复请求:使用缓存策略(如 SWR、localStorage、IndexedDB)
-
请求合并:将多个小请求合并为一个批量请求
-
懒加载与分页:大数据集分批获取,避免阻塞主线程
-
内容压缩:服务端开启 Gzip/Brotli,前端声明 Accept-Encoding
-
使用 HTTP/2:确保 CDN / 服务端开启多路复用特性,浏览器自动受益
总结
优化 await fetch() 的性能,需要从多个方面着手,包括复用连接、使用高效的流式格式、限制并发数量等。使用 undici 替代默认 fetch,避免不必要的 JSON.stringify() 操作,并通过缓存和预检优化 CORS 请求,都能显著提升应用性能。
建议持续进行性能测试,定期监控并优化。可以进一步考虑使用 GraphQL-over-HTTP/2、gRPC-web 或 msgpack 等替代方案,为应用带来更出色的响应速度和效率。
关于本文
译者:@飘飘
作者:@anliberant
原文:https://jsdev.space/await-fetch-slow/
