React 基础体系 · 第 68/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

Next.js Node 与 Edge Runtime:API、限制、依赖和部署选择

Next.js 中的 Runtime 是执行服务端代码的运行时环境。它决定代码能够调用哪些 API、依赖如何被打包、请求可以部署到哪里,以及某些框架能力是否可用。

在现代 Next.js App Router 中,常见的服务端代码包括:

  • React Server Component;
  • Route Handler,例如 app/api/users/route.ts
  • Server Action;
  • 页面和布局中的服务端数据获取逻辑;
  • 中间件或代理逻辑。

这些代码默认通常使用 Node.js Runtime,也可以在部分路由或服务端入口中声明使用 Edge Runtime。两者都在服务端执行,但它们不是同一个 JavaScript 环境。

本文使用 React 19、现代 TypeScript 和 Next.js App Router 的写法。具体 Next.js 版本、部署平台和适配器可能影响个别能力,因此代码中的运行时声明应以项目当前版本文档和构建结果为准。


一、先区分三个边界:浏览器、React Server Component 和 Runtime

理解 Node 与 Edge Runtime,首先不能把以下三个概念混为一谈:

  1. 浏览器运行时:执行带有 "use client" 的客户端组件;
  2. React Server Component(RSC)执行环境:执行服务端组件并生成 RSC Payload;
  3. Next.js Runtime:决定服务端 JavaScript 使用 Node.js 还是 Edge 环境执行。

一个客户端组件可以调用浏览器 API,但不能直接读取服务端环境变量或数据库。一个服务端组件可以读取数据库,但不能直接使用 window。而服务端组件本身究竟运行在 Node 还是 Edge,则由 Next.js 路由或服务端入口的运行时配置决定。

典型请求过程如下:

sequenceDiagram
    participant B as 浏览器
    participant N as Next.js 路由
    participant R as Node 或 Edge Runtime
    participant D as 数据库/外部 API

    B->>N: 请求页面或 API
    N->>R: 执行 Server Component / Route Handler
    R->>D: 查询数据或调用外部服务
    D-->>R: 返回结果
    R-->>N: HTML、RSC Payload 或 Response
    N-->>B: 返回页面或 API 响应
    B->>B: 执行 Client Component

这里的 R 可能是 Node,也可能是 Edge。浏览器不会因为某个路由使用 Edge Runtime 就获得 Node API;Edge 代码仍然是在服务端执行的。

use client 不是 Runtime 声明

'use client'

export function SearchBox() {
  return <input placeholder="Search" />
}

'use client' 定义的是 React 组件边界:该文件及其客户端依赖会进入浏览器端 bundle。它不是:

export const runtime = 'edge'

后者定义的是 Next.js 服务端路由的执行运行时。

因此:

  • 'use client' 不能让代码使用 Node.js API;
  • runtime = 'edge' 也不会让组件在浏览器中运行;
  • 客户端组件通常通过 HTTP、Server Action 或父级服务端组件获取服务端数据。

二、Node.js Runtime 是什么

Node.js Runtime 是 Next.js 使用 Node.js 执行服务端代码的环境。它拥有 Node.js 提供的标准库和生态系统兼容性,例如:

import fs from 'node:fs/promises'
import path from 'node:path'
import { createHash } from 'node:crypto'

Node Runtime 通常可以使用:

  • node:fsnode:pathnode:crypto 等 Node 内置模块;
  • 依赖 Node API 的 npm 包;
  • 依赖原生扩展或系统库的包,前提是部署平台能提供相应二进制环境;
  • TCP、文件系统、进程等由 Node 或部署平台支持的能力。

但是“使用 Node Runtime”不等于“拥有一台永不退出的服务器”。如果应用部署为 serverless function,每次实例可能是临时创建的,文件系统可能是临时的,进程内内存也不能作为可靠的共享存储。

Node Runtime 的核心特征是 兼容性较强,而不是自动保证:

  • 请求一定在固定机器处理;
  • 本地文件一定持久化;
  • 全局变量一定在请求之间保留;
  • 数据库连接一定永远有效。

Node Runtime 的 Route Handler 示例

目录结构:

app/
└── api/
    └── digest/
        └── route.ts

