Node.js 22 与 Web 平台 API:后端 JavaScript 正在变得更统一

过去写 Node.js,很多 API 都和浏览器世界割裂得很明显。前端用 fetch,Node 里用 httphttpsaxiosnode-fetch;前端处理流是一套,Node 处理流又是一套;前端用 AbortController 取消请求,Node 里还要看具体库支不支持。

到 2024 年,Node.js 的一个明显趋势是:越来越多 Web 平台 API 成为默认能力。Node.js 22 并不是突然改变一切,但它代表了一个方向:后端 JavaScript 和浏览器 JavaScript 的基础模型正在变得更统一。

这对工程实践很有意义。它不只是少装几个包,而是让同一套请求、取消、流处理、测试思路可以在前后端之间复用。

我自己最直观的感受是 package.json 变干净了。前两年随便起一个 Node 服务,dependenciesaxiosnode-fetchabort-controllerform-data 几乎是标配。后来升到 Node 18/20,fetch 陆续摘掉了实验警告、--experimental-fetch 这个 flag 也成了历史,把这些依赖一个个删掉之后,CI 安装时间和 node_modules 体积都明显下来了。Node 22 把这条路又往前推了一截:WebSocket 客户端默认可用,连原本要 require('node:test') 的测试运行器都更顺手了。

需要提一句版本节奏:2024-05 写到这里时,Node 22 还是 Current 版本,官方计划是在 2024-10 进入 LTS。生产环境我一般不会追偶数版本刚发布的那几个月,等进 LTS、社区把坑踩得差不多了再升。这里聊的能力大多在 18/20 上就能用,22 只是把它们从“能用但要留意”继续往“可以当默认能力”推进了一步。

fetch 成为默认能力后的变化

以前在 Node 里发请求,项目里经常会装 axios

1import axios from 'axios'
2
3const response = await axios.get('https://api.example.com/users')
4console.log(response.data)

现在很多简单场景可以直接用原生 fetch

1const response = await fetch('https://api.example.com/users')
2
3if (!response.ok) {
4  throw new Error(`HTTP ${response.status}`)
5}
6
7const users = await response.json()
8console.log(users)

这让前后端请求代码更接近。一个在浏览器里熟悉 fetch 的前端工程师,切到 Node 服务端脚本时不需要先学习另一套库。

但也要注意:fetchaxios 的行为不完全一样,迁移的时候我被坑过好几次。

最常见的坑是,HTTP 404、500 不会让 fetch 自动 reject。只要服务端返回了响应,Promise 就会正常 fulfilled。你必须自己判断 response.ok。我们组当时有个同学直接把 axios.get 替换成 fetch,结果一个返回 500 的接口被当成成功处理,错误数据往下游灌了大半天才被发现。

除此之外还有几个细节差异,我列一下当时整理的对照清单:

  • 响应体只能读一次。 response.json()response.text() 是消费流,读过一次再读会抛 Body is unusable。axios 那个 response.data 是普通对象,想读几次读几次,习惯了很容易栽。
  • 超时要自己接 AbortController axios 有 timeout 选项,fetch 原生没有,得用下面会讲到的方案。
  • 不会自动带 cookie。 同源策略那套在 Node 里不存在,但 fetch 默认不处理 cookie jar,做需要登录态的爬取/转发时要自己管。
  • 请求/响应拦截器没有。 axios 的 interceptors 很多团队用来统一塞 token、打日志,fetch 得自己封一层。
  • 错误对象信息少。 axios 的报错里能直接拿到 error.responsefetch 抛出来的网络错误(比如 DNS 失败、连接拒绝)是个干巴巴的 TypeError: fetch failed,真正的原因藏在 error.cause 里,排查时记得往里看一层。

我一般会封装一层统一请求函数:

