首页
/ Next.js App Router 的 React Vendoring 机制深度解析:entry-base.ts 边界、类型声明与 Turbopack Remap

Next.js App Router 的 React Vendoring 机制深度解析:entry-base.ts 边界、类型声明与 Turbopack Remap

2026-09-05 13:37:32作者:咎岭娴Homer

本文基于 Next.js 仓库内部的 React vendoring 技能文档(.agents/skills/react-vendoring/SKILL.md)展开,系统讲解 App Router 为什么不走 node_modules 解析 React、vendored React 的两条 channel 如何构建、entry-base.ts 为何是 react-server-dom-webpack/*(Flight 服务 API)的唯一合法入口、$$compiled.internal.d.ts 类型声明的职责,以及 Turbopack 对模块名的静默 remap。读完本文,你将掌握在 Next.js 中安全添加 Node.js-only React API 的完整套路(类型声明 + process.env 守卫 + ComponentMod 访问),并理解违反 react-server 层边界时在开发/生产环境的差异化表现。

App Router 的 React 不是从 node_modules 解析的

Next.js 中两条路由体系对 React 的解析策略截然不同:

  • Pages Router:React 照常从项目依赖的 node_modules 中解析;
  • App Router:React 在 pnpm build 阶段被 vendored(内嵌拷贝)到 packages/next/src/compiled/ 目录,运行时使用这份内部副本而非 node_modules 中的版本。

这一行为由构建任务 taskfile.js 中的 copy_vendor_react() 驱动,从源码可以看到它实际执行了以下几类工作:

  1. 两条 channel 的渠道选择。任务函数通过 opts.experimental 决定渠道后缀:stable 渠道使用 builtin,experimental 渠道使用 experimental-builtin,对应目标目录分别为 src/compiled/reactsrc/compiled/react-domsrc/compiled/schedulersrc/compiled/react-experimentalsrc/compiled/react-dom-experimentalsrc/compiled/scheduler-experimental(见 taskfile.js#L1478-L1481channelpackageSuffix 的定义,以及 taskfile.js#L1779-L1782 中先后以 { experimental: false }{ experimental: true } 各执行一次 copy_vendor_react_impl)。
  2. 改写包名以避免 Haste 模块名冲突overridePackageName() 会把 vendored 包的 package.jsonname 追加 -builtin-experimental-builtin 后缀(源码注释说明这是为了避免 "The name react was looked up in the Haste module map" 告警,见 taskfile.js#L1483-L1505)。
  3. 重写 CJS 产物中的 require 指向aliasVendoredReactPackages() 用正则把 vendored React CJS 文件里的 require("react")require("react-dom")require("scheduler") 替换为 require("next/dist/compiled/react…") 等自指路径(taskfile.js#L1507-L1521),确保 vendored 副本内部互相引用而不泄漏回 node_modules
  4. 剔除无用文件。任务还会移除 vendored react-dom 中未使用的产物,如 static.jsserver.bun.jsunstable_testing.js 等(taskfile.js#L1647-L1659)。

两条 channel 在运行时由 webpack 配置的 alias 机制切换:源码中提到 runtime bundle 的 webpack 配置通过 makeAppAliases({ experimental })react/react-dom 指向正确的 vendored channel。也就是说,实验性 React 版本不需要改任何业务代码,只需要通过该 alias 参数把整条 App Router 链路切到 -experimental 目录。

entry-base.ts:react-server 层的唯一边界

这是整个 vendoring 体系中最容易踩坑的一条规则。

在 rspack(webpack 5 演进版)中,App Router 的服务端渲染代码会被编译进一个名为 (react-server) 的特殊 layer。这个 layer 对应 React 的 react-server 条件导出——只有在这个 layer 中,reactreact-dom 等包才会解析到 Server Components 专用构建。而规则是:

只有 entry-base.ts 会在 (react-server) layer 中被编译。所有来自 react-server-dom-webpack/* 的 Flight 服务 API(如 renderToReadableStreamprerenderdecodeAction 等)必须经由 entry-base.ts 导出。

entry-base.ts#L1-L11 可以看到该文件的导出形态:

// eslint-disable-next-line import/no-extraneous-dependencies
export {
  createTemporaryReferenceSet,
  renderToReadableStream,
  decodeReply,
  decodeAction,
  decodeFormState,
} from 'react-server-dom-webpack/server'

// eslint-disable-next-line import/no-extraneous-dependencies
export { prerender } from 'react-server-dom-webpack/static'

而像 stream-ops.node.tsapp-render.tsx 这类渲染管线文件,不能直接 import Flight API,它们必须通过 ComponentMod 参数访问——这个参数就是 entry-base.ts 模块本身,经由 app-page.ts 构建模板注入。也就是说渲染链路上所有对 Flight 服务的调用,都被收敛到单一边界文件。

违反这条边界会发生什么?

  • 直接 import react-server-dom-webpack/server.nodereact-server-dom-webpack/static 的非 entry-base.ts 文件,会在运行时抛出 "The react-server condition must be enabled"——因为普通 layer 解析不到 react-server 条件导出;
  • 开发模式下该错误可能被掩盖(dev 的模块解析与错误处理路径不同),但生产模式的 worker 会立即失败。这是排查此类问题时"本地看起来没事、上线就炸"的典型来源。

entry-base.ts 的整体结构看,它除了 Flight API,还聚合了 LayoutRouterClientPageRootserverHookspreloadStyle 等服务端渲染基础设施的出口(entry-base.ts#L44-L70),进一步印证了它作为 react-server 层"总闸门"的定位。

类型声明:$$compiled.internal.d.ts 的职责

vendored React 包位于 next/dist/compiled/ 内部,TypeScript 默认无法感知其类型。Next.js 的解法是在 packages/next/types/$$compiled.internal.d.ts 中用 declare module 块为每个 vendored 包补全类型。该文件同时声明了内部路径(如 next/dist/compiled/react-server-dom-webpack/server.edge)与裸说明符(bare specifier)两个维度,例如:

declare module 'react-server-dom-webpack/server' {
  export * from 'react-server-dom-webpack/server.node'
}

declare module 'react-server-dom-webpack/server.node' {
  export function renderToReadableStream(/* ... */)
  export function renderToPipeableStream(
    model: any,
    webpackMap: ClientManifest,
    options?: {
      temporaryReferences?: TemporaryReferenceSet
      environmentName?: string | (() => string)
      filterStackFrame?: (...) => boolean
      onError?: (error: unknown) => void
      debugChannel?: import('node:stream').Writable
      startTime?: number
    }
  ): {
    pipe<Writable extends NodeJS.WritableStream>(destination: Writable): Writable
    abort(reason?: unknown): void
  }
  // decodeReply / decodeAction / decodeFormState / decodeReplyFromBusboy ...
}