app/api/digest/route.ts

import { createHash } from 'node:crypto'

export const runtime = 'nodejs'

export async function POST(request: Request) {
  const body = await request.text()

  if (body.length > 1024 * 1024) {
    return Response.json(
      { error: 'request body is too large' },
      { status: 413 },
    )
  }

  const digest = createHash('sha256')
    .update(body, 'utf8')
    .digest('hex')

  return Response.json({ sha256: digest })
}

发送请求:

curl -X POST \
  -H 'content-type: text/plain' \
  --data 'hello' \
  http://localhost:3000/api/digest

预期结果:

{
  "sha256": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
}

每一步成立的原因是:

  1. runtime = 'nodejs' 要求该路由使用 Node Runtime;
  2. node:crypto 是 Node 内置模块,不是浏览器 Web API;
  3. request.text() 读取标准 Fetch API 的请求体;
  4. createHash 对 UTF-8 字节流计算 SHA-256;
  5. Response.json 将结果序列化为 HTTP JSON 响应。

这里的请求体大小检查是应用层限制。它不能替代反向代理、平台函数和网关的请求大小限制;真实部署中,最先拒绝请求的可能是平台,而不是这段代码。


三、Edge Runtime 是什么

Edge Runtime 通常运行在靠近用户的边缘节点中,底层常见实现是受限制的 V8 isolate 或类似的轻量级沙箱。它不是完整的 Node.js。

Edge Runtime 的设计目标是:

  • 快速启动短生命周期的请求处理;
  • 在多个地理位置执行;
  • 使用标准 Web API;
  • 限制进程、文件系统和底层网络能力,以便平台管理隔离和扩展。

Edge 代码通常可以使用:

Request
Response
Headers
URL
URLPattern
fetch
ReadableStream
TransformStream
TextEncoder
TextDecoder
crypto
crypto.subtle
setTimeout

具体可用 API 由 Next.js 和部署平台共同决定。不能因为某个 API 在浏览器中存在,就假定所有 Edge 平台都以完全相同的方式实现它;应以当前 Next.js Edge Runtime API 列表和目标平台文档为准。

Edge Runtime 的 Route Handler 示例

app/api/edge-digest/route.ts

export const runtime = 'edge'

export async function POST(request: Request) {
  const body = await request.text()

  if (body.length > 1024 * 1024) {
    return Response.json(
      { error: 'request body is too large' },
      { status: 413 },
    )
  }

  const bytes = new TextEncoder().encode(body)
  const hashBuffer = await crypto.subtle.digest('SHA-256', bytes)

  const hash = Array.from(new Uint8Array(hashBuffer))
    .map((byte) => byte.toString(16).padStart(2, '0'))
    .join('')

  return Response.json({ sha256: hash })
}

同样使用:

curl -X POST \
  -H 'content-type: text/plain' \
  --data 'hello' \
  http://localhost:3000/api/edge-digest

会得到相同的 SHA-256 结果。

这个版本没有导入 node:crypto,而是使用 Web Crypto API。crypto.subtle.digest 是异步操作,返回 ArrayBuffer,所以代码还需要:

  1. 将字符串编码为 Uint8Array
  2. 计算摘要;
  3. 将字节转换成十六进制字符串;
  4. 生成 JSON 响应。

以下写法不能放进 Edge Runtime:

import { createHash } from 'node:crypto'
import fs from 'node:fs'
import { Buffer } from 'node:buffer'

即使某些打包器能够解析其中一部分导入,运行时也不代表存在对应实现。Edge 环境不是“把 Node 模块换个名字加载”,而是根本没有完整的 Node 进程和标准库。


四、Node 与 Edge 的 API 差异

1. Node 内置模块与 Web API

Node Runtime 同时可以使用大量 Node API 和 Web 标准 API:

import os from 'node:os'

export async function GET() {
  return Response.json({
    platform: os.platform(),
  })
}

Edge Runtime 通常只能依赖 Web API:

export async function GET() {
  return Response.json({
    origin: new URL('https://example.com/a').origin,
  })
}

实际选择依赖于任务:

任务 Node Runtime Edge Runtime
fetch 调用 HTTP 服务 支持 支持
Request / Response 支持 支持
Web Crypto 支持 支持
node:fs 访问文件 支持 不支持
node:crypto 支持 不支持
原生 npm 扩展 可能支持 通常不支持
依赖 Node TCP API 的库 可能支持 通常不支持
依赖动态代码执行的库 视 Node 和打包方式 常见不支持或受限

“支持”仍然要区分三个层次:

  1. JavaScript 语法是否能编译;
  2. 打包器是否能解析依赖;
  3. 部署后的目标运行时是否真的提供 API。

第三步失败时,开发环境可能正常而生产环境报错。

2. 动态代码执行

Edge Runtime 通常限制 evalnew Function 或类似动态代码执行能力。例如某些模板引擎、脚本解释器和依赖注入库会在内部生成函数:

const fn = new Function('value', 'return value + 1')

这类代码在 Edge 环境可能导致构建期或运行期错误。常见失败表现包括:

Dynamic code evaluation is not allowed

这不是业务代码一定写了 eval。也可能是某个传递依赖在内部使用了动态求值。

诊断时应查看完整依赖链,而不是只检查直接依赖:

npm ls some-package

并检查构建输出中涉及的模块:

next build

不同版本和平台的错误信息不同,但核心判断方式不变:找到具体导入路径,再确认它是否依赖 Node API、原生模块或动态代码执行。

3. 原生模块

下面这类依赖通常对 Edge 不友好:

  • 通过 N-API、node-gyp 或预编译二进制提供功能的包;
  • 依赖 OpenSSL、系统字体、系统命令或本地文件的包;
  • 直接打开 TCP 连接的数据库驱动;
  • 假定存在 Bufferprocessstream 等 Node 对象的包。

例如,数据库访问库可能有两种完全不同的实现:

应用代码
  └── ORM
      └── Node TCP 驱动       → 通常需要 Node Runtime

或者:

应用代码
  └── HTTP 数据库客户端
      └── fetch
          └── Edge 可用

因此,不能只看“这个 ORM 是否支持 Next.js”。要追踪它最终使用的是:

  • Node TCP;
  • HTTP;
  • WebSocket;
  • 原生二进制;
  • 还是平台专用连接接口。

五、依赖兼容性如何判断

可以把运行时能力形式化。设:

  • R 是应用某条路径所需要的能力集合;
  • C(Node) 是 Node Runtime 可用的能力集合;
  • C(Edge) 是 Edge Runtime 可用的能力集合。

当且仅当:

R ⊆ C(Runtime)

这条路径才在该 Runtime 中具备运行的基础条件。

例如,一个文件上传服务需要:

R = {
  Request,
  ReadableStream,
  fetch,
  S3 HTTP API
}

如果使用基于 fetch 的对象存储 SDK,可能满足:

R ⊆ C(Edge)

但如果它还需要:

node:fs
node:stream
本地临时文件

那么就不能直接放到 Edge。

依赖判断不能只检查源文件中的显式导入,还要检查:

入口模块
  → 直接依赖
    → 传递依赖
      → Node 内置模块 / 原生扩展 / 动态执行

一个常见的错误结构是:

// app/api/route.ts
import { client } from '@/lib/client'
// lib/client.ts
import { Client } from 'some-node-only-package'

路由文件表面上没有 node:fs,但整个依赖图仍然要求 Node Runtime。

将 Node-only 代码隔离

// lib/server-config.ts
import 'server-only'

import fs from 'node:fs/promises'

export async function readServerConfig() {
  return fs.readFile('/etc/myapp/config.json', 'utf8')
}

server-only 的作用是:如果这个模块错误地被客户端组件导入,尽早让构建失败,而不是把服务端模块泄漏到浏览器 bundle。

但它不会把 Node 模块变成 Edge 兼容模块。如果 Edge 路由导入 readServerConfig,仍然会失败。正确做法是让 Node-only 依赖只被 Node Runtime 的服务端入口引用。


六、Runtime 的配置作用域和生命周期

在 App Router 中,可以在路由段文件中声明:

export const runtime = 'nodejs'

或:

export const runtime = 'edge'

例如:

// app/reports/page.tsx
export const runtime = 'nodejs'