1export async function requestJson(url, options = {}) {
2  const response = await fetch(url, {
3    ...options,
4    headers: {
5      Accept: 'application/json',
6      ...options.headers,
7    },
8  })
9
10  const body = await response.json().catch(() => null)
11
12  if (!response.ok) {
13    throw normalizeHttpError(response.status, body)
14  }
15
16  return body
17}
18
19function normalizeHttpError(status, body) {
20  return {
21    status,
22    code: body?.error?.code || 'HTTP_ERROR',
23    message: body?.error?.message || `Request failed with ${status}`,
24  }
25}

这样无论是 CLI 脚本、服务端接口还是前端请求层,错误结构都能保持一致。注意 .json().catch(() => null) 这个细节——服务端报错时返回的 body 经常不是合法 JSON(可能是 Nginx 的 HTML 错误页,也可能是空字符串),不兜住的话原本的 HTTP 错误会被一个 SyntaxError: Unexpected token < in JSON 盖掉,真正的状态码反而看不到了。

如果对端是自己人、稳定返回 JSON,倒是可以用 response.headers.get('content-type') 先判断一下,避免无脑 .catch 把真实解析错误也吞了:

1const contentType = response.headers.get('content-type') || ''
2const body = contentType.includes('application/json')
3  ? await response.json()
4  : await response.text()

至于 axios 的拦截器,迁移过来后我一般不强行复刻,而是靠这一层封装函数承担同样的职责:统一加 header、统一取 token、统一打日志。逻辑显式写在一个地方,比拦截器那种“魔法注入”更好排查——线上出问题时,至少不用去猜是哪个 interceptor 改了请求。

fetch 底层是 undici,连接池才是真正要懂的东西

很多人以为 Node 的 fetch 是浏览器那个 fetch 搬过来的,其实不是。它是构建在 undici 这个 HTTP/1.1 客户端之上的,undici 现在直接进了 Node 内核,globalThis.fetch 只是它对外暴露的 WHATWG 规范门面。理解这一层,排查很多“玄学”问题会突然变得清晰。

undici 维护着一个全局 Agent,里面是按 origin(协议+域名+端口)分的连接池。默认每个 origin 的 connections 上限是无限(但有 pipelining 等参数约束),keep-alive 连接会被复用。出过一次很隐蔽的事故:我们一个服务高频调用同一个内部接口,偶发报 UND_ERR_SOCKET / other side closed。查下来是对端 Nginx 的 keepalive_timeout 是 65s,而 undici 这边以为连接还活着,复用了一个其实已经被对端单方面关掉的 socket,请求发出去正好撞上关闭窗口。

这种问题用裸 fetch 根本没法调参,得直接拿 undici 的 Agent 来配,然后用 setGlobalDispatcher 让全局 fetch 都走它:

1import { Agent, setGlobalDispatcher } from 'undici'
2
3setGlobalDispatcher(
4  new Agent({
5    // 连接空闲多久后主动回收,要小于对端的 keepalive_timeout
6    keepAliveTimeout: 30_000,
7    keepAliveMaxTimeout: 60_000,
8    // 每个 origin 的并发连接上限,避免打爆下游
9    connections: 128,
10    connect: { timeout: 10_000 },
11  })
12)
13
14// 之后所有的 globalThis.fetch 都会走这个 dispatcher
15const res = await fetch('https://internal.api/users')

keepAliveTimeout 压到比对端的空闲超时更小,让 undici 抢在对端关闭之前自己回收连接,那次的偶发 socket 错误就消失了。这是个排查思路:HTTP 客户端的连接超时一定要比服务端的空闲超时短,否则复用到“僵尸连接”几乎是必然的。

如果只想给某一个请求单独配 dispatcher,而不影响全局,可以走 dispatcher 选项(这是 Node fetch 的非标准扩展,浏览器没有):

1import { Agent } from 'undici'
2
3const res = await fetch('https://slow.api/report', {
4  dispatcher: new Agent({ headersTimeout: 60_000, bodyTimeout: 300_000 }),
5})

