1. 项目概述:为什么Node.js开发者需要关注fetch?
如果你和我一样,是从前端开发转向或者同时涉足Node.js后端,那么对fetch这个API一定不会陌生。在浏览器里,它是我们与服务器通信的“瑞士军刀”,简洁的语法和基于Promise的设计让它迅速取代了古老的XMLHttpRequest。然而,当我们在Node.js环境中也想顺手拈来地使用fetch时,却常常会碰壁——你会发现,原生的Node.js核心模块里,根本没有fetch这个东西。
这引出了一个核心问题:在Node.js里发起HTTP请求,我们到底有多少种选择?从内置的http/https模块,到社区里经久不衰的axios、request(已弃用但影响深远),再到node-fetch、got等后起之秀,选择众多。但fetchAPI以其标准化的接口和与前端开发体验的一致性,对全栈开发者有着独特的吸引力。近年来,随着Node.js自身的发展,情况正在发生变化。从v17.5.0开始,Node.js实验性地引入了对fetch的原生支持,并在v18.0.0中将其提升为稳定功能(但仍需通过--experimental-fetch标志开启),直到最近的LTS版本(如v18+在特定子版本后),它终于成为了一个全局可用的、稳定的API。
这意味着,我们正处在一个过渡期。很多教程、老项目还在使用node-fetch这个第三方polyfill库,而新项目则可以直接拥抱原生fetch。这种新旧交替,正是各种“坑”滋生的温床。本文将基于我最近在多个生产级Node.js服务中整合fetch的经验,为你彻底梳理在Node.js中使用fetch的完整路径,并重点剖析那些官方文档不会明说,但实际开发中一定会遇到的典型问题及其解决方案。无论你是想在新项目中直接使用原生fetch,还是需要维护一个使用了node-fetch的老项目,这篇文章都能给你提供直接的参考。
2. 核心思路与方案选型:原生、Polyfill还是其他?
在动手写第一行代码之前,我们必须先厘清一个根本问题:在当前的项目环境和Node.js版本下,我应该使用哪种fetch?这绝不是一个拍脑袋的决定,它直接关系到项目的依赖复杂度、运行稳定性以及未来的可维护性。
2.1 三种核心实现路径的深度对比
1. Node.js原生fetch (v18及以上LTS版本推荐)这是最理想、最简洁的方案。如果你的项目可以运行在Node.js v18.0.0或更高版本(强烈建议使用最新的LTS版本,如v20.x),那么fetch已经作为一个全局API存在,无需任何额外安装。
- 优势:
- 零依赖:减少
node_modules体积,避免依赖冲突,提升安装速度和安全性。 - 官方支持:与Node.js运行时深度集成,通常能获得更好的性能和兼容性保证,更新随Node.js版本同步。
- 标准一致:行为最接近Web标准,前端经验无缝迁移。
- 零依赖:减少
- 劣势:
- 版本锁定:要求生产环境Node.js版本必须足够新,在一些受限制的旧服务器或容器环境中可能无法满足。
- 功能相对基础:相较于一些成熟的第三方库,原生
fetch在早期版本可能缺少如请求超时自动取消、请求重试、进度监控等高级特性(不过这些正在逐步完善)。
2. node-fetch库 (兼容旧版本Node.js或需要特定功能的项目)这是一个将浏览器fetchAPI移植到Node.js环境的第三方库,在原生fetch成熟之前,它是事实上的标准。
- 优势:
- 广泛的兼容性:支持低至Node.js v12的版本,是旧项目或环境无法升级时的救星。
- 社区生态成熟:拥有大量的使用者,常见问题通常都能找到社区解决方案。其API与Web标准高度一致。
- 可预测性:在原生
fetch尚未稳定的时期,它提供了一个行为稳定的替代品。
- 劣势:
- 额外依赖:需要安装和维护这个第三方包。
- 潜在的API差异:虽然极力模仿,但与原生实现或浏览器实现之间可能存在细微差别,尤其是在处理流(Stream)、重定向或某些错误边界时。
- 未来迁移成本:如果项目后期升级Node.js并想转向原生
fetch,可能需要修改代码(尽管大部分兼容)。
3. 其他HTTP客户端 (如axios, got)这些是功能更全面的HTTP客户端,它们有自己的API设计哲学,但通常也提供类似fetch的体验或兼容层。
- 优势:
- 功能强大:内置请求/响应拦截器、自动转换JSON数据、取消请求、文件上传、HTTP/2支持等高级功能。
- 开发者体验:通常提供更便捷的API,例如
axios直接区分axios.get()、axios.post()。
- 劣势:
- 非标准API:如果你追求与浏览器
fetchAPI的一致性,那么这些库的API是不同的,需要单独学习。 - 体积更大:功能丰富意味着包体积通常比单纯的
fetchpolyfill要大。
- 非标准API:如果你追求与浏览器
注意:在Node.js v16及更早版本中,尝试直接使用
fetch会导致ReferenceError: fetch is not defined错误。这是你判断环境是否支持原生fetch的最直接信号。
2.2 如何做出你的技术选型?
我的决策逻辑通常遵循以下流程,你可以参考:
- 检查并锁定Node.js版本:这是第一步,也是最重要的一步。运行
node -v确认生产环境和开发环境的Node.js版本。如果>=18.x LTS,优先考虑原生fetch。 - 评估项目需求:
- 如果是全新的微服务、API Server或脚本工具,且对运行环境有控制权(如使用Docker容器,可自由选择Node.js版本),强烈推荐使用Node.js v18+ LTS并直接采用原生
fetch。这是最干净、最面向未来的选择。 - 如果是需要兼容旧企业环境的遗留项目,或者是一个需要被广泛安装的命令行工具(CLI)(用户Node.js版本不可控),那么使用
node-fetch是更安全的选择。 - 如果你的应用需要非常复杂的HTTP交互逻辑,如请求重试、缓存、高级拦截等,可以考虑
axios或got,它们开箱即用。你也可以在原生fetch基础上封装这些功能。
- 如果是全新的微服务、API Server或脚本工具,且对运行环境有控制权(如使用Docker容器,可自由选择Node.js版本),强烈推荐使用Node.js v18+ LTS并直接采用原生
- 制定降级/回退策略:对于库(Library)或框架的开发者,你需要考虑用户的多样性。一种常见的模式是,优先尝试使用原生
fetch,如果不存在,则动态加载(require或import)node-fetch作为备选。这需要一些条件判断代码。
// 一个简单的降级策略示例 let fetchImplementation; if (globalThis.fetch) { // 使用原生fetch fetchImplementation = globalThis.fetch; } else { // 动态引入node-fetch。注意:在ES模块中需使用异步import() const { default: nodeFetch } = await import('node-fetch'); fetchImplementation = nodeFetch; } // 后续使用 fetchImplementation 进行请求3. 从零开始:在不同环境中安装与配置
确定了方案,接下来就是搭建环境。这里我们分场景详细说明。
3.1 场景一:使用Node.js原生fetch (v18+)
如果你的Node.js版本符合要求,那么你不需要安装任何东西。fetch、Request、Response、Headers这些Web API已经是全局可用的了。你可以创建一个最简单的test.js文件来验证:
// test.js try { const response = await fetch('https://api.github.com'); console.log('原生fetch可用,状态码:', response.status); } catch (error) { console.error('原生fetch不可用或请求失败:', error.message); }在终端运行node test.js,如果看到状态码输出(如200),恭喜你,环境已经就绪。
重要配置点:对于Node.js v18.0.0,你可能还需要在启动时加上--experimental-fetch标志,但在v18.17.0之后的LTS版本中,它已是默认启用且稳定的。你可以通过node -p "process.versions"查看Node.js详细版本,并查阅对应版本的官方文档确认。
3.2 场景二:使用node-fetch库
这是目前更普遍的场景,因为很多项目还未升级到v18。
1. 初始化项目与安装首先,确保你有一个Node.js项目(如果没有,使用npm init -y初始化)。然后,在项目根目录下安装node-fetch。
# 使用 npm npm install node-fetch # 或使用 yarn yarn add node-fetch # 或使用 pnpm pnpm add node-fetch2. 不同模块系统的引入方式这是第一个容易踩坑的地方。node-fetch从v3版本开始,只提供ES模块(ESM)支持。这意味着你的项目文件必须是.mjs扩展名,或者package.json中设置了"type": "module"。而v2版本则同时支持CommonJS和ESM。
如果你的项目是ES模块(.mjs文件或设置了
"type": "module"):// 正确:使用ESM的import语法 import fetch from 'node-fetch';如果你的项目是CommonJS(.js文件且未设置
"type": "module"):- 方案A:安装v2版本(不推荐,v2已停止维护)。
npm install node-fetch@2 - 方案B:使用动态
import()(推荐,异步方式)。
// 在CommonJS中使用node-fetch v3 const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args)); // 然后在一个async函数中使用 async function makeRequest() { const response = await fetch('https://api.github.com'); const data = await response.text(); console.log(data); } makeRequest();- 方案C:将你的项目迁移到ESM(长期来看是最好的选择)。
- 方案A:安装v2版本(不推荐,v2已停止维护)。
实操心得:我强烈建议新项目直接采用ESM规范。这不仅是为了兼容
node-fetchv3,更是因为ESM是JavaScript官方的模块标准,越来越多的生态库正在转向ESM。迁移虽然初期有阵痛,但能避免未来更多的兼容性问题。你可以在package.json中加入"type": "module"来开启。
3. 处理TypeScript项目在TypeScript项目中使用node-fetch,你还需要安装对应的类型定义文件。
npm install --save-dev @types/node-fetch然后在你的TS文件中引入:
import fetch from 'node-fetch'; // TypeScript现在能正确识别fetch的类型3.3 环境变量与代理配置
在企业网络或某些特定环境下,你可能需要通过代理服务器访问外部网络。fetch(无论是原生还是node-fetch)默认会尊重系统的HTTP_PROXY、HTTPS_PROXY和NO_PROXY环境变量。
- 在Linux/macOS的终端中临时设置:
export HTTPS_PROXY=http://your-proxy:port node your-script.js - 在Windows的CMD中临时设置:
set HTTPS_PROXY=http://your-proxy:port node your-script.js - 在代码中通过Agent显式配置(以
node-fetch为例,原生fetch目前不支持直接传入自定义agent,需通过undici配置,这是另一个复杂话题):import fetch from 'node-fetch'; import { HttpsProxyAgent } from 'https-proxy-agent'; const proxyAgent = new HttpsProxyAgent('http://your-proxy:port'); const response = await fetch('https://api.example.com', { agent: proxyAgent });
如果你遇到fetch请求在本地成功但在服务器失败,或者出现奇怪的网络超时,首先检查代理配置和环境变量。
4. 基础到进阶:fetch API的实战应用详解
无论底层实现是原生还是node-fetch,其API都与Web标准基本一致。让我们通过实例,从最简单的GET请求深入到复杂的场景。
4.1 发起一个简单的GET请求
这是最常见的操作。注意,fetch()返回一个Promise,它解析为一个Response对象。这个Response代表了HTTP响应的整个状态,但响应体(body)本身可能还没有被完全读取。
import fetch from 'node-fetch'; // 或直接使用全局fetch const url = 'https://jsonplaceholder.typicode.com/posts/1'; async function getPost() { try { // 1. 发起请求,获取Response对象 const response = await fetch(url); // 2. 关键检查:响应状态是否成功(状态码在200-299之间) if (!response.ok) { // 如果响应不成功,抛出错误,包含状态码和状态文本 throw new Error(`HTTP错误! 状态码: ${response.status} ${response.statusText}`); } // 3. 解析响应体。根据内容类型选择方法。 // 对于JSON响应: const data = await response.json(); console.log('获取到的数据:', data); // 其他常见的解析方法: // const text = await response.text(); // 解析为纯文本 // const blob = await response.blob(); // 解析为Blob对象(Node.js中有限支持) // const arrayBuffer = await response.arrayBuffer(); // 解析为ArrayBuffer } catch (error) { // 捕获网络错误、解析错误或我们抛出的HTTP错误 console.error('请求失败:', error.message); } } getPost();关键点解析:
response.ok:这是一个非常方便的布尔属性,当response.status在200-299范围内时为true。永远不要只检查status === 200,因为像201(Created)、204(No Content)也是成功的状态。response.json():这个方法也是异步的(返回Promise),因为它需要从网络流中读取完整的响应体并解析为JSON。忘记await它是一个常见错误。- 错误处理:
fetch只在网络故障(如DNS解析失败、连接被拒绝)时才会拒绝(reject)Promise。对于HTTP错误状态(404, 500等),Promise仍然是兑现(fulfilled)的,你需要通过response.ok或response.status来手动检查。这是fetch与axios等库的一个重要区别,axios默认会将HTTP错误状态也视为reject。
4.2 构造复杂的POST/PUT请求
发送数据到服务器,需要配置method、headers和body选项。
async function createPost() { const url = 'https://jsonplaceholder.typicode.com/posts'; const postData = { title: '我的新文章', body: '这是一篇关于fetch API的精彩内容。', userId: 1, }; const response = await fetch(url, { method: 'POST', // 或 'PUT', 'PATCH', 'DELETE' headers: { // 必须根据你发送的body类型设置正确的Content-Type 'Content-Type': 'application/json', // 可以添加其他头,如认证令牌 'Authorization': 'Bearer your-token-here', }, // body可以是字符串、FormData、Buffer、URLSearchParams等 body: JSON.stringify(postData), // 将JavaScript对象序列化为JSON字符串 }); if (!response.ok) { const errorText = await response.text(); // 尝试获取服务器返回的错误信息 throw new Error(`创建失败: ${response.status} - ${errorText}`); } const newPost = await response.json(); console.log('创建成功,新文章ID:', newPost.id); return newPost; }Content-Type的学问:
'application/json':当你发送JSON字符串时使用。'application/x-www-form-urlencoded':发送表单格式数据,body应为new URLSearchParams({key: 'value'})生成的字符串。'multipart/form-data':用于文件上传。在浏览器中可以用FormData对象,在Node.js中(特别是node-fetch)处理起来稍复杂,通常需要借助form-data这个npm包来构建请求体。
4.3 处理超时与取消请求
原生fetch和node-fetch早期版本一个被诟病的点是没有内置的超时机制。这意味着一个请求可能会永远挂起。我们必须自己实现。
1. 使用AbortController实现超时(现代推荐方式)AbortController是Web标准的一部分,用于中止一个或多个Web请求。Node.js原生fetch和node-fetchv3+都支持它。
async function fetchWithTimeout(resource, options = {}, timeout = 8000) { // 1. 创建一个AbortController实例 const controller = new AbortController(); // 获取它的signal(信号) const { signal } = controller; // 2. 设置一个定时器,在超时后触发abort const timeoutId = setTimeout(() => { controller.abort(); // 中止请求 console.log(`请求超时: ${resource}`); }, timeout); // 3. 发起fetch请求,传入signal try { const response = await fetch(resource, { ...options, signal, // 将signal关联到这次fetch请求 }); // 请求成功完成,清除超时定时器 clearTimeout(timeoutId); return response; } catch (error) { // 请求失败,清除定时器 clearTimeout(timeoutId); // 判断错误是否是由abort引起的 if (error.name === 'AbortError') { console.error('请求被主动中止(超时)'); // 这里可以抛出一个自定义的超时错误 throw new Error(`请求超时(${timeout}ms)`); } else { // 其他类型的错误(网络错误等) console.error('请求发生其他错误:', error.message); throw error; } } } // 使用示例 try { const response = await fetchWithTimeout('https://httpbin.org/delay/10', {}, 5000); // 5秒超时 const data = await response.json(); } catch (error) { console.error('捕获到的错误:', error.message); // 将输出“请求超时(5000ms)” }2. 使用Promise.race的旧方案(备选)在AbortController不被支持的环境(极老的Node.js或浏览器),可以使用Promise.race。
function fetchWithTimeoutOld(resource, options, timeout = 8000) { // 创建一个会在超时后reject的Promise const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error(`请求超时(${timeout}ms)`)), timeout); }); // 比赛:是fetch先完成,还是超时先发生 return Promise.race([ fetch(resource, options), timeoutPromise ]); } // 注意:此方案有一个严重缺陷!即使请求超时被reject,底层的fetch请求可能仍在后台进行,无法真正中止,会浪费资源。实操心得:务必为生产环境的每一个外部HTTP请求设置合理的超时。超时时间应根据接口的SLA(服务等级协议)来定,通常快速API可以设为5-10秒,文件上传等操作可以更长。
AbortController是当前最优雅和正确的解决方案。
4.4 处理流式响应与大文件下载
对于大体积的响应(如文件下载),一次性将数据读入内存(response.json()或response.text())可能导致内存溢出。fetch的响应体(response.body)是一个Node.js可读流(Readable Stream),我们可以流式处理。
import fs from 'fs'; import { pipeline } from 'stream/promises'; async function downloadFile(url, filePath) { const response = await fetch(url); if (!response.ok) { throw new Error(`下载失败: ${response.status}`); } // 1. 获取响应体作为可读流 const readableStream = response.body; // 2. 创建一个写入到本地文件的流 const writableStream = fs.createWriteStream(filePath); // 3. 使用pipeline管理流,它会自动处理错误、关闭和管道连接 await pipeline(readableStream, writableStream); console.log(`文件已下载至: ${filePath}`); } // 使用示例 await downloadFile('https://example.com/large-video.mp4', './video.mp4');关键点:
response.body在Node.js环境中是一个Readable流。- 使用
stream/promises中的pipeline函数是处理流的最佳实践,它比手动监听'data'、'end'事件或使用.pipe()更安全,能妥善处理错误和清理工作。 - 这种方式内存占用非常小,无论文件多大,都只使用一个固定大小的缓冲区。
4.5 处理Cookie与会话
默认情况下,fetch不会像浏览器那样自动发送或存储Cookie。如果你需要处理基于Cookie的会话(例如模拟登录),需要手动处理Cookie请求头,并可能使用像tough-cookie这样的库来管理Cookie jar。
一个简单的示例,手动传递Cookie:
const someCookie = 'sessionId=abc123; userId=456'; const response = await fetch('https://api.example.com/protected', { headers: { 'Cookie': someCookie } }); // 从响应中读取Set-Cookie头 const setCookieHeader = response.headers.get('set-cookie'); if (setCookieHeader) { console.log('服务器设置了新的Cookie:', setCookieHeader); // 你需要解析这个字符串,并在后续请求中携带它 }对于复杂的会话管理,建议使用专门的HTTP客户端库(如axios,它内置了withCredentials选项和Cookie jar支持),或者在fetch基础上封装自己的会话逻辑。
5. 避坑指南:那些官方文档没明说的问题
在实际项目中,我踩过不少坑。下面这些问题是搜索引擎的高频词,也是开发者的血泪史。
5.1 问题一:fetch返回的Promise只在网络错误时reject
这是fetch设计上最需要适应的一点。一个返回404或500的请求,在fetch看来依然是“成功的请求”(Promise fulfilled)。你必须手动检查response.ok或response.status。
错误示范:
// 这样写,404错误会被吞掉! fetch('/api/not-found') .then(response => response.json()) .then(data => console.log(data)) // 如果404,这里会报解析错误,因为响应体不是JSON .catch(error => console.error('捕获到错误', error)); // 可能捕获的是JSON解析错误,而非HTTP错误正确做法:
fetch('/api/not-found') .then(async (response) => { if (!response.ok) { // 尝试读取错误信息,可能是文本也可能是JSON const errorText = await response.text(); throw new Error(`请求失败 ${response.status}: ${errorText}`); } return response.json(); }) .then(data => console.log(data)) .catch(error => console.error('请求全过程错误:', error));5.2 问题二:response.json()解析失败
当服务器返回的不是有效的JSON(比如HTML错误页面、空响应或纯文本),调用response.json()会抛出SyntaxError。
防御性代码:
async function safeJsonParse(response) { const contentType = response.headers.get('content-type'); if (!contentType || !contentType.includes('application/json')) { // 如果不是JSON,返回文本 const text = await response.text(); return { _raw: text }; // 或者根据业务逻辑处理 } try { return await response.json(); } catch (e) { // 即使Content-Type是JSON,也可能解析失败 console.warn('JSON解析失败,返回原始文本', e); const text = await response.text(); return { _raw: text, parseError: e.message }; } } // 使用 const response = await fetch(someUrl); const data = await safeJsonParse(response);5.3 问题三:并发限制与连接池
Node.js底层的HTTP客户端(undici, 自v18起fetch基于它)有默认的连接池限制。如果你同时发起海量(例如成千上万)的fetch请求,可能会遇到性能瓶颈甚至 socket 耗尽错误。
现象:大量请求排队,延迟增加,可能出现ECONNRESET或socket hang up错误。
解决方案:
- 控制并发量:使用如
p-limit、async库的queue或Promise.allSettled配合分片,将大量请求分批进行。import pLimit from 'p-limit'; const limit = pLimit(10); // 最多同时10个请求 const urls = [...]; // 1000个URL const promises = urls.map(url => limit(() => fetchAndProcess(url))); const results = await Promise.allSettled(promises); - 调整Agent配置(针对
node-fetch或底层http):对于node-fetch,你可以传递自定义的agent(来自http或https模块)并设置maxSockets等参数。原生fetch的配置更底层,涉及undici的选项。
5.4 问题四:SSL/TLS证书问题
在开发环境中,特别是使用自签名证书测试HTTPS服务时,fetch会抛出unable to verify the first certificate或self signed certificate错误。
警告:以下方案仅用于开发测试,生产环境必须使用有效证书。
方案A:设置 rejectUnauthorized (不推荐用于生产)通过自定义Agent来禁用证书验证(
node-fetch):import https from 'https'; import fetch from 'node-fetch'; const agent = new https.Agent({ rejectUnauthorized: false // 危险!跳过证书验证 }); const response = await fetch('https://self-signed.badssl.com', { agent });原生
fetch目前没有直接提供此选项,需要通过更复杂的方式配置底层undici的connect选项。方案B:设置环境变量NODE_TLS_REJECT_UNAUTHORIZED (更不推荐)在启动Node.js进程前设置:
NODE_TLS_REJECT_UNAUTHORIZED=0 node your-script.js这会禁用整个进程的所有TLS证书验证,极度危险,仅用于临时测试。
正确做法:在开发/测试环境,将自签名证书添加到系统的受信任根证书库,或使用工具如mkcert生成本地可信证书。
5.5 问题五:内存泄漏与资源释放
fetch请求完成后,如果响应体没有被完全读取或消费,可能会导致内存或连接资源未被及时释放。
错误示范:
// 只读取了部分数据就停止了 const response = await fetch(url); const reader = response.body.getReader(); const { value, done } = await reader.read(); // 如果没有继续读到done=true,流就没有关闭正确做法:
- 如果使用
response.json()、response.text()等便捷方法,它们会帮你消费完整个流。 - 如果使用流式接口(
response.body),确保流被完全读取或手动取消。// 使用pipeline或readableStream.destroy()确保清理 const stream = response.body; // 方案1: 使用pipeline消费到底 // 方案2: 如果中途需要停止 // stream.destroy(); // 销毁流,释放资源
5.6 常见错误信息速查表
| 错误信息 | 可能原因 | 解决方案 |
|---|---|---|
fetch is not defined | Node.js版本低于v17.5,且未安装node-fetch | 升级Node.js至v18+ LTS,或安装node-fetch库。 |
Cannot find package 'node-fetch' | 未安装node-fetch或安装失败 | 运行npm install node-fetch。检查网络和npm源。 |
Error [ERR_REQUIRE_ESM]: require() not support | 在CommonJS文件中用require()引入了node-fetchv3 | 改用ESM的import语法,或使用动态import(),或降级到node-fetch@2。 |
Unexpected token < in JSON at position 0 | response.json()解析失败,服务器返回了HTML(如404页面) | 先检查response.ok和Content-Type,再决定是否调用.json()。 |
socket hang up/ECONNRESET | 服务器过早关闭连接,或客户端并发过高 | 增加超时时间,控制请求并发量,检查服务器稳定性。 |
net::ERR_CERT_AUTHORITY_INVALID(浏览器) 或unable to verify the first certificate(Node.js) | SSL证书无效或自签名 | 开发环境可临时配置rejectUnauthorized: false(仅测试!),生产环境必须使用有效证书。 |
| 请求无限挂起,无响应 | 未设置超时,服务器无响应或网络问题 | 务必使用AbortController设置请求超时。 |
TypeError: Failed to parse URL | 提供的URL字符串格式不正确 | 检查URL是否包含非法字符,是否完整(如缺少协议http://)。使用new URL()构造函数验证。 |
6. 性能优化与高级实践
掌握了基础用法和避坑技巧后,我们可以关注如何让fetch用得更高效、更健壮。
6.1 连接复用与Keep-Alive
HTTP/1.1默认启用了Keep-Alive,Node.js的HTTP Agent也会复用TCP连接。对于原生fetch(基于undici),其连接池默认就是开启且优化的。对于node-fetch,你可以通过传递一个复用的agent来提升性能:
import https from 'https'; import fetch from 'node-fetch'; // 创建一个可复用的Agent实例 const keepAliveAgent = new https.Agent({ keepAlive: true, // 启用连接保持 maxSockets: 50, // 每个主机最大socket数 maxFreeSockets: 10, // 空闲时保持的最大socket数 }); async function makeMultipleRequests() { const options = { agent: keepAliveAgent }; // 一系列到同一主机的请求将复用连接 const req1 = fetch('https://api.example.com/endpoint1', options); const req2 = fetch('https://api.example.com/endpoint2', options); // ... }6.2 实现请求重试机制
网络不稳定,偶尔的失败是正常的。一个健壮的客户端应该具备重试能力。
async function fetchWithRetry(url, options = {}, maxRetries = 3, baseDelay = 1000) { let lastError; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), options.timeout || 10000); const response = await fetch(url, { ...options, signal: controller.signal }); clearTimeout(timeoutId); if (response.ok) { return response; // 成功,直接返回 } // 如果是服务器错误(5xx),可以考虑重试 if (response.status >= 500 && response.status < 600) { lastError = new Error(`服务器错误 ${response.status}`); } else { // 客户端错误(4xx),通常重试无意义 throw new Error(`客户端错误 ${response.status}`); } } catch (error) { lastError = error; // 如果是中止错误(超时)或网络错误,进行重试 if (error.name === 'AbortError' || error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') { console.warn(`请求失败,开始第${attempt}次重试...`, error.message); } else { // 其他错误(如语法错误、非重试的HTTP错误),直接抛出 throw error; } } // 指数退避延迟:第一次等1秒,第二次等2秒,第三次等4秒... if (attempt < maxRetries) { const delay = baseDelay * Math.pow(2, attempt - 1); await new Promise(resolve => setTimeout(resolve, delay)); } } // 所有重试都失败 throw lastError; }6.3 封装一个健壮的通用fetch工具函数
结合超时、重试、错误处理、日志,我们可以封装一个用于生产环境的fetch工具。
// utils/fetchClient.js import fetch from 'node-fetch'; // 或使用全局fetch class FetchClient { constructor(baseURL = '', defaultOptions = {}) { this.baseURL = baseURL; this.defaultOptions = { timeout: 10000, retries: 2, ...defaultOptions, }; } async request(endpoint, options = {}) { const url = `${this.baseURL}${endpoint}`; const mergedOptions = { ...this.defaultOptions, ...options }; const { timeout, retries, ...fetchOptions } = mergedOptions; let lastError; for (let i = 0; i <= retries; i++) { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); const response = await fetch(url, { ...fetchOptions, signal: controller.signal, }); clearTimeout(timeoutId); // 处理非2xx的HTTP状态码 if (!response.ok) { const errorText = await response.text().catch(() => '无法读取错误信息'); const error = new Error(`HTTP ${response.status}: ${errorText}`); error.status = response.status; // 5xx错误且还有重试次数,则继续循环 if (response.status >= 500 && i < retries) { lastError = error; console.warn(`[FetchClient] 服务器错误,准备重试 (${i+1}/${retries})`, url); await this._waitForRetry(i); continue; } throw error; // 客户端错误或重试次数用尽,直接抛出 } // 根据Content-Type决定如何解析响应 const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/json')) { return await response.json(); } else if (contentType.includes('text/')) { return await response.text(); } else { // 其他类型,返回原始的Response对象,让调用者处理 return response; } } catch (error) { lastError = error; // 网络错误、超时错误,且还有重试次数 if ((error.name === 'AbortError' || error.code === 'ECONNRESET') && i < retries) { console.warn(`[FetchClient] 网络错误,准备重试 (${i+1}/${retries})`, url, error.message); await this._waitForRetry(i); continue; } // 其他错误或重试次数用尽,跳出循环 break; } } // 所有重试都失败 throw lastError; } _waitForRetry(attempt) { const delay = 1000 * Math.pow(2, attempt); // 指数退避 return new Promise(resolve => setTimeout(resolve, delay)); } // 便捷方法 get(endpoint, options) { return this.request(endpoint, { method: 'GET', ...options }); } post(endpoint, body, options) { return this.request(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify(body), ...options, }); } // 可以继续添加put, delete, patch等方法... } // 导出单例或类 export const apiClient = new FetchClient('https://api.example.com/v1', { headers: { 'User-Agent': 'MyApp/1.0' }, }); // 使用示例 // try { // const user = await apiClient.get('/users/123'); // const newPost = await apiClient.post('/posts', { title: 'Hello' }); // } catch (error) { // console.error('API请求失败:', error.status, error.message); // }这个封装提供了基础URL、默认配置、自动重试、超时、错误分类和响应内容类型判断,是一个不错的起点,你可以根据项目需求进一步扩展。