export default async function ReportsPage() {
  const reports = await loadReportsFromDatabase()

  return (
    <main>
      <h1>Reports</h1>
      <pre>{JSON.stringify(reports, null, 2)}</pre>
    </main>
  )
}

该页面中的服务端执行逻辑需要 Node Runtime。具体的继承、覆盖和可配置范围与 Next.js 版本有关;最安全的做法是把声明放在实际需要该 Runtime 的页面、布局或 Route Handler 中,并通过生产构建验证。

运行时配置不是缓存配置。下面两个问题相互独立:

  • 代码在哪个 Runtime 执行;
  • Next.js 是否缓存页面、fetch 结果或 Route Handler 响应。

如果 API 每次都必须读取最新数据,应明确表达动态要求,并结合当前 Next.js 版本的缓存规则配置。例如:

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

export async function GET() {
  const data = await readLatestData()
  return Response.json(data)
}

force-dynamic 表示该路由不应被静态化;它不表示数据库查询自动具备事务一致性,也不替代数据库层的缓存控制。

生命周期不是“每个请求一个新进程”

在 Node serverless 和 Edge isolate 中,都可能出现:

请求 A → 创建实例 → 执行 → 实例暂时保留
请求 B → 复用实例,或被调度到另一实例
请求 C → 原实例被回收,重新冷启动

因此以下状态都不能作为可靠持久化存储:

let counter = 0

export async function GET() {
  counter += 1
  return Response.json({ counter })
}

这个计数器可能返回 1、2、3,也可能因为实例不同而多次返回 1。如果需要可靠计数,应使用数据库、KV、缓存服务或其他持久化系统。


七、Node 与 Edge 的数据流和故障路径

将请求放到 Edge,并不意味着所有依赖也自动移动到用户附近。假设用户在东京,数据库在美国:

东京用户
  → 东京 Edge Function
      → 美国数据库
      ← 美国数据库
  ← 东京用户

Edge 减少了用户到函数的距离,但函数到数据库的距离仍然存在。如果应用主要时间都耗费在数据库查询,Edge 可能只降低很小一部分延迟,甚至因为额外的跨区域网络路径增加复杂性。

Node 区域部署可能是:

用户
  → 区域 Node Function
      → 同区域数据库

这时函数与数据库距离更短,连接池和事务也更容易管理。

故障路径也不同:

Edge 路径可能失败在

  1. Edge 不支持某个 Node 内置模块;
  2. SDK 在内部使用动态代码执行;
  3. Edge 节点无法访问私有 VPC;
  4. 数据库只允许固定区域或固定 IP;
  5. 外部服务请求超时;
  6. 受限执行时间内无法完成计算。

Node 路径可能失败在

  1. serverless 实例冷启动;
  2. 数据库连接过多;
  3. 函数实例没有持久文件系统;
  4. 依赖原生模块的构建产物与部署系统不匹配;
  5. 单区域故障导致整体可用性下降;
  6. 长任务超过平台函数时限。

一个 API 的最终可靠性可粗略看成多个依赖成功概率的乘积。若请求必须依次访问函数、数据库和第三方服务:

P(请求成功)
≈ P(函数执行成功)
 × P(数据库访问成功)
 × P(第三方服务成功)

这不是平台 SLA 的精确计算,因为重试、并行和熔断会改变模型,但它说明了一个事实:更换 Runtime 只改变其中一部分条件,不会消除外部依赖故障。


八、数据库、密钥和外部服务的实际取舍

1. 数据库驱动

基于 TCP 的数据库驱动往往更适合 Node Runtime:

// 仅用于 Node Runtime 的服务端模块
import { Pool } from 'some-node-database-driver'

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
})

在 serverless 环境中,连接池需要特别小心:多个实例会各自创建连接,实例数量扩大后可能耗尽数据库连接上限。进程级单例只能减少单个实例内的重复连接,不能限制全平台实例总数。

Edge 更适合使用明确基于 HTTP 或平台 Edge 协议的客户端:

export const runtime = 'edge'