顺带说一个规范层面的坑:undici 的 fetch 严格遵循 WHATWG 规范,默认最多自动跟随 20 次重定向,且跨域重定向时会按规范剥掉 Authorization 头。我们有个加了签名头的接口,对端做了一次 301 到另一个子域名,结果签名头被规范“好心”剥掉,对端 401。最后改成 redirect: 'manual' 自己处理跳转才绕过去。axios 在这点上行为不一样,迁移时这种规范级差异最容易让人摸不着头脑。

AbortController 不只是前端用

AbortController 在浏览器里常用于取消请求,在 Node 里同样很有价值。

例如给请求加超时:

1async function fetchWithTimeout(url, timeout = 8000) {
2  const controller = new AbortController()
3  const timer = setTimeout(() => controller.abort(), timeout)
4
5  try {
6    const response = await fetch(url, {
7      signal: controller.signal,
8    })
9
10    return response
11  } finally {
12    clearTimeout(timer)
13  }
14}

服务端代码尤其需要超时意识。一个外部接口挂住,如果没有超时,可能会占住连接、拖慢队列,最后把自己的服务也拖垮。某个第三方支付回调接口偶发不返回、fetch 又没设超时的场景很典型:连接一个个堆着不释放,事件循环慢慢被拖到几乎不响应,监控上看 CPU 不高、内存也不高,只是请求全部超时——这种现象光看资源指标很难联想到根因是下游那个没设超时上限的调用,得从慢请求的堆积曲线倒推回去。

补充一个 Node 18.17+ 之后的好用写法,AbortSignal.timeout() 可以省掉手动 setTimeoutclearTimeout

1const response = await fetch(url, {
2  signal: AbortSignal.timeout(8000),
3})

它在超时时抛的是 TimeoutErrorerror.name === 'TimeoutError'),和手动 controller.abort() 抛的 AbortError 能区分开。不过手动 AbortController 也别急着淘汰:当你需要把多个请求的取消信号绑在一起(比如用户离开页面、或者其中一个请求失败要连带取消其余的),手动控制器更灵活。Node 20 还有个 AbortSignal.any([...]),可以把“超时”和“外部取消”两个 signal 合成一个,我在做批量并发请求时用得不少。

更完整一点的写法,可以把超时错误标准化:

1async function getJson(url, timeout = 8000) {
2  const controller = new AbortController()
3  const timer = setTimeout(() => controller.abort(), timeout)
4
5  try {
6    const response = await fetch(url, {
7      signal: controller.signal,
8    })
9
10    const body = await response.json().catch(() => null)
11
12    if (!response.ok) {
13      throw normalizeHttpError(response.status, body)
14    }
15
16    return body
17  } catch (error) {
18    if (error.name === 'AbortError') {
19      throw {
20        status: 0,
21        code: 'REQUEST_TIMEOUT',
22        message: 'Request timeout',
23      }
24    }
25
26    throw error
27  } finally {
28    clearTimeout(timer)
29  }
30}

很多线上问题不是代码逻辑错,而是没有给不可靠的外部依赖设上限——超时多久算超时、重试几次算够、什么情况该熔断,这些策略都应该从请求层开始设计。

提醒一个容易混淆的点:超时触发 abort 之后,fetch 这个 Promise 会 reject,但底层连接的释放是 Node 帮你做的,你不用手动去 close。真正要小心的是带 body 的流式响应——如果你已经开始 response.body.getReader() 读流了,单纯 abort 外层 signal 不一定能立刻中断正在传输的数据块,长连接的 SSE/streaming 场景我会额外在读循环里检查 signal.aborted 主动 break。

重试也别裸写。我踩过的坑是对 POST 这种非幂等请求做了自动重试,结果下游收到了两笔订单。后来定下规矩:默认只重试 GET 和明确幂等的接口,并且只对网络层错误(fetch failed)和 5xx 重试,4xx 一律不重试——重试一个 400 没有任何意义,只会放大压力。

