原文链接:How to make an HTTP request in Node.js
本文将教你如何在 Node.js 中使用内置的 fetch() API、http/https 模块发起 HTTP 请求,涵盖 POST 请求、身份验证、流式传输以及请求测试等内容,并附带完整代码示例。
目录
- 快速答案:使用
fetch() - 使用内置
fetch()API- 基础 GET 请求
- 带 JSON 体的 POST 请求
- 添加请求头与身份验证
- 设置请求超时
- 处理不同的响应类型
- 请求与响应的流式传输
- 流式上传
- 流式下载
- 发起多个并发请求
- 处理常见业务场景
- 失败请求重试
- 表单数据与文件上传
- 结合 FormData 流式传输大文件
- 处理 URL 查询参数
- 模拟 HTTP 请求进行测试
- 使用 MockAgent 实现基础模拟
- 模拟 POST 请求
- 最佳实践
- 性能考量
- 何时使用第三方库
- 使用
http和https模块- 基于 https 的基础 GET 请求
- 基于 http.request () 的 POST 请求
- 结合 http/https 实现流式传输
- 总结
- 常见问题解答
我记得很久以前,自己一直用 request 包(现已废弃)在 Node.js 中发起 HTTP 请求,后来 Promise 成为主流,就切换到了 request-promise(同样已废弃)。再到后来,我开始使用 axios,本以为会一直用下去…… 但时代总在变化。Node.js 中的 HTTP 相关能力一直在演进,而且理由十分充分!
发起 HTTP 请求是 Node.js 开发中最常见的操作之一。无论是调用 REST API、从外部服务获取数据,还是开发网络爬虫,你都需要掌握高效的实现方式。好消息是,从 Node.js 18 版本开始,Web 标准的 fetch() API 已作为内置全局方法提供。如果你在浏览器中用过 fetch(),那在服务端使用它对你来说会轻车熟路 —— 无需额外依赖,无需封装层,熟悉的 API 就能让你用现代方式在 Node.js 中完成各类 HTTP 操作。
但从「本地能跑」到「生产可用」之间,还藏着无数的边缘情况陷阱:永不结束的挂起请求、未处理的网络错误、因未处理流导致的内存泄漏、会造成重复订单的重试逻辑,以及意外调用真实 API 的测试用例。本指南会教你所有将 HTTP 相关代码安全部署到生产环境的知识,不仅包括基础用法,还会讲解那些只有出问题时才会暴露的设计模式、潜在陷阱和测试策略。
前置要求
本文中的示例使用了顶层
await语法,该语法需要基于 ESM(ECMAScript 模块)运行。要运行这些示例,你需要满足以下任一条件:
- 在
package.json中设置"type": "module"- 将文件扩展名改为
.mjs所有示例均基于 Node.js 18 及以上版本编写。
快速答案:使用 fetch()
我知道你没时间深入研究 Node.js 发起 HTTP 请求的所有细节,所以先给你想要的快速答案:
如果你使用的是 Node.js 18 及以上版本,发起 HTTP 请求最简单的方式就是使用内置的 fetch() 函数:
const response = await fetch('https://api.example.com/data')
const data = await response.json()
console.log(data)
就是这么简单,无需安装任何包,无需复杂配置。但如果直接将这段代码部署到生产环境,你可能会遇到各种问题:请求挂起、静默失败、未处理流导致的内存泄漏等等。继续往下读,学习能让你自信地将 HTTP 相关代码部署到生产环境的设计模式。
使用内置 fetch() API
正如我们刚才所说,在现代 Node.js 中,fetch() 是发起 HTTP 请求的推荐方式。接下来我们深入讲解高效使用它的各类细节。
从 Node.js 18 开始,fetch() API 已全局可用,无需任何导入操作。这和你在浏览器 JavaScript 中使用的 fetch() 是同一个 API,上手简单、使用熟悉。
基础 GET 请求
fetch-get.js
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts/1')
if (!response.ok) {
throw new Error(`HTTP 错误!状态码: ${response.status}`)
}
const data = await response.json()
console.log('文章标题:', data.title)
} catch (error) {
console.error('请求失败:', error.message)
}
这个示例有几个重要要点需要注意:
fetch()会返回一个 Promise,该 Promise 解析后得到一个 Response 对象- 该 Promise仅在网络错误时会拒绝,对语义化的 HTTP 错误(4xx、5xx 状态码)不会拒绝。这个区别至关重要:网络错误表示请求完全无法完成(DNS 解析失败、连接被拒绝、超时),而语义化 HTTP 错误表示请求已到达服务器并得到了合法的 HTTP 响应,只是该响应表明请求处理出现了问题(客户端错误 4xx 或服务端错误 5xx)
- 务必检查
response.ok来处理语义化 HTTP 错误 - 使用
.json()、.text()或.blob()解析响应体并将其加载到内存中
为什么 fetch 需要两步操作?
有些 HTTP 库会封装这种复杂性,一次调用就返回完整的响应体,那为什么 fetch 要拆成两步呢?这是因为 HTTP 协议将响应分为响应头和响应体两部分。
第一次调用
fetch()会建立连接、发送请求,并仅解析响应头。此时你可以检查状态码,再决定是否继续从底层套接字读取响应体。如果状态码异常,你可以立即停止读取,节省带宽。响应体可能非常大(比如下载视频文件或 AI 模型文件),因此你可以自主选择读取方式:
- 分块流式处理响应(例如写入文件),适合大体积响应体
- 对于小体积响应体(最大几 MB),直接加载为二进制、文本或 JSON 格式
带 JSON 体的 POST 请求
当需要向请求附加数据时,会使用 POST 请求。请求会包含一个承载负载的请求体,其编码格式由服务器的预期决定,你需要通过 Content-Type 请求头告知服务器数据的编码方式。
本示例中我们使用 JSON 格式(application/json),这是 API 最常用的格式。其他常见的内容类型还包括:
multipart/form-data:用于文件上传application/x-www-form-urlencoded:用于传统 HTML 表单提交
fetch-post.js
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: '我的新文章',
body: '这是我的文章内容。',
userId: 1,
}),
})
if (!response.ok) {
throw new Error(`HTTP 错误!状态码: ${response.status}`)
}
const data = await response.json()
console.log('创建的文章:', data)
} catch (error) {
console.error('创建文章失败:', error.message)
}
添加请求头与身份验证
在上一节中,我们已经看到了如何添加请求头来指定内容类型。但请求头还有其他重要的使用场景,尤其是身份验证。
fetch-auth.js
const token = process.env.API_TOKEN
try {
const response = await fetch('https://api.example.com/protected', {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
'X-Custom-Header': 'custom-value',
},
})
if (response.status === 401) {
throw new Error('未授权:请检查你的 API 令牌')
}
if (!response.ok) {
throw new Error(`HTTP 错误!状态码: ${response.status}`)
}
const data = await response.json()
console.log(data)
} catch (error) {
console.error('请求失败:', error.message)
}
上面展示的 Authorization: Bearer <token> 模式被称为Bearer 令牌认证,定义在 RFC 6750 中,是 REST API 最常用的认证方式。
一些服务(如 AWS)会使用更复杂的认证方案,例如 AWS Signature V4 会使用 Credential、SignedHeaders、Signature 等特殊请求头对请求进行签名。
你可能会疑惑示例中的 Accept 请求头的作用:Accept 请求头用于告知服务器你偏好的响应格式。例如,部分服务器可以根据该请求头返回 JSON、XML 或纯文本格式的数据,在使用针对大语言模型优化的 API 和网站时,这一点尤为重要。如果没有设置正确的 Accept 请求头,服务器可能会默认返回冗长的 HTML 格式,造成宝贵令牌的浪费。你可以在 MDN 上了解更多关于 Accept 请求头的知识。
设置请求超时
不让请求无限期挂起是良好的开发实践,尤其是当 HTTP 请求处于业务流程的关键链路时,一个挂起的请求会拖慢后续所有操作。
现代 Node.js 提供了一种简单的方式,通过 AbortSignal.timeout() 设置请求超时:fetch-timeout.js
try {
const response = await fetch('https://api.example.com/data', {
signal: AbortSignal.timeout(5000), // 5 秒后中止请求
})
if (!response.ok) {
throw new Error(`HTTP 错误!状态码: ${response.status}`)
}
const data = await response.json()
console.log(data)
} catch (error) {
if (error.name === 'TimeoutError') {
console.error('请求超时')
} else {
console.error('请求失败:', error.message)
}
}
AbortSignal.timeout() 从 Node.js 18(或 17.3+)版本开始可用。
处理不同的响应类型
还记得我们之前讨论的两步操作流程吗?以下是针对常见场景的简洁处理方式:fetch-response-types.js
// JSON 响应
const jsonData = await fetch(url).then((res) => res.json())
// 文本响应(HTML、纯文本)
const textData = await fetch(url).then((res) => res.text())
// 二进制数据(图片、文件)
const blobData = await fetch(url).then((res) => res.blob())
const arrayBuffer = await fetch(url).then((res) => res.arrayBuffer())
// 获取响应头
const response = await fetch(url)
console.log('内容类型:', response.headers.get('Content-Type'))
console.log('所有响应头:', Object.fromEntries(response.headers))
.then() 语法和第二次使用 await 效果相同,但当你不需要先检查响应状态时,这种写法可以让你写出简洁的一行代码。
请求与响应的流式传输
当处理大体积请求体(如上传视频文件)或大体积响应体(如下载数据集)时,你不会想将所有数据一次性加载到内存中。将大负载加载到内存会减慢进程速度,甚至可能因内存不足导致进程崩溃。对于高可用的系统,无论负载大小,你都希望内存使用保持稳定且可预测。流式传输允许你在数据传输过程中分块处理,任何时候都只使用一小块缓冲区。
流式上传
要在不将整个文件加载到内存的情况下上传大文件,你可以将文件直接作为请求体进行流式传输:fetch-stream-upload.js
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
async function uploadLargeFile(url, filePath) {
const fileStats = await stat(filePath)
const fileStream = createReadStream(filePath)
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': fileStats.size.toString(),
},
body: fileStream,
duplex: 'half', // 流式请求体必须设置该选项
})
if (!response.ok) {
throw new Error(`上传失败: ${response.status}`)
}
return response.json()
}
// 使用
await uploadLargeFile('https://api.example.com/upload', './large-video.mp4')
当将流作为请求体时,必须设置 duplex: 'half' 选项,它会告知 fetch 我们正在向一个方向发送数据,同时可能从另一个方向接收数据。
流式下载
对于大体积响应,你可能希望在数据到达时就进行处理,而不是等待整个响应接收完成。response.body 属性可以让你获取到一个 ReadableStream 可读流:fetch-stream.js
const response = await fetch('https://example.com/large-file')
if (!response.ok) {
throw new Error(`HTTP 错误!状态码: ${response.status}`)
}
// response.body 是一个 ReadableStream 实例
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
process.stdout.write(chunk)
}
这种方式适用于处理 NDJSON 流、服务器发送事件(SSE),或任何需要展示进度、增量处理数据的大体积响应场景。如需了解更高级的模式,可以参考我们的JavaScript 异步迭代器指南。
如果是下载文件,你可以使用 Readable.fromWeb() 将响应直接管道写入磁盘:fetch-download.js
import { createWriteStream } from 'node:fs'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
const response = await fetch('https://example.com/large-file.zip')
if (!response.ok) {
throw new Error(`HTTP 错误!状态码: ${response.status}`)
}
const nodeStream = Readable.fromWeb(response.body)
await pipeline(nodeStream, createWriteStream('./download.zip'))
console.log('下载完成!')
发起多个并发请求
当需要发起多个 HTTP 请求时,你可以通过并发执行提升性能:concurrent-requests.js
async function fetchMultipleUsers(userIds) {
const requests = userIds.map((id) =>
fetch(`https://jsonplaceholder.typicode.com/users/${id}`).then((res) =>
res.json(),
),
)
// 等待所有请求完成
const users = await Promise.all(requests)
return users
}
// 使用
const users = await fetchMultipleUsers([1, 2, 3, 4, 5])
console.log(`获取到 ${users.length} 个用户`)
users.forEach((user) => console.log(`- ${user.name}`))
如果部分请求可能失败,但你希望继续处理成功的请求,可以使用 Promise.allSettled():concurrent-requests-settled.js
async function fetchMultipleWithFallback(urls) {
const requests = urls.map((url) => fetch(url).then((res) => res.json()))
const results = await Promise.allSettled(requests)
return results.map((result, index) => {
if (result.status === 'fulfilled') {
return { url: urls[index], data: result.value, error: null }
} else {
return { url: urls[index], data: null, error: result.reason.message }
}
})
}
当处理大量请求(数百或数千个)时,一次性发起所有请求可能会压垮服务器或耗尽系统资源。这种情况下,你需要限制并发数:让请求并发执行,但同时运行的请求数不超过指定最大值。在《Node.js 设计模式》一书中,我们用多个章节讲解了这个主题,展示了如何使用回调、Promise 和 async/await 实现这些模式。你也可以阅读《Node.js 竞态条件》了解常见的并发陷阱。
处理常见业务场景
失败请求重试
网络请求可能因多种临时原因失败:服务器暂时过载、网络波动中断连接、触发限流规则,或下游服务正在重启。这些失败通常是暂时的,重新发起请求可能就会成功。
为大多数 HTTP 请求实现重试机制是良好的开发实践,尤其是关键操作。以下是一个简单的重试工具函数:fetch-retry.js
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
let lastError
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, options)
if (!response.ok) {
throw new Error(`HTTP 错误!状态码: ${response.status}`)
}
return await response.json()
} catch (error) {
lastError = error
console.log(`第 ${attempt} 次尝试失败: ${error.message}`)
if (attempt < maxRetries) {
// 指数退避:重试间隔逐渐变长
const delay = Math.pow(2, attempt) * 100
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}
throw new Error(`经过 ${maxRetries} 次尝试后仍失败: ${lastError.message}`)
}
这个 fetchWithRetry 工具函数封装了 fetch(),会自动对失败的请求进行重试,最多重试 maxRetries 次。它在重试之间使用指数退避策略(200ms、400ms、800ms……),为服务器留出恢复时间。网络错误和非 OK 状态的 HTTP 响应都会触发重试。
注意:这个示例是一个定制化实现,它始终将响应解析为 JSON,并将任何非 OK 状态码视为值得重试的错误。如需更灵活的实现,你可以返回原始响应,让调用方决定如何解析;也可以添加逻辑,仅对特定状态码(如 429 请求过多、503 服务不可用)进行重试。
幂等性警告
这个重试工具函数最适合用于幂等的 GET 请求。对于 POST/PUT/DELETE 请求,重试可能会导致重复的副作用(如创建多个订单)。此外,如果
options.body是一个流,它只能被消费一次,重试会失败。对于非幂等操作,要么禁用重试,要么实现针对特定请求的重试逻辑。
表单数据与文件上传
前面的流式上传小节展示了如何将文件作为原始字节流上传,此时整个请求体只有文件内容。这种方式适用于 API 接收原始二进制请求体的场景,但大多数实际场景中的 API 更期望接收 multipart/form-data 格式。该格式遵循 Web 标准(和设置了 enctype="multipart/form-data" 的 HTML 表单使用的编码方式相同),允许你在上传文件的同时,附带描述、标签、用户 ID 等元数据字段。
要发送 multipart/form-data 格式的请求,可使用 FormData API:fetch-upload.js
import { readFile } from 'node:fs/promises'
async function uploadFile(url, filePath) {
const fileContent = await readFile(filePath)
const formData = new FormData()
formData.append('file', new Blob([fileContent]), 'upload.txt')
formData.append('description', '我上传的文件')
const response = await fetch(url, {
method: 'POST',
body: formData,
// 注意:不要手动设置 Content-Type 请求头
// fetch 会自动为 multipart/form-data 设置包含正确边界符的请求头
})
if (!response.ok) {
throw new Error(`上传失败: ${response.status}`)
}
return await response.json()
}
内存使用警告
这个示例使用了
readFile(),它会在上传前将整个文件加载到内存中。这种方式适用于小文件(几 MB),但处理大文件时会出现问题。如果尝试用这种方式上传数 GB 的视频,进程可能会变慢甚至崩溃。如需上传大文件,请参考下面的流式传输方式。
结合 FormData 流式传输大文件
要在使用 multipart/form-data 格式的同时,避免将大文件加载到内存,你可以创建一个带有 stream() 方法的类文件对象:fetch-upload-stream-formdata.js
import { createReadStream } from 'node:fs'
import { basename } from 'node:path'
async function uploadLargeFileWithFormData(url, filePath, metadata) {
const fileName = basename(filePath)
const formData = new FormData()
// 随文件一起添加元数字段
formData.append('description', metadata.description)
formData.append('category', metadata.category)
// 将文件作为流添加(不加载到内存)
formData.append('file', {
[Symbol.toStringTag]: 'File',
name: fileName,
stream: () => createReadStream(filePath),
})
const response = await fetch(url, {
method: 'POST',
body: formData,
})
if (!response.ok) {
throw new Error(`上传失败: ${response.status}`)
}
return response.json()
}
// 使用
await uploadLargeFileWithFormData(
'https://api.example.com/videos',
'./large-video.mp4',
{ description: '我的旅行视频', category: '旅行' },
)
这里的关键技巧是创建一个带有 stream() 方法的类文件对象,该方法返回一个可读流。Node.js 的 fetch()(由 undici 驱动)能识别这种模式,会流式传输文件内容而非将其加载到内存中。[Symbol.toStringTag]: 'File' 属性让 FormData 将该对象视为有效的文件。
注意:该技巧是 Node.js 专属的,依赖于 undici 处理类文件对象的方式,在浏览器中无法使用(浏览器有自己的
FileAPI)。
处理 URL 查询参数
构建带查询参数的 URL 时,人们很容易手动拼接字符串:${baseUrl}?query=${userInput}。这种方式非常危险:如果 userInput 包含 &、=、# 等特殊字符,你的 URL 会失效或行为异常。更严重的是,如果输入来自用户,攻击者可能会注入额外的参数或篡改 URL 结构。
务必使用 URL 和 URLSearchParams API 安全地构建 URL,它们会自动处理字符编码:fetch-query-params.js
function buildUrl(baseUrl, params) {
const url = new URL(baseUrl)
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
url.searchParams.append(key, value)
}
}
return url.toString()
}
// 使用
const url = buildUrl('https://api.example.com/search', {
query: 'node.js',
page: 1,
limit: 20,
sort: 'date',
})
console.log(url)
// 输出:https://api.example.com/search?query=node.js&page=1&limit=20&sort=date
const response = await fetch(url)
这个 buildUrl 工具函数会创建一个 URL 对象,并使用 searchParams.append() 添加每个参数,该方法会自动对特殊字符进行编码。例如,查询词 c++ & c# 会被编码为 c%2B%2B%20%26%20c%23,确保 URL 有效且安全。该工具函数还会跳过 undefined 和 null 值,这在部分参数为可选时非常有用。
模拟 HTTP 请求进行测试
为发起 HTTP 请求的代码编写单元测试时,你肯定不想调用真实的 API。模拟请求能让你控制响应结果、测试错误场景,并且无需依赖网络,快速运行测试用例。
Node.js 的 fetch() 由 undici 驱动,undici 提供了 MockAgent 用于拦截请求。虽然内置的 fetch() 基于 undici 实现,但模拟工具并未全局暴露,你需要手动安装 undici 依赖才能使用:
npm install undici --save-dev
重要:尽管
fetch()是 Node.js 的内置方法,但MockAgent和其他测试工具都来自 undici 包,你必须单独安装。
结合 Node.js 内置的测试运行器,你可以无需第三方模拟库,编写高效的单元测试。
使用 MockAgent 实现基础模拟
假设你有一个获取用户数据的函数:user-service.js
export async function getUser(id) {
const response = await fetch(`https://api.example.com/users/${id}`)
if (!response.ok) {
throw new Error(`获取用户失败: ${response.status}`)
}
return response.json()
}
以下是如何通过模拟响应来测试这个函数:user-service.test.js
import assert from 'node:assert/strict'
import { afterEach, beforeEach, describe, it } from 'node:test'
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici'
import { getUser } from './user-service.js'
describe('getUser', () => {
let mockAgent
let originalDispatcher
beforeEach(() => {
originalDispatcher = getGlobalDispatcher()
mockAgent = new MockAgent()
mockAgent.disableNetConnect() // 防止意外调用真实请求
setGlobalDispatcher(mockAgent)
})
afterEach(async () => {
await mockAgent.close()
setGlobalDispatcher(originalDispatcher) // 恢复原始的调度器
})
it('传入有效 ID 时返回用户数据', async () => {
const mockUser = { id: 1, name: '爱丽丝', email: 'alice@example.com' }
mockAgent
.get('https://api.example.com')
.intercept({ path: '/users/1', method: 'GET' })
.reply(200, mockUser)
const user = await getUser(1)
assert.deepEqual(user, mockUser)
})
it('用户不存在时抛出错误', async () => {
mockAgent
.get('https://api.example.com')
.intercept({ path: '/users/999', method: 'GET' })
.reply(404, { error: '未找到' })
await assert.rejects(() => getUser(999), {
message: '获取用户失败: 404',
})
})
})
afterEach 中的清理操作至关重要:它会关闭模拟代理(释放资源)并恢复原始的调度器。这能防止测试污染(一个测试的模拟会泄露到另一个测试中),也能避免运行大量测试时出现资源泄漏。disableNetConnect() 会添加一道额外的安全屏障,确保如果测试意外尝试发起真实的 HTTP 请求,会快速失败。
我们来拆解一下这个测试文件的执行逻辑:从 Node.js 内置的测试运行器(node:test)中导入 describe、it、beforeEach,并导入 assert 用于断言。在 beforeEach 钩子中,创建一个新的 MockAgent 实例,并通过 setGlobalDispatcher() 将其注册为全局调度器。这会告知 undici(进而告知 fetch())将所有请求路由到我们的模拟代理。
然后每个测试用例通过 mockAgent.get() 定位到特定的域名,通过 .intercept() 匹配路径和请求方法,再通过 .reply() 定义模拟的响应。当我们的 getUser() 函数调用 fetch() 时,会获取到模拟的响应,而非发起真实的网络请求。
通过以下命令运行测试:
node --test user-service.test.js
模拟 POST 请求
你可以用同样的方式模拟 POST 请求,这在测试创建或更新资源的函数时非常有用:api-client.test.js
it('创建新文章', async () => {
const newPost = { title: '你好', body: '世界', userId: 1 }
const createdPost = { id: 42, ...newPost }
mockAgent
.get('https://api.example.com')
.intercept({ path: '/posts', method: 'POST' })
.reply(201, createdPost)
const response = await fetch('https://api.example.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
})
const data = await response.json()
assert.equal(data.id, 42)
})
最佳实践
了解了发起 HTTP 请求的基本方法后,我们来总结一些关键的最佳实践,帮助你编写可靠、可维护的代码。这些准则无论你使用 fetch() 还是其他 HTTP 库都适用:
- 始终处理错误:网络请求可能失败,使用 try/catch 捕获异常并检查响应状态码
- 设置请求超时:不要让请求无限期挂起,使用 AbortController 或库的超时选项
- 验证响应数据:不要假设响应的结构,在访问属性前先进行验证
- 通过环境变量管理 URL 和令牌:永远不要硬编码 API 密钥或敏感的 URL
- 考虑限流规则:遵守 API 的限流规则,为重试实现退避策略
- 开发环境中记录请求:添加日志便于调试问题,但注意不要记录敏感数据
以下是一个实现了这些最佳实践的包装函数示例:
// 示例:包含最佳实践的请求包装函数
async function apiRequest(endpoint, options = {}) {
// 实践4:从环境变量加载敏感值
const baseUrl = process.env.API_BASE_URL
const token = process.env.API_TOKEN
// 实践1:用 try/catch 捕获错误
try {
const response = await fetch(`${baseUrl}${endpoint}`, {
...options,
signal: AbortSignal.timeout(10000), // 实践2:设置10秒超时
headers: {
Authorization: `Bearer ${token}`, // 实践4:使用环境变量存储令牌
'Content-Type': 'application/json',
...options.headers,
},
})
// 实践1:检查响应状态并处理错误
if (!response.ok) {
const errorBody = await response.text()
throw new Error(`API 错误 ${response.status}: ${errorBody}`)
}
// 实践3:解析响应(调用方需验证数据结构)
return await response.json()
} catch (error) {
// 实践2:单独处理超时错误
if (error.name === 'TimeoutError') {
throw new Error(`请求 ${endpoint} 超时`)
}
throw error
}
}
性能考量
虽然 fetch() 是大多数应用的推荐选择,但你需要知道它并非性能最快的方案。内置的 fetch() 由 undici 驱动,但因遵循 fetch 规范,引入了 WebStreams 带来的开销。在基准测试中,启用长连接的 http.request() 性能比 fetch() 快 50%,而直接使用 undici.request() 性能比 fetch() 快 7-10 倍。
对于绝大多数应用来说,这个性能差异可以忽略不计 —— 网络延迟通常是性能的主要瓶颈,fetch() 带来的开发体验提升远大于性能损耗。但如果你正在构建高吞吐量的系统(API 网关、代理,或每秒发起数千个请求的服务),可以考虑:
- 直接使用
undici.request()以获得最大吞吐量 - 使用带连接池的
http/https模块 - 在优化前先对具体的业务负载进行性能分析
Node.js v22 版本通过对 WebStreams 的优化,大幅提升了 fetch 的性能,未来的版本可能会进一步缩小和底层 API 的性能差距。
何时使用第三方库
虽然 Node.js 内置能力能处理大多数使用场景,但 axios、got、ky 等第三方库提供了更多额外特性:
- 可配置策略的自动重试
- 用于日志、令牌刷新的请求 / 响应拦截器
- 大文件上传 / 下载的进度事件
- 无需 AbortController 样板代码的内置超时处理
- 开箱即用的代理支持
- 更简洁的请求取消 API
对于简单的应用,坚持使用内置的 fetch() 即可;当你需要上述特定特性,或希望减少复杂应用中的样板代码时,可以考虑使用第三方库。
如果性能是你的首要考量,建议直接通过 undici.request() 使用 undici—— 它就是驱动 Node.js 内置 fetch() 的底层库,但直接调用会绕过 WebStreams 层,使用 Node.js 原生流,因此性能会大幅提升,同时仍提供现代的、基于 Promise 的 API。其接口与 Web 标准的 fetch() 略有不同,但这些差异在服务端代码中几乎可以忽略。
使用 http 和 https 模块
上面的示例均使用了 fetch(),这是现代 Node.js 的推荐方式。但在以下场景中,你可能会遇到或需要使用更底层的 http/https 模块:
- 使用 Node.js 18 之前的版本,此时
fetch()不可用 - 与期望接收
http.IncomingMessage或http.ClientRequest对象的库集成 - 将遗留代码库逐步迁移到现代开发模式
- 高级使用场景,需要直接访问底层套接字或连接
node:http 和 node:https 模块从 Node.js 早期就存在,你仍会在许多代码库中看到它们的身影。这是一套更底层的 API,能让你直接访问请求和响应流。
两者的区别是什么?http 模块用于纯 HTTP 连接(通常是 80 端口),而 https 模块用于加密的 TLS/SSL 连接(通常是 443 端口)。对于大多数实际场景中的 API,你会使用 https 模块,因为现代服务都要求加密连接。
基于 https 的基础 GET 请求
http-get.js
import https from 'node:https'
function httpsGet(url) {
return new Promise((resolve, reject) => {
https
.get(url, (response) => {
// 处理重定向
if (
response.statusCode >= 300 &&
response.statusCode < 400 &&
response.headers.location
) {
return resolve(httpsGet(response.headers.location))
}
if (response.statusCode !== 200) {
reject(new Error(`HTTP 错误!状态码: ${response.statusCode}`))
response.resume() // 消费响应以释放内存
return
}
const chunks = []
response.on('data', (chunk) => chunks.push(chunk))
response.on('end', () => {
const body = Buffer.concat(chunks).toString()
resolve(JSON.parse(body))
})
response.on('error', reject)
})
.on('error', reject)
})
}
// 使用
const data = await httpsGet('https://jsonplaceholder.typicode.com/posts/1')
console.log('文章标题:', data.title)
这种方式比 fetch() 需要更多的代码,但能让你访问原始的流,并完全控制数据的处理方式。
基于 http.request () 的 POST 请求
如需发起 POST 请求或获得更多控制权,可以使用 http.request():http-post.js
import https from 'node:https'
function httpsPost(url, data) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url)
const postData = JSON.stringify(data)
const options = {
hostname: urlObj.hostname,
port: urlObj.port || 443,
path: urlObj.pathname + urlObj.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
}
const req = https.request(options, (response) => {
const chunks = []
response.on('data', (chunk) => chunks.push(chunk))
response.on('end', () => {
const body = Buffer.concat(chunks).toString()
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(JSON.parse(body))
} else {
reject(new Error(`HTTP ${response.statusCode}: ${body}`))
}
})
})
req.on('error', reject)
// 设置超时
req.setTimeout(10000, () => {
req.destroy(new Error('请求超时'))
})
// 写入数据并结束请求
req.write(postData)
req.end()
})
}
// 使用
const newPost = await httpsPost('https://jsonplaceholder.typicode.com/posts', {
title: '我的文章',
body: '这里是内容',
userId: 1,
})
console.log('创建的文章:', newPost)
结合 http/https 实现流式传输
http/https 模块能让你直接访问 Node.js 流。虽然 fetch() 也支持流式传输(如前文所示),但你可能会在旧代码库中看到以下这种实现模式:http-stream.js
import https from 'node:https'
import { createWriteStream } from 'node:fs'
import { pipeline } from 'node:stream/promises'
async function downloadFile(url, destPath) {
return new Promise((resolve, reject) => {
https
.get(url, async (response) => {
if (response.statusCode !== 200) {
reject(new Error(`下载失败: ${response.statusCode}`))
response.resume()
return
}
const fileStream = createWriteStream(destPath)
try {
await pipeline(response, fileStream)
console.log(`下载完成,保存至 ${destPath}`)
resolve()
} catch (error) {
reject(error)
}
})
.on('error', reject)
})
}
// 使用
await downloadFile(
'https://nodejs.org/dist/v22.0.0/node-v22.0.0.tar.gz',
'./node-source.tar.gz',
)
如需了解更多关于流式传输和文件操作的知识,可以参考我们的《Node.js 读写文件》指南。
总结
现代 Node.js 为发起 HTTP 请求提供了优秀的内置方案:
| 方法 | 最佳适用场景 | Node.js 版本 |
|---|---|---|
fetch() | 大多数场景:GET、POST、流式传输、文件下载 | 18+(推荐) |
undici.request() | 高吞吐量场景,需要极致性能 | 18+(需手动安装) |
http.request() | 非加密 HTTP、遗留系统集成、原始性能需求 | 所有版本 |
https.request() | 加密 HTTPS、遗留系统集成、原始性能需求 | 所有版本 |
对于大多数开发者来说,fetch() 是推荐选择。它和浏览器中的使用方式一致、基于 Promise 实现、支持流式传输,且无需外部依赖。但如果你需要最大的吞吐量,undici.request() 能绕过 WebStreams 的开销,性能比 fetch() 快 7-10 倍。
http.request() 和 https.request() 是所有 Node.js 版本都支持的底层替代方案,适用于遗留系统集成,或需要无依赖的原始性能的场景。两者的核心区别是,你必须显式选择协议:http.request() 用于非加密连接,https.request() 用于加密连接。而 fetch() 和 undici 会根据 URL 自动选择协议。
常见问题解答
Node.js 中发起 HTTP 请求最简单的方式是什么?
最简单的方式是使用内置的 fetch() API(Node.js 18 及以上版本可用),只需调用 fetch(url) 并 await 响应即可,无需安装任何外部包。
Node.js 有内置的 fetch 函数吗?
有。从 Node.js 18 开始,fetch() 已作为内置的全局函数提供,你无需安装 node-fetch、axios 等包即可发起 HTTP 请求。
如何在 Node.js 中发起带 JSON 体的 POST 请求?
使用 fetch(),将 method 设置为 'POST',将 Content-Type 请求头设置为 'application/json',并将数据通过 JSON.stringify() 处理后传入 body 选项。
何时应该使用 http/https 模块而非 fetch?
对于大多数场景,建议使用 fetch()。http/https 模块(或 undici.request())主要适用于以下场景:
- 使用 Node.js 18 之前的版本
- 维护遗留代码库
- 与期望接收
http.IncomingMessage或http.ClientRequest对象的库集成 - 构建高吞吐量系统,需要极致性能(fetch 因 WebStreams 存在额外开销)
现代的 fetch() 也支持流式传输,因此这不再是选择旧模块的理由 —— 但原始吞吐量方面,底层 API 仍更具优势。
在 Node.js 中发起 HTTP 请求需要使用 axios 或 got 吗?
不需要。现代 Node.js(18+)内置的 fetch() API 能处理大多数使用场景。axios、got 等第三方库是可选的,主要适用于需要自动重试、请求拦截器、进度事件等高级特性的场景。