export async function GET() {
  const response = await fetch(process.env.DATA_API_URL!, {
    headers: {
      authorization: `Bearer ${process.env.DATA_API_TOKEN!}`,
    },
  })

  if (!response.ok) {
    return Response.json(
      { error: 'upstream data service failed' },
      { status: 502 },
    )
  }

  return Response.json(await response.json())
}

此处必须处理上游非 2xx 响应。fetch 在 HTTP 404 或 500 时通常不会自动抛出异常,只有网络错误才通常以 rejected Promise 体现。因此只写:

const data = await fetch(url).then((r) => r.json())

可能把上游错误页面当成正常数据处理。

2. 密钥

服务端 Runtime 可以读取服务端环境变量:

const token = process.env.DATA_API_TOKEN

带有 NEXT_PUBLIC_ 前缀的变量通常会被设计为可暴露到客户端,不能放置私钥。无论 Node 还是 Edge,部署平台都必须正确注入密钥。

Edge 的特殊风险是:请求可能在多个地区执行,密钥会被部署到相应的 Edge 运行环境。应确认平台的密钥分发、轮换、区域合规和日志脱敏行为。

3. 文件系统

Node Runtime 中可以读取文件:

import { readFile } from 'node:fs/promises'

const text = await readFile('./data.json', 'utf8')

但在 serverless 中,本地文件通常只适合:

  • 读取随构建发布的静态文件;
  • 使用临时目录处理单次请求;
  • 读取不会变化的内置资源。

它不适合保存用户上传文件、订单状态或跨实例缓存。Edge 一般没有可供应用依赖的本地文件系统,因此这些数据应存入对象存储、数据库或 KV。


九、部署选择:不是“Node 慢,Edge 快”

部署选择必须同时考虑执行能力、数据位置和故障模型。

适合优先选择 Node Runtime 的场景

  • 使用成熟 Node-only npm 包;
  • 使用文件系统、原生扩展或系统库;
  • 需要传统数据库驱动和事务;
  • 需要较复杂的服务端计算;
  • 依赖稳定的 Node 调试和本地复现环境;
  • 目标平台本身提供长驻 Node 进程或容器。

适合考虑 Edge Runtime 的场景

  • 请求处理主要由 Web API 和 fetch 构成;
  • 认证、重定向、简单 Header 改写;
  • 靠近用户的轻量 API;
  • 调用支持 HTTP 的全球化服务;
  • 逻辑短、无文件系统和 Node-only 依赖;
  • 数据本身已经在边缘 KV 或边缘缓存中。

Edge 并非所有请求都更快。若 Edge 代码必须访问远端数据库,数据库往返可能成为主要耗时。Node 也并非一定更慢:同区域 Node 函数与数据库配合时,整体链路可能更短。

一个合理的决策顺序是:

先确认依赖兼容性
  ↓
再确认数据源和函数的地理位置
  ↓
再确认请求时长、并发、连接和存储要求
  ↓
最后比较目标平台的成本、观测和故障恢复能力

如果第一步就发现依赖要求 node:fs 或原生模块,那么 Edge 不是性能优化选项,而是能力不匹配。


十、本地运行、构建验证和生产诊断

1. 本地开发通过不代表 Edge 兼容

开发服务器可能使用 Node 进程运行一部分逻辑,因此不能仅凭 next dev 成功判断 Edge 兼容性。至少应执行生产构建:

npm run build
npm run start

并实际请求目标路由:

curl -i http://localhost:3000/api/edge-digest

如果项目使用特定部署适配器,还应使用该平台提供的本地模拟器或预览部署验证,因为平台对 Edge API、环境变量、区域路由和请求限制的实现可能不同。

2. 常见失败表现

导入 Node 模块失败

Module not found: Can't resolve 'node:fs'

处理方式:

  1. 找到报错文件;
  2. 查找其父级导入链;
  3. 确认是否被 Edge 路由引用;
  4. 将该路径改为 Node Runtime,或替换为 Web API/HTTP 客户端。

动态求值被拒绝

Dynamic code evaluation is not allowed

处理方式:

  1. 搜索直接代码中的 evalnew Function
  2. 检查构建输出和传递依赖;
  3. 升级到支持 Edge 的依赖版本,或更换依赖;
  4. 如果依赖无法替换,将路由迁移到 Node Runtime。