还有个不写代码就看不出来的事故。AbortControllersignal 也是个 EventTarget,你每次 addEventListener('abort', ...) 挂上去的监听器,如果绑定的是一个长生命周期的 controller(比如全局的、或者跟着某个长连接走的),而请求自己结束后没把监听器摘掉,监听器数组就会一直长。我们一个 WebSocket 网关里,每条消息都拿同一个“连接级” signal 去 AbortSignal.any([msgSignal, connSignal]),跑了几小时后内存稳步上涨,heap snapshot 里全是 AbortSignal 和闭包。原因是 AbortSignal.any() 会在传入的每个 signal 上注册监听,而 connSignal 活得很久,每条消息都往它身上挂一个,从不卸载。

修法是给每个请求一个独立的、用完即弃的 controller,让它能被 GC:

1function perRequestSignal(connSignal, timeoutMs) {
2  const timeout = AbortSignal.timeout(timeoutMs)
3  // 三个里任意一个触发就 abort;返回的 signal 随请求结束一起被回收
4  return AbortSignal.any([connSignal, timeout])
5}

关键不在这段代码,而在于认知:AbortSignal.any() 合出来的 signal 会持有对源 signal 的引用,长寿命的源 signal 会把短寿命的合成 signal 拖住。不同 Node 版本在 AbortSignal 创建和监听器管理上的实现细节会变化,最保险的还是别让请求级对象去长期挂靠在连接级/进程级对象上。判断方法很简单:signal.eventListeners?.length 或者直接在 process 里打 getEventListeners(signal),数字只涨不跌就是漏了。

Web Streams 与 Node Streams

Node 早就有自己的 Stream 模型,但 Web Streams 的普及让跨运行时流处理更统一。

比如读取一个大响应,不必一次性加载到内存:

1const response = await fetch('https://example.com/large-file.txt')
2const reader = response.body.getReader()
3
4while (true) {
5  const { done, value } = await reader.read()
6
7  if (done) break
8
9  console.log('chunk size:', value.length)
10}

这类能力对大文件下载、日志流、AI 流式输出都很重要。2024 年很多前端同学第一次认真接触流,不是因为文件下载,而是因为大模型接口的 streaming response。

我去年接了个对接大模型的需求,OpenAI 那种 text/event-stream 返回,最早的写法是 await response.text() 整段拿下来再切,体验是用户盯着转圈等十几秒一次性蹦出来。改成边读边吐之后,首字延迟从几秒降到几百毫秒,产品同学当场就觉得“快了好多”,其实总耗时一点没变,只是把等待感拆碎了。

SSE 流解析有个坑:getReader().read() 给你的 chunk 切分点和 SSE 消息之间的分隔符(\n\n)完全不对齐,一个 data: 事件可能被切成两个 chunk,两个事件也可能挤在一个 chunk 里。必须自己维护一个 buffer 做粘包/拆包,再配合 TextDecoder 处理多字节字符恰好卡在两个 chunk 之间被截断的情况:

1const reader = response.body.getReader()
2const decoder = new TextDecoder()
3let buffer = ''
4
5function consumeEvents(chunkText) {
6  buffer += chunkText
7
8  let index
9  while ((index = buffer.indexOf('\n\n')) !== -1) {
10    const rawEvent = buffer.slice(0, index)
11    buffer = buffer.slice(index + 2)
12
13    for (const line of rawEvent.split('\n')) {
14      if (line.startsWith('data:')) {
15        const data = line.slice(5).trim()
16        if (data === '[DONE]') return true // 上游显式结束
17        handleChunk(JSON.parse(data))
18      }
19    }
20  }
21  return false
22}
23
24while (true) {
25  const { done, value } = await reader.read()
26
27  if (done) {
28    // 流结束了,但 decoder 内部可能还压着半个多字节字符没吐出来,
29    // 必须再调用一次不带参数的 decode() 去 flush;漏了这一步,
30    // 结尾恰好卡在多字节字符中间的那条消息会静默丢字节甚至报 JSON.parse 错误。
31    const tail = decoder.decode()
32    if (tail) consumeEvents(tail)
33    // buffer 里如果还剩最后一段没有被 \n\n 触发的完整数据,
34    // 说明上游没按协议收尾(没发 \n\n 或者连接被提前掐断),
35    // 这段残留内容不能直接丢,至少要按“不完整事件”处理掉
36    if (buffer.trim()) {
37      for (const line of buffer.split('\n')) {
38        if (line.startsWith('data:')) {
39          const data = line.slice(5).trim()
40          if (data && data !== '[DONE]') handleChunk(JSON.parse(data))
41        }
42      }
43    }
44    break
45  }
46
47  if (consumeEvents(decoder.decode(value, { stream: true }))) return
48}

