Next.js App Router 的 React Vendoring 机制深度解析:entry-base.ts 边界、类型声明与 Turbopack Remap
本文基于 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() 驱动,从源码可以看到它实际执行了以下几类工作:
- 两条 channel 的渠道选择。任务函数通过
opts.experimental决定渠道后缀:stable 渠道使用builtin,experimental 渠道使用experimental-builtin,对应目标目录分别为src/compiled/react、src/compiled/react-dom、src/compiled/scheduler和src/compiled/react-experimental、src/compiled/react-dom-experimental、src/compiled/scheduler-experimental(见 taskfile.js#L1478-L1481 中channel与packageSuffix的定义,以及 taskfile.js#L1779-L1782 中先后以{ experimental: false }和{ experimental: true }各执行一次copy_vendor_react_impl)。 - 改写包名以避免 Haste 模块名冲突。
overridePackageName()会把 vendored 包的package.json中name追加-builtin或-experimental-builtin后缀(源码注释说明这是为了避免 "The namereactwas looked up in the Haste module map" 告警,见 taskfile.js#L1483-L1505)。 - 重写 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。 - 剔除无用文件。任务还会移除 vendored
react-dom中未使用的产物,如static.js、server.bun.js、unstable_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 中,react、react-dom 等包才会解析到 Server Components 专用构建。而规则是:
只有 entry-base.ts 会在
(react-server)layer 中被编译。所有来自react-server-dom-webpack/*的 Flight 服务 API(如renderToReadableStream、prerender、decodeAction等)必须经由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.ts、app-render.tsx 这类渲染管线文件,不能直接 import Flight API,它们必须通过 ComponentMod 参数访问——这个参数就是 entry-base.ts 模块本身,经由 app-page.ts 构建模板注入。也就是说渲染链路上所有对 Flight 服务的调用,都被收敛到单一边界文件。
违反这条边界会发生什么?
- 直接 import
react-server-dom-webpack/server.node或react-server-dom-webpack/static的非entry-base.ts文件,会在运行时抛出 "The react-server condition must be enabled"——因为普通 layer 解析不到 react-server 条件导出; - 开发模式下该错误可能被掩盖(dev 的模块解析与错误处理路径不同),但生产模式的 worker 会立即失败。这是排查此类问题时"本地看起来没事、上线就炸"的典型来源。
从 entry-base.ts 的整体结构看,它除了 Flight API,还聚合了 LayoutRouter、ClientPageRoot、serverHooks、preloadStyle 等服务端渲染基础设施的出口(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(例如新的 renderToPipeableStream、prerenderToNodeStream)时,第一步永远是在 $$compiled.internal.d.ts 补 declare 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声明行。当const与require()分处两行时(如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.js 的 copy_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(复现与验证流程),可沿此线索继续在当前仓库中检索。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00