【翻译】Node.js 发起 HTTP 请求的完整指南

5 阅读17分钟

原文链接:How to make an HTTP request in Node.js

作者:Luciano Mammino

本文将教你如何在 Node.js 中使用内置的 fetch() API、http/https 模块发起 HTTP 请求,涵盖 POST 请求、身份验证、流式传输以及请求测试等内容,并附带完整代码示例。

目录

  • 快速答案:使用 fetch()
  • 使用内置 fetch() API
    • 基础 GET 请求
    • 带 JSON 体的 POST 请求
    • 添加请求头与身份验证
    • 设置请求超时
    • 处理不同的响应类型
    • 请求与响应的流式传输
    • 流式上传
    • 流式下载
    • 发起多个并发请求
  • 处理常见业务场景
    • 失败请求重试
    • 表单数据与文件上传
    • 结合 FormData 流式传输大文件
    • 处理 URL 查询参数
  • 模拟 HTTP 请求进行测试
    • 使用 MockAgent 实现基础模拟
    • 模拟 POST 请求
  • 最佳实践
  • 性能考量
  • 何时使用第三方库
  • 使用 httphttps 模块
    • 基于 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)
}

这个示例有几个重要要点需要注意:

  1. fetch() 会返回一个 Promise,该 Promise 解析后得到一个 Response 对象
  2. 该 Promise仅在网络错误时会拒绝,对语义化的 HTTP 错误(4xx、5xx 状态码)不会拒绝。这个区别至关重要:网络错误表示请求完全无法完成(DNS 解析失败、连接被拒绝、超时),而语义化 HTTP 错误表示请求已到达服务器并得到了合法的 HTTP 响应,只是该响应表明请求处理出现了问题(客户端错误 4xx 或服务端错误 5xx)
  3. 务必检查 response.ok 来处理语义化 HTTP 错误
  4. 使用 .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 会使用 CredentialSignedHeadersSignature 等特殊请求头对请求进行签名。

你可能会疑惑示例中的 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 处理类文件对象的方式,在浏览器中无法使用(浏览器有自己的 File API)。

处理 URL 查询参数

构建带查询参数的 URL 时,人们很容易手动拼接字符串:${baseUrl}?query=${userInput}。这种方式非常危险:如果 userInput 包含 &=# 等特殊字符,你的 URL 会失效或行为异常。更严重的是,如果输入来自用户,攻击者可能会注入额外的参数或篡改 URL 结构。

务必使用 URLURLSearchParams 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 有效且安全。该工具函数还会跳过 undefinednull 值,这在部分参数为可选时非常有用。

模拟 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)中导入 describeitbeforeEach,并导入 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 库都适用:

  1. 始终处理错误:网络请求可能失败,使用 try/catch 捕获异常并检查响应状态码
  2. 设置请求超时:不要让请求无限期挂起,使用 AbortController 或库的超时选项
  3. 验证响应数据:不要假设响应的结构,在访问属性前先进行验证
  4. 通过环境变量管理 URL 和令牌:永远不要硬编码 API 密钥或敏感的 URL
  5. 考虑限流规则:遵守 API 的限流规则,为重试实现退避策略
  6. 开发环境中记录请求:添加日志便于调试问题,但注意不要记录敏感数据

以下是一个实现了这些最佳实践的包装函数示例:

// 示例:包含最佳实践的请求包装函数
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 网关、代理,或每秒发起数千个请求的服务),可以考虑:

  1. 直接使用 undici.request() 以获得最大吞吐量
  2. 使用带连接池的 http/https 模块
  3. 在优化前先对具体的业务负载进行性能分析

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() 略有不同,但这些差异在服务端代码中几乎可以忽略。

使用 httphttps 模块

上面的示例均使用了 fetch(),这是现代 Node.js 的推荐方式。但在以下场景中,你可能会遇到或需要使用更底层的 http/https 模块:

  1. 使用 Node.js 18 之前的版本,此时 fetch() 不可用
  2. 与期望接收 http.IncomingMessagehttp.ClientRequest 对象的库集成
  3. 将遗留代码库逐步迁移到现代开发模式
  4. 高级使用场景,需要直接访问底层套接字或连接

node:httpnode: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())主要适用于以下场景:

  1. 使用 Node.js 18 之前的版本
  2. 维护遗留代码库
  3. 与期望接收 http.IncomingMessagehttp.ClientRequest 对象的库集成
  4. 构建高吞吐量系统,需要极致性能(fetch 因 WebStreams 存在额外开销)

现代的 fetch() 也支持流式传输,因此这不再是选择旧模块的理由 —— 但原始吞吐量方面,底层 API 仍更具优势。

在 Node.js 中发起 HTTP 请求需要使用 axios 或 got 吗?

不需要。现代 Node.js(18+)内置的 fetch() API 能处理大多数使用场景。axios、got 等第三方库是可选的,主要适用于需要自动重试、请求拦截器、进度事件等高级特性的场景。