decoder.decode(value, { stream: true }) 这个 stream: true 别漏——中文、emoji 这种多字节字符一旦被两个 chunk 拦腰切开,不加这个标志就会解码出乱码的“�”,而且这种 bug 偶发、难复现,等到测出来已经是几天后了。但光加 stream: true 还只解决了一半问题:它会把跨 chunk 截断的字节暂存在 TextDecoder 内部,等下一块数据来了再拼起来吐出正确字符——可如果这个截断恰好发生在最后一块 chunk 上,根本没有“下一块”来触发拼接,decoder 内部就会一直攥着这几个字节不放。我最早的实现里 done 为真直接 break,从没在循环结束后补一次无参数的 decoder.decode(),结果表现是:绝大多数流都正常,唯独消息结尾恰好落在一个中文字符中间的极少数请求,最后一个字会丢或者变乱码,排查了很久才想到问题出在收尾没 flush。无参数调用 decode()TextDecoder 规范里专门留的收尾动作,等价于告诉它“流已经结束,把手里攒着的字节都吐出来”。同理,buffer 里那句没被 \n\n 触发的残留内容也不能悄悄丢掉,哪怕上游没按协议规规矩矩发送结束分隔符,也该按最后一条不完整消息尽量处理,而不是随着函数返回一起被垃圾回收。

一个服务端转发流的例子:

1export async function proxyStream(request, response) {
2  const upstream = await fetch('https://api.example.com/stream')
3
4  if (!upstream.ok) {
5    response.statusCode = upstream.status
6    response.end('upstream failed')
7    return
8  }
9
10  for await (const chunk of upstream.body) {
11    response.write(chunk)
12  }
13
14  response.end()
15}

流式处理的关键是不要过早把数据聚合成完整字符串。能边到边传,就不要等全部拿完再返回。

这个转发例子里还藏着一个生产环境一定会遇到的问题:背压(backpressure)for await...of 配合 response.write() 看着简单,但如果客户端下载得慢,而上游推得快,response.write() 返回 false 时数据会在内存里堆积。for await 循环本身会在 await 处自然暂停,所以这种写法其实已经隐式处理了一部分背压——但更稳妥的是直接用 pipeline,它会替你管理整条链路的暂停与恢复,还能在任一端出错时正确清理:

1import { pipeline } from 'node:stream/promises'
2import { Readable } from 'node:stream'
3
4export async function proxyStream(request, response) {
5  const upstream = await fetch('https://api.example.com/stream')
6
7  if (!upstream.ok) {
8    response.statusCode = upstream.status
9    response.end('upstream failed')
10    return
11  }
12
13  // Web ReadableStream -> Node Readable,两套流模型在这里打通
14  await pipeline(Readable.fromWeb(upstream.body), response)
15}

Readable.fromWeb() 和反方向的 Readable.toWeb() 是这两年我用得最多的两个桥接 API。Web Streams 和 Node Streams 短期内不会合并成一套,但有了这两个转换函数,至少能在两套模型之间随时切换,不用为了统一流模型把整个数据管道重写。