declare module 'react-server-dom-webpack/static' {
  export function prerender(...): Promise<{ prelude: ReadableStream<Uint8Array> }>
  export function prerenderToNodeStream(...)
}

(实际内容见 $$compiled.internal.d.ts#L214-L360

这里有一条关键约定:src/ 下的源码对裸说明符(例如 react-server-dom-webpack/server)写 import 语句,TypeScript 就对着这些 declare module 块做类型检查;而 vendored 路径版本(next/dist/compiled/react-server-dom-webpack/…,见 $$compiled.internal.d.ts#L13-L17)多为空声明,仅用于压制模块不存在的报错。此外,packages/next/types/compiled.d.ts#L68-L69 中还保留了 react-server-dom-webpack/server/static 的最小空声明,两份声明文件配合覆盖不同解析场景。

推论(实战要点):当你给 vendored React 引入新 API(例如新的 renderToPipeableStreamprerenderToNodeStream)时,第一步永远是在 $$compiled.internal.d.tsdeclare module 声明,否则整个 src/ 的 TypeScript 构建会直接失败——这正是下一节的完整流程。

添加 Node.js-only React API 的三步套路

React 的部分渲染 API 只存在于 .node 构建(如 react-server-dom-webpack/server.node),而不在通用类型定义中。在 Next.js 中加入这类 API 需要严格的三步:

第 1 步:在 $$compiled.internal.d.ts 添加类型声明。renderToPipeableStream 为例,类型需完整描述返回值(pipe + abort)与选项参数,见上文 $$compiled.internal.d.ts#L228-L254

第 2 步:在 entry-base.ts 中经由 process.env 守卫导出。 这是当前仓库的活体示例(entry-base.ts#L13-L39):

// In entry-base.ts (react-server layer) only:
/* eslint-disable import/no-extraneous-dependencies */
export let renderToPipeableStream: FlightRenderToPipeableStream | undefined
export let prerenderToNodeStream: FlightPrerenderToNodeStream | undefined
if (process.env.__NEXT_USE_NODE_STREAMS) {
  renderToPipeableStream = (
    require('react-server-dom-webpack/server.node') as typeof import('react-server-dom-webpack/server.node')
  ).renderToPipeableStream
  prerenderToNodeStream = (
    require('react-server-dom-webpack/static') as typeof import('react-server-dom-webpack/static')
  ).prerenderToNodeStream
} else {
  renderToPipeableStream = undefined
  prerenderToNodeStream = undefined
}
/* eslint-enable import/no-extraneous-dependencies */

这里有三层设计意图:

  • let 导出 + 环境守卫__NEXT_USE_NODE_STREAMS 未启用时 API 为 undefined,非 Node 流路径不会引入 Node-only 依赖,也方便打包器做 DCE(dead code elimination);
  • require() 而非 import:运行时条件加载,保证未启用时该模块根本不进入 bundle;
  • as typeof import(...):借助第 1 步的类型声明完成运行时对象到类型空间的桥接。

第 3 步:在其他文件中通过 ComponentMod 访问。

// In other files, access via ComponentMod:
ComponentMod.renderToPipeableStream!(payload, clientModules, opts)

调用方拿到的是 entry-base.ts 模块对象,用非空断言 ! 表明"此处已确认该 API 可用"。三步缺一不可:跳过第 1 步 TS 报错;跳过第 2 步直接在调用文件里 import server.node 会触发前文所述的 react-server 条件导出错误。

ESLint 实践:守卫式 require 的注释位置

import/no-extraneous-dependencies 规则会把 react-server-dom-webpack/* 这类"未声明在依赖中的模块"标记为违规(因为它们只在构建期由 alias 解析)。仓库内的实践规则是:

  • 优先使用 scoped 的 /* eslint-disable */ / /* eslint-enable */ 块级开关包裹整个守卫块,而不是逐行注释——entry-base.ts 实际采用的就是块级方式(entry-base.ts#L25-L39);
  • 如果必须用 eslint-disable-next-line,注释必须紧贴 require() 调用那一行,而不是 const 声明行。当 constrequire() 分处两行时(如 const x = 下一行才是 require(...)),把注释写在 const 前会失效,属于容易踩的坑。

Turbopack Remap:代码写 webpack,运行时是 turbopack

最后一个必须理解的细节:react-server-dom-webpack/* 会被 Turbopack 的 import map 静默重映射(remap)为 react-server-dom-turbopack/*

  • 源码层面:所有 import 语句统一写 react-server-dom-webpack/*,一份代码同时服务 Webpack/Turbopack 两条构建链路;
  • 运行时层面:Turbopack 消费的是它自己的 Flight 绑定(turbopack 变体)。
  • 调试影响:在 Turbopack 下复现问题时,栈帧与错误信息里出现的是 react-server-dom-turbopack 变体名。看到栈中出现 turbopack 字样不要误判为"模块解析错了",这是 remap 的必然结果。

小结:规则速查表

场景 正确做法 依据
修改 vendored React 产物 编辑 taskfile.jscopy_vendor_react,重新 pnpm build 生成 src/compiled/react* taskfile.js#L1478-L1782
import Flight 服务 API 只在 entry-base.ts 中直接 import,其余文件走 ComponentMod SKILL.md + entry-base.ts#L1-L11
引入新 Node-only API $$compiled.internal.d.ts 声明 → entry-base.ts 环境守卫导出 → ComponentMod 访问 entry-base.ts#L13-L39
压制守卫式 require 的 lint 块级 disable/enable,disable-next-line 必须紧贴 require() entry-base.ts#L25-L39
Turbopack 下看到 turbopack 变体栈 属正常 remap,无需处理 SKILL.md

这套机制的文档来源是仓库内的 Agent 技能文件 .agents/skills/react-vendoring/SKILL.md,其定位正是"改动 vendored React、react-server-dom-webpack/* 或 react-server 层边界时的操作手册"。对需要深入相关链路的读者,该文档还指向了三个相邻技能:$flags(配置/schema/define-env/运行时环境接线)、$dce-edge(DCE 安全的 require 模式与 edge 约束)、$runtime-debug(复现与验证流程),可沿此线索继续在当前仓库中检索。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.79 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
988
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384