运行时出现 Buffer is not defined

这表示代码或依赖假设存在 Node 的 Buffer。不要简单地在 Edge 中手工注入一个全局变量,因为依赖还可能继续使用 streamprocess 或其他 Node 行为。应优先使用 TextEncoderUint8Array、Web Crypto 等 Web API,或改用 Node Runtime。

Node 版本差异

Node Runtime 使用哪个 Node 版本由项目配置和部署平台决定。应通过 package.jsonengines、平台运行时设置和 CI 验证保持一致:

{
  "engines": {
    "node": ">=20"
  }
}

实际版本范围必须结合当前 Next.js 版本支持矩阵确定,不能只复制一个固定数字。升级 Node 后应重新验证原生依赖、加密库、构建工具和数据库驱动。

3. 错误处理不能只看 Runtime

无论 Node 还是 Edge,都应区分:

  • 输入错误:返回 400
  • 身份验证失败:返回 401
  • 权限不足:返回 403
  • 上游依赖失败:通常返回 502503
  • 服务内部异常:记录关联 ID,向客户端返回稳定的 500 结构。

示例:

export async function GET() {
  try {
    const response = await fetch(process.env.DATA_API_URL!, {
      signal: AbortSignal.timeout(3000),
    })

    if (!response.ok) {
      console.error('data api status:', response.status)

      return Response.json(
        { error: 'data service unavailable' },
        { status: 502 },
      )
    }

    return Response.json(await response.json())
  } catch (error) {
    console.error('request failed:', error)

    return Response.json(
      { error: 'internal server error' },
      { status: 500 },
    )
  }
}

AbortSignal.timeout 的可用性取决于目标运行时和版本;如果项目需要覆盖较旧环境,应使用当前平台支持的超时实现。超时不是取消所有底层工作的一般保证,但可以防止请求无限等待。


十一、容易混淆的结论

Edge 不是浏览器

Edge Runtime 运行在服务端,通常可以读取服务端密钥并访问后端服务。它只是采用 Web API 模型,不代表代码已经暴露给用户。

Node 不是持久服务器

Node Runtime 可能运行在容器、传统服务器或 serverless 实例中。部署形态决定进程和磁盘生命周期,Runtime 名称本身不能推出持久化语义。

Runtime 不决定缓存

同一个 Node 路由可以动态执行,也可以返回可缓存响应;同一个 Edge 路由也可以被 CDN 缓存。缓存策略需要结合 Next.js 的静态化、fetch 缓存、响应 Header 和平台 CDN 规则单独判断。

Edge 不会自动解决跨区域数据访问

函数离用户近,不表示数据库、对象存储和第三方服务也离用户近。必须测量完整请求链路,而不是只测函数入口到用户的距离。

依赖兼容性是传递性的

直接依赖没有导入 Node API,不代表它的依赖树没有导入 Node API。Edge 迁移必须检查最终打包图,并通过目标平台构建验证。


十二、一个可执行的选择准则

可以将一次选择写成以下条件:

选择 Edge,当且仅当:
1. 该路径的依赖和 API 满足 R ⊆ C(Edge);
2. 数据源能被 Edge 稳定访问;
3. Edge 的执行时间、请求体、网络和区域限制满足业务要求;
4. 平台的日志、密钥、回滚和观测能力可接受。

否则选择 Node。

这不是说 Node 永远优于 Edge,而是先满足功能约束,再比较部署收益。

例如:

  • 使用 node:crypto、原生数据库驱动和本地文件:选 Node;
  • 使用 Web Crypto、fetch 和边缘 KV:可以考虑 Edge;
  • 使用 HTTP 数据库客户端但事务复杂、数据库位于单一区域:通常仍应优先评估区域 Node;
  • 只是做轻量鉴权和 Header 判断:Edge 可能合适,但必须确认鉴权服务的访问路径和密钥分发策略。

最终,Node 与 Edge 的差异不是简单的性能开关,而是 API 能力、依赖模型、数据位置、生命周期和部署故障边界 的组合。先明确服务端代码实际需要什么,再根据依赖和数据流选择 Runtime,通常比先指定“全站 Edge”或“全站 Node”更可靠。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。