node:test 让小项目少一个依赖

Node 内置测试工具也越来越可用。对于库、脚本、小型服务,不一定一开始就上 Jest 或 Vitest。

1import test from 'node:test'
2import assert from 'node:assert/strict'
3
4function sum(a, b) {
5  return a + b
6}
7
8test('sum numbers', () => {
9  assert.equal(sum(1, 2), 3)
10})

异步测试也很直接:

1test('requestJson throws on 500', async () => {
2  await assert.rejects(
3    () => requestJson('https://api.example.com/error'),
4    (error) => error.status === 500
5  )
6})

node:test 还自带了不少之前要靠 Jest 才有的能力,我常用的几个:

  • --watch 监听模式node --test --watch 改完代码自动重跑,写库的时候很顺手。
  • 内置 mockimport { mock } from 'node:test'mock.method()mock.timers 这些够覆盖大部分场景,不用再装 sinon。
  • 覆盖率node --test --experimental-test-coverage 直接出覆盖率报告,省掉 c8/nyc。
  • describe/it 风格:从 node:test 里也能导出 describeit,从 Jest/Mocha 迁过来的人不用改思维模型。
1import { test, mock } from 'node:test'
2import assert from 'node:assert/strict'
3
4test('retries once on 500', async () => {
5  const fetchMock = mock.fn(async () => ({ ok: false, status: 500 }))
6  await assert.rejects(() => withRetry(fetchMock))
7  assert.equal(fetchMock.mock.callCount(), 2)
8})

要说短板,node:test 的报错输出和 IDE 集成确实还没 Vitest 那么顺,TS 项目里还得自己接 --import tsx 或类似的 loader。所以我的实际选择是:纯 JS 的库、CLI、内部小工具用 node:test,省一整套配置;前端项目或重度依赖快照、jsdom、组件测试的,还是 Vitest。

如果项目已经有成熟测试体系,不需要为了内置测试工具迁移。但新写一个 Node 脚本库、内部工具或轻量服务时,node:test 能减少依赖和配置。

权限、环境变量和该收多紧

Node 的能力很强,可以读写文件、访问网络、执行子进程。强能力意味着更需要主动收着用,而不是默认放开。

服务端代码里经常会出现这样的配置读取:

1const apiKey = process.env.API_KEY
2
3if (!apiKey) {
4  throw new Error('Missing API_KEY')
5}

我建议启动阶段就校验环境变量,而不是等某个请求进来后才报错:

1function getEnv(name) {
2  const value = process.env[name]
3
4  if (!value) {
5    throw new Error(`Missing required env: ${name}`)
6  }
7
8  return value
9}
10
11export const config = {
12  apiKey: getEnv('API_KEY'),
13  databaseUrl: getEnv('DATABASE_URL'),
14}

这类代码看起来朴素,但能避免很多“线上某个接口才报错”的问题。我更倾向于把它放在应用启动的最前面,校验不过直接让进程退出——快速失败(fail fast)比带病运行强得多。容器编排里这点尤其有用:进程一启动就因为缺配置退出,K8s 的健康检查会拦住这个 Pod,流量根本不会打进来,比上线后某个深夜的请求才把问题暴露出来安全得多。

Node 20.6 之后还有个我很喜欢的内置能力:--env-file。本地开发不用再装 dotenv,直接 node --env-file=.env server.js 就把 .env 灌进 process.env 了:

1node --env-file=.env server.js

需要注意它和 dotenv 的行为不完全一样——--env-file 默认不会覆盖已经存在的环境变量(这其实是好事,CI 注入的真实变量不会被本地文件盖掉),但它不支持 dotenv 那种变量插值 ${OTHER}。生产环境我还是倾向于走平台注入(K8s Secret、Vault 之类),.env 文件只在本地用,千万别提交进仓库。

另外提一句权限收紧:Node 20 引入的权限模型(--experimental-permission)可以限制脚本对文件系统、子进程、网络的访问,跑不可信脚本或者 CI 里执行第三方代码时值得开。我目前还没在主服务上常态启用,因为粒度和生态支持还在演进,但它代表的方向——“强能力默认收一收”——和这一节想说的思路是一致的。

那些悄悄变成全局的小 API

除了几个大块头,Node 这两年还塞进来一批 Web 标准的小工具,它们单独看不起眼,但拼起来能砍掉好几个老依赖,而且都是全局可用、不用 require

crypto.randomUUID() 早就让 uuid 这个包在大多数场景下没必要了;structuredClone() 替掉了 JSON.parse(JSON.stringify(x)) 这种丢失 DateMapundefined 的山寨深拷贝;Web CryptoglobalThis.crypto.subtle)让签名、哈希这类逻辑能在 Node 和浏览器、以及 Cloudflare Workers/Deno 这些 runtime 之间直接复用。

举个实际收益:以前在 Node 里算 HMAC 签名用 node:cryptocreateHmac,那套 API 浏览器没有,前端要验签得另写一份。换成 Web Crypto 后,同一段代码两边都能跑:

1async function hmacSha256(secret, message) {
2  const enc = new TextEncoder()
3  const key = await crypto.subtle.importKey(
4    'raw',
5    enc.encode(secret),
6    { name: 'HMAC', hash: 'SHA-256' },
7    false,
8    ['sign']
9  )
10  const sig = await crypto.subtle.sign('HMAC', key, enc.encode(message))
11  // 转成 hex,和后端常见的签名格式对齐
12  return [...new Uint8Array(sig)]
13    .map((b) => b.toString(16).padStart(2, '0'))
14    .join('')
15}

这里有个安全细节值得说:验签千万别用 === 直接比字符串,那是时序攻击的经典口子——比较在第一个不同字符处就提前返回,攻击者能通过测响应时间一位位猜出签名。node:crypto 给了 timingSafeEqual 做常量时间比较:

1import { timingSafeEqual } from 'node:crypto'
2
3function safeEqual(a, b) {
4  const ba = Buffer.from(a)
5  const bb = Buffer.from(b)
6  // 长度不等时 timingSafeEqual 会抛错,得先挡一层;
7  // 注意先比长度本身也会泄露长度信息,但签名长度通常是固定的,可接受
8  if (ba.length !== bb.length) return false
9  return timingSafeEqual(ba, bb)
10}

Web Crypto 的 subtle 目前没有等价的常量时间比较 API,所以涉及验签这种安全敏感的比较,我还是落回 node:crypto。这也是“统一”还没覆盖到的地方:基础原语能跨 runtime 复用,但安全相关的细节往往还得依赖具体平台的实现,不能想当然地认为 Web 标准已经全覆盖了。

什么时候还需要第三方库

原生 API 变多,不代表第三方库没有价值。

fetch 适合基础请求,但如果项目需要拦截器、复杂重试、请求签名、上传进度、统一取消管理,成熟请求库仍然有意义。内置测试适合轻量测试,但大型前端项目可能仍然更适合 Vitest。Node 原生能力是底座,不是所有场景的完整解决方案。

我的判断标准是:如果原生 API 能清楚表达需求,就优先原生;如果业务需要大量横切能力,再引入库。不要为了“少依赖”把一堆复杂逻辑手写到项目里,也不要为了习惯装一个其实没必要的包。

Node.js 22 代表的趋势,是后端 JavaScript 和 Web 平台越来越靠近。fetchAbortController、Web Streams、内置测试工具,让很多基础能力变得更统一。

这对开发体验是好事,但请求没设超时会把事件循环慢慢拖到几乎不响应,流读到最后一块忘了 flush 会把一个字悄悄丢掉,把请求级的 signal 挂在连接级对象上会让内存只涨不跌——这些坑不会因为 API 变成了 Web 标准就自动消失,还是得一个个踩过、补上检查才算数。