- 发布日期
第 15 讲:请求一生:从 router-server 到 app-render
一次请求的完整生命周期:router-server → base-server → app-render 调用链
阶段三的开篇。前面我们花了 10 讲把 App Router 在用户视角下的能力(路由、组件、缓存、Action、Metadata)讲透了。从本讲开始,我们要把视角切到 服务端 内部:当一个 HTTP 请求打进 Next.js Node 进程时,到底发生了什么?哪些模块、按什么顺序工作?这条调用链会把后续 8 讲的内容(RSC 序列化、SSR、PPR、Edge)都串起来。
学习目标
读完本讲,你应该能够:
- 画出
next start模式下一次 GET 请求的完整调用链:从http.createServer→ router-server → render-server → base-server → AppPageRouteModule →renderToHTMLOrFlight。 - 区分 router-server / render-server / base-server 三个角色:它们各自负责什么、互相通过什么数据通信。
- 在源码里精准定位每一个关键转折点(headers 过滤、locale 解析、middleware、resolveRoutes、findPageComponents、responseCache、renderHTML、workAsyncStorage.run)。
- 在生产排障时用调用栈反推到具体源码位置(看到
at handleRequestImpl时立即知道走到了哪一步)。 - 知道为什么 dev 和 start 模式下调用链会有分叉(hotReloader、devBundler、minimalMode)。
本讲对应代码:
packages/next/src/server/lib/router-server.ts、render-server.ts、packages/next/src/server/base-server.ts、packages/next/src/server/next-server.ts、packages/next/src/server/route-modules/app-page/module.ts、packages/next/src/server/app-render/app-render.tsx。
一、为什么要先理解整条调用链
很多人学 Next.js 直接跳进 app-render.tsx,看到 5000 行代码就劝退。原因是:他们不知道在到达 app-render.tsx 之前,请求已经被 6 层管道 处理过了;如果不理解这 6 层在干什么,单看 app-render.tsx 就会掉进无数 if-else 的细节里。
业务视角举几个真实例子:
- 场景 A:客户反馈某个页面在生产环境总是返回
307,但本地 dev 模式没问题。最终定位到是i18n.locales配置和assetPrefix冲突,触发了 router-server 中的 locale 重定向。如果你不知道 router-server 是 locale 路径处理的第一站,就只能盲猜。 - 场景 B:一个接入了 OpenTelemetry 的服务发现,所有 RSC 请求的 span 名称都叫
GET /而不是RSC GET /products/[id],导致 APM 看板上聚合错误。这背后就是handleRequest里next.routeattribute 的传递逻辑。 - 场景 C:客户报告说自定义 middleware 的耗时统计偏低 200ms,但页面整体响应耗时偏高。原因是 middleware 在 router-server 中执行,而后续 ResponseCache lookup 才在 base-server 中执行——这两段不属于 middleware 的"作用域",但用户感受是同一次请求。
理解整条调用链最大的收益不是"看懂源码",而是 知道每个观测点的位置:日志、trace、metric、headers,到底是在哪一层打的、哪一层能改。
二、Node.js 启动入口:next start 是怎么起来的
next start 命令最终走到 packages/next/src/server/lib/start-server.ts(在 packages/next/src/cli/next-start.ts 里被引导)。它做的事情非常薄:
# CLI 入口
node packages/next/dist/bin/next start
↓ (next-start.ts)
startServer(opts) # 来自 lib/start-server.ts
↓
http.createServer(handler)
↓
router-server.ts → initialize()
startServer 里的核心是创建一个原生 Node http.Server,把每个连接都丢给一个 requestHandler 函数:
const handler = async (req, res) => {
await requestHandler(req, res);
};
const server = http.createServer(handler);
server.listen(port, hostname);
这个 requestHandler 来自哪里?它由 router-server.ts 的 initialize 返回。所以你可以理解为:
router-server 是真正意义上的"请求接收方",它是 Next.js 内部的"入口路由器",负责对一个 HTTP 请求做所有 路由相关 的工作(headers 过滤、locale、rewrite、middleware、文件系统检查),然后把"已经知道该渲染哪个 page"的请求 转交 给 render-server。
一个观察 trick:进程标题
如果你在生产机器上 ps aux | grep next,常常会看到几个不同名字的进程:
next-start— 主进程(router-server 跑在这里)next-render-worker-pages或next-render-worker-app— 渲染子进程(render-server 跑在这里)
源码在 render-server.ts 第 107-110 行:
}): Promise<ServerInitResult> {
const type = process.env.__NEXT_PRIVATE_RENDER_WORKER
if (type) {
process.title = 'next-render-worker-' + type
}
早期版本的 Next.js 使用 worker_threads / child_process 把 router-server 和 render-server 做物理隔离。现在的实现里,"分离"主要是模块层的逻辑分离(同进程内不同模块),但代码结构仍然保留了多进程时代的边界。
三、router-server 的全貌
打开 packages/next/src/server/lib/router-server.ts,最重要的就是 initialize 函数。它的输出是一个 requestHandlerImpl 闭包:
const requestHandlerImpl: WorkerRequestHandler = async (req, res) => {
我们把这个函数的逻辑抽象成 7 个步骤:
- 过滤内部 headers(
filterInternalHeaders),防止外部伪造内部 headers 串入。 - dev 模式:先走 hotReloader(处理
_next/webpack-hmr等开发态请求)。 - resolveRoutes:执行 redirects、rewrites、headers、middleware、fs check,得到
matchedOutput和parsedUrl。 - dev devVirtualFsItem:对开发态虚拟文件系统的请求直接交给 devBundler。
- 应用响应 headers / status / body stream(如果 resolveRoutes 已经决定终止响应)。
- invokeRender:把请求 forward 给 render-server。
- 错误兜底(NoFallbackError 重试、500、x-nextjs-data 头特殊处理)。
步骤 1:filterInternalHeaders(安全关键)
⚠️ 这是整条调用链的 第一道安全闸门。所有以
x-middleware-、x-invoke-、x-now-等 Next.js 内部约定开头的 headers 都会在此处被剥离,防止用户构造伪造头绕过 middleware/route handler。
源码中 INTERNAL_HEADERS 是一个常量数组,新增一个内部 header 时必须把它加到这里——这是社区贡献时审稿的固定检查项。
步骤 2:dev hot reloader
const hotReloaderResult = await development.bundler.hotReloader.run(
req,
res,
parsedUrl
)
if (hotReloaderResult.finished) {
return hotReloaderResult
}
只有 next dev 时这一段才会执行。它接管 HMR 通道、错误覆盖层(react-dev-overlay)、turbopack 子进程握手。一个常见误解是"hotReloader 只服务 HMR",实际上它还接管了 /__nextjs_* 一系列开发态约定路由。
步骤 3:resolveRoutes — 路由的"大脑"
const {
finished,
parsedUrl,
statusCode,
resHeaders,
bodyStream,
matchedOutput,
} = await resolveRoutes({
req,
res,
isUpgradeReq: false,
signal: signalFromNodeResponse(res),
invokedOutputs,
})
resolveRoutes 是 router-utils 下的一个独立模块,本讲不展开(第 16 讲会详细拆 middleware)。这里你只要知道它的输出语义:
finished: true→ 已经把响应写完了(比如重定向、middleware 返回了 Response),调用链结束。matchedOutput.type === 'devVirtualFsItem'→ 开发态虚拟资源(CSS chunk、HMR chunk),直接交给 devBundler。matchedOutput是一个 page entry 或 app entry → 进入 invokeRender。
步骤 6:invokeRender — 真正的"转交"
async function invokeRender(
parsedUrl: NextUrlWithParsedQuery,
invokePath: string,
handleIndex: number,
additionalRequestMeta?: RequestMeta
) {
// ...
addRequestMeta(req, 'invokePath', invokePath)
addRequestMeta(req, 'invokeQuery', parsedUrl.query)
addRequestMeta(req, 'middlewareInvoke', false)
// ...
try {
const initResult =
await renderServer?.instance?.initialize(renderServerOpts)
try {
await initResult?.requestHandler(req, res)
注意这里几个关键点:
- 通过
addRequestMeta把 invokePath 写到 req 上。后面 base-server 里就靠它判断"我应该渲染哪个 page",不会再做一次路由匹配。 initResult.requestHandler(req, res):这就是 render-server 暴露的入口。- NoFallbackError 重试:当请求命中一个 SSG fallback 路由但未生成静态文件时,会回到
handleRequest(handleIndex + 1)。handleIndex > 5是熔断机制——避免无限循环。
业务排障小贴士:当生产日志里出现
Attempted to handle request too many times时,几乎都是 fallback 配置错误(dynamic params 没有静态文件、又被 middleware rewrite 回同一路径)。
四、render-server:薄包装的 NextNodeServer
render-server.ts 的角色非常薄,本质是 创建并复用一个 NextNodeServer 实例:
const server = next({
...opts,
hostname: opts.hostname || 'localhost',
customServer: false,
httpServer: opts.server,
port: opts.port,
}) as NextServer
// ...
requestHandler = server.getRequestHandler()
upgradeHandler = server.getUpgradeHandler()
getRequestHandler() 返回的就是 BaseServer.handleRequest.bind(this):
return this.handleRequest.bind(this)
至此,请求正式进入 base-server。
为什么要单独有 render-server 这一层?历史上 router-server 和 render-server 跑在 不同的 worker process(V8 isolate),通过 IPC 通信。这样 router 层不会因渲染崩溃而挂掉。现在的实现里它们在同进程,但保留了模块边界,方便未来再次拆分(例如 Vercel 的 multi-region 部署)。
五、base-server.handleRequest:通用调度器
BaseServer 是 Next.js 的"抽象服务器",由 NextNodeServer(Node 运行时)和 NextWebServer(Edge 运行时)继承。handleRequest 是它的统一入口。
handleRequest 的三段式
public async handleRequest(
req: ServerRequest,
res: ServerResponse,
parsedUrl?: NextUrlWithParsedQuery
): Promise<void> {
await this.prepare()
const method = req.method.toUpperCase()
const tracer = getTracer()
return tracer.withPropagatedContext(req.headers, () => {
// ...
return tracer.trace(
BaseServerSpan.handleRequest,
{
spanName: `${method}`,
kind: SpanKind.SERVER,
attributes: {
'http.method': method,
'http.target': req.url,
},
},
async (span) =>
this.handleRequestImpl(req, res, parsedUrl).finally(() => {
逻辑分三段:
await this.prepare():第一次请求时加载 manifests、初始化 IncrementalCache、构建 matchers。后续请求会命中缓存,几乎零开销。tracer.withPropagatedContext:从请求 headers(traceparent、tracestate等 W3C trace context)恢复上游 span,让 OpenTelemetry 链路无缝衔接。tracer.trace(BaseServerSpan.handleRequest, ...):创建本次请求的 root span,所有后续渲染、缓存、fetch 的 span 都挂在它下面。
注意 next.route 的传递
const route = rootSpanAttributes.get('next.route')
if (route) {
const name = isRSCRequest
? `RSC ${method} ${route}`
: `${method} ${route}`
span.setAttributes({
'next.route': route,
'http.route': route,
'next.span_name': name,
})
span.updateName(name)
这就是为什么 APM 看板能正确聚合"按路由"的指标。next.route 是在更深处的 renderPageComponent 里通过 setRootSpanAttribute('next.route', pathname) 设置的,最后在 handleRequest 的 finally 块里被读出来重写 span 名称。
排障小贴士:如果你在 Datadog/SigNoz 上看到 span 名称是
GET /而不是GET /products/[id],多半是某个早期 abort/error 让代码没走到setRootSpanAttribute那一行。
六、handleRequestImpl:路由匹配 + invokePath fast path
handleRequestImpl 是真正干活的函数(约 600 行)。我们只关注最核心的路径:
6.1 invokePath fast path
// when invokePath is specified we can short short circuit resolving
// ...
const invokePath = getRequestMeta(req, 'invokePath')
const useInvokePath = !useMatchedPathHeader && invokePath
router-server 已经通过 addRequestMeta(req, 'invokePath', invokePath) 告诉 base-server "你只管渲染这条 path"。handleRequestImpl 看到 invokePath 就 跳过 redirects/rewrites,直接进入路由匹配。
6.2 matchers.match:appPaths vs pagesPaths
base-server 有一个统一的 RouteMatcherManager,它包含两类 matcher:
AppPageRouteMatcher— 匹配app/**下的路由PagesRouteMatcher— 匹配pages/**下的路由
matchers.match(pathname, options) 返回一个 RouteMatch,里面带 definition(路由定义)和 params(动态段值)。这一步就是把 /products/iphone-15 解析成 { definition: { page: '/products/[slug]' }, params: { slug: 'iphone-15' } }。
6.3 renderPageComponent → findPageComponents
const result = await this.findPageComponents({
locale: getRequestMeta(ctx.req, 'locale'),
page,
query,
params: ctx.renderOpts.params || {},
isAppPath,
sriEnabled: !!this.nextConfig.experimental.sri?.algorithm,
appPaths,
// Ensuring for loading page component routes is done via the matcher.
shouldEnsure: false,
})
findPageComponents 是抽象方法,由 NextNodeServer 实现(第 805 行起)。它的核心动作是:
- 解析 page 文件路径(从 manifests 拿到 chunks)。
require()page module(dev 模式还会先 ensure,即触发编译)。- 返回一个
LoadComponentsReturnType,里面就有ComponentMod(page 模块)和routeModule。
这是为什么我们说"manifests 是 server 启动后第二关键的数据"——它直接决定了能不能加载到正确的 chunk。
七、ResponseCache:拦截渲染的"前置缓存"
进入实际渲染前,base-server 还要看一眼 responseCache.get(cacheKey):
详细的 4 层缓存我们在第 13 讲讲过了。这里我们关注 缓存与渲染的边界:
renderToResponseWithComponentsImpl里会先尝试从 IncrementalCache 拿到一份已经渲染过的 HTML+RSC payload,命中就直接res.write返回;只有 miss 才会进入renderHTML。
// 概念伪代码(实际逻辑在 renderToResponseWithComponentsImpl 里)
const cached = await responseCache.get(cacheKey, async () => {
// miss 时执行渲染
return await this.renderHTML(req, res, pathname, query, renderOpts);
});
if (cached.isMiss) {
// 渲染并写入 cache
} else {
// 直接复用 cached.value
}
业务示例:一个营销活动页的请求 P99 从 850ms 降到 12ms,看似魔法,其实就是 ISR 第一次渲染后,后续请求直接从 IncrementalCache 命中——根本没进 React 渲染管道。
八、renderHTML → AppPageRouteModule.render
base-server 在 renderToResponseWithComponentsImpl 里调用了 this.renderHTML(req, res, pathname, query, renderOpts),这是一个抽象方法,NextNodeServer 的实现就是我们前面看到的 renderHTMLImpl:
protected async renderHTML(
req: NodeNextRequest,
res: NodeNextResponse,
pathname: string,
query: NextParsedUrlQuery,
renderOpts: LoadedRenderOpts
): Promise<RenderResult> {
return getTracer().trace(NextNodeServerSpan.renderHTML, async () =>
this.renderHTMLImpl(req, res, pathname, query, renderOpts)
)
}
private async renderHTMLImpl(
req: NodeNextRequest,
res: NodeNextResponse,
pathname: string,
query: NextParsedUrlQuery,
renderOpts: LoadedRenderOpts
): Promise<RenderResult> {
if (process.env.NEXT_MINIMAL) {
throw new Error(
'Invariant: renderHTML should not be called in minimal mode'
)
} else {
renderOpts.nextFontManifest = this.nextFontManifest
if (this.enabledDirectories.app && renderOpts.isAppPath) {
return lazyRenderAppPage(
req,
res,
pathname,
query,
null,
renderOpts as LoadedRenderOpts<AppPageModule>,
this.getServerComponentsHmrCache(),
{
buildId: this.buildId,
deploymentId: this.deploymentId,
// ...
}
)
} else {
return lazyRenderPagesPage(/* ... */)
}
}
}
注意几个关键事实:
process.env.NEXT_MINIMAL的分支保护:在 minimalMode(Vercel 部署常用)下,renderHTML不应该被调用——所有渲染逻辑应该走routeModule.render直连。这是树摇优化的关键:minimal 模式下 NextNodeServer 不会把 lazyRenderAppPage 打进 bundle。lazyRenderAppPage是动态 import 的包装,最终调用的就是AppPageRouteModule.render:
public render(
req: BaseNextRequest,
res: BaseNextResponse,
context: AppPageRouteHandlerContext
): Promise<RenderResult> {
return renderToHTMLOrFlight(
req,
res,
context.page,
context.query,
context.fallbackRouteParams,
context.renderOpts,
context.serverComponentsHmrCache,
context.sharedContext
)
}
至此终于到达 app-render.tsx。
九、app-render.tsx 入口的预处理
export const renderToHTMLOrFlight: AppPageRender = (
req,
res,
pagePath,
query,
fallbackRouteParams,
renderOpts,
serverComponentsHmrCache,
sharedContext
) => {
if (!req.url) {
throw new Error('Invalid URL')
}
const url = parseRelativeUrl(req.url, undefined, false)
const parsedRequestHeaders = parseRequestHeaders(req.headers, {
isRoutePPREnabled: renderOpts.experimental.isRoutePPREnabled === true,
previewModeId: renderOpts.previewProps?.previewModeId,
})
// ... interpolatedParams + postponedState 处理 ...
const workStore = createWorkStore({
page: renderOpts.routeModule.definition.page,
renderOpts,
isPrefetchRequest,
buildId: sharedContext.buildId,
deploymentId: sharedContext.deploymentId,
previouslyRevalidatedTags,
nonce,
})
return workAsyncStorage.run(
workStore,
renderToHTMLOrFlightImpl,
req,
res,
url,
pagePath,
query,
renderOpts,
workStore,
parsedRequestHeaders,
postponedState,
serverComponentsHmrCache,
sharedContext,
interpolatedParams,
fallbackRouteParams
)
}
入口函数本身只做 4 件事:
parseRequestHeaders:从 headers 里抽出RSC、Next-Router-Prefetch、Next-Router-State-Tree、Next-URL等内部头,得到isPrefetchRequest、isRSCRequest等布尔。interpolateParallelRouteParams:把 LoaderTree 上的并行路由 slot 也展开到 params(这是上一讲讲过的内容)。parsePostponedState:PPR 模式下如果有 postponed state(恢复点),先解析。workAsyncStorage.run(workStore, renderToHTMLOrFlightImpl, ...):这是 整条调用链最重要的转折——所有后续渲染都跑在workAsyncStorage(基于AsyncLocalStorage)的上下文里。workStore里挂着:当前 page、prerenderManifest、forceStatic 标志、dynamicUsageDescription(用于 PPR 报错追溯)、fetchTags 等。
业务排障小贴士:你在 server console 看到
Error: Page used "cookies()" inside generateMetadata,这条信息就是从workStore.dynamicUsageDescription来的。任何"动态 API"(cookies、headers、searchParams)调用,都会写到 workStore 里,最后报错时能精确定位到 page name。
workAsyncStorage.run(...) 之后的事情我们留到下一讲(第 16 讲)展开——它涉及 RSC payload 的生成、SSR 的 dual rendering、PPR 的 postpone/resume 三大主题。
十、调用链总览图
把前面讲的串起来,一次 生产模式(next start) 下的 App Router 页面请求,调用链如下:
┌─────────────────────────────────────────────────────────────────┐
│ HTTP Request │
└──────────────────────────────┬──────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ http.createServer (start-server.ts) │
└──────────────────────────────┬──────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ router-server.ts: requestHandlerImpl │
│ 1. filterInternalHeaders │
│ 2. (dev only) hotReloader.run │
│ 3. resolveRoutes: │
│ redirects → headers → rewrites │
│ middleware (在 router-utils 里调用 edge-runtime sandbox) │
│ fsChecker (静态资源、appPaths、pagesPaths) │
│ 4. (dev devVirtualFsItem 分支) │
│ 5. 应用 resHeaders / status / bodyStream │
│ 6. invokeRender(parsedUrl, invokePath, ...) │
│ └─ addRequestMeta('invokePath', ...) │
│ └─ renderServer.initialize() → server.getRequestHandler() │
└──────────────────────────────┬──────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ render-server.ts: requestHandler │
│ 本质是 NextNodeServer.getRequestHandler() │
└──────────────────────────────┬──────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ base-server.ts: handleRequest → handleRequestImpl │
│ 1. await this.prepare() │
│ 2. 创建 OTEL root span (next.span_type=BaseServerSpan.handleR.) │
│ 3. invokePath fast path → matchers.match(pathname) │
│ 4. renderToResponse → renderPageComponent │
│ └─ findPageComponents (require page module) │
│ └─ renderToResponseWithComponents │
│ └─ ResponseCache.get(cacheKey) [第13讲: 4层缓存] │
│ ├─ HIT → 直接 res.write │
│ └─ MISS → renderHTML │
└──────────────────────────────┬──────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ next-server.ts: renderHTMLImpl │
│ if (isAppPath) lazyRenderAppPage(...) │
│ else lazyRenderPagesPage(...) │
└──────────────────────────────┬──────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ AppPageRouteModule.render (route-modules/app-page/module.ts) │
│ └─ renderToHTMLOrFlight (app-render.tsx) │
│ 1. parseRequestHeaders │
│ 2. interpolateParallelRouteParams │
│ 3. parsePostponedState │
│ 4. workAsyncStorage.run(workStore, renderToHTMLOrFlightImpl)│
│ └─ React 渲染(第 16 ~ 19 讲展开) │
└─────────────────────────────────────────────────────────────────┘
十一、dev 模式的分叉
next dev 在 router-server 第 2 步(hotReloader)和第 4 步(devVirtualFsItem)多走了两段,但更深的一个区别在 base-server:dev 模式下 findPageComponents 会先调用 ensurePage,触发 webpack/turbopack 重新编译这个 entry。
排障小贴士:
- 生产 OK,dev 报错:通常是 ensurePage 阶段的编译错误,看 terminal 而不是浏览器 console。
- dev OK,生产报错:通常是
next build阶段的优化(DCE、minify、tree shake)触发的 bundling 副作用,关键看.next/server/下的产物。
十二、Edge Runtime 的分叉
base-server.ts 的兄弟实现是 next-server-web.ts(NextWebServer),跑在 V8 isolate 里。它没有 findPageComponents(没有 require 系统),改用 build 时注入的 __BUILD_MANIFEST 和 __APP_PATH_ROUTES_MANIFEST。但 handleRequest 之后的 ResponseCache → renderHTML → renderToHTMLOrFlight 调用链结构完全一致——这正是 抽象在 BaseServer 的价值。
后续第 19 讲(Edge Runtime)我们会专门展开 NextWebServer 与 NextNodeServer 的差异。
十三、生产排障实战清单
下面这张表把"现象"映射到"调用链上的位置",以后看错误日志可以速查:
| 现象 | 大概率位置 | 关键文件 |
|---|---|---|
307 重定向到带 locale 前缀的 URL | router-server fsChecker.handleLocale | router-server.ts:308 |
Attempted to handle request too many times | router-server handleIndex 熔断 | router-server.ts:373 |
Vary header 不对 | base-server.setVaryHeader → AppPageRouteModule.getVaryHeader | base-server.ts:2138, app-page/module.ts:184 |
| RSC 请求被 CDN 缓存到错路由 | base-server.computeCacheBustingSearchParam | base-server.ts:2089 |
Error: ENOENT .next/server/app/... | findPageComponents.require | next-server.ts:805+ |
OTEL span 名字是 GET / 而不是 GET /products/[id] | base-server.handleRequest finally 没拿到 next.route | base-server.ts:943 |
cookies() called outside request scope | workAsyncStorage 没有挂上去 | app-render.tsx:3206 |
Page used "X" inside generateMetadata | workStore.dynamicUsageDescription | (后续讲展开) |
十四、配套 fixture:观察整条调用链
我们在 fixtures/lecture-15/ 下准备了一个最小项目,开启 OpenTelemetry,在 console 里把每一层的 span 都打印出来。
启动:
cd learning/nextjs-40-lectures/fixtures/lecture-15
pnpm install
pnpm dev
然后访问:
http://localhost:3000/products/iphone-15— 一次普通 GEThttp://localhost:3000/products/iphone-15多次刷新 — 观察 ResponseCache HIT- 在浏览器 devtools 里点击 Link — 观察 RSC 请求的 span 名字
trace 输出片段(示例):
[trace] BaseServerSpan.handleRequest GET /products/iphone-15 (45ms)
├─ NextNodeServerSpan.findPageComponents (3ms)
├─ ResponseCacheSpan.get (1ms) [MISS]
├─ NextNodeServerSpan.renderHTML (38ms)
│ └─ AppRenderSpan.renderToHTMLOrFlight (37ms)
│ ├─ AppRenderSpan.createComponentTree (12ms)
│ └─ AppRenderSpan.renderToReadableStream (24ms)
fixture 里我们用
@vercel/otel(package 已经 vendored)和一个最小的 console exporter,避免引入完整的 OTEL Collector,方便本地实验。
十五、本讲小结
- 整条调用链的层级关系是 router-server → render-server → base-server → AppPageRouteModule → app-render。
- 每一层都有自己的"职责单一"边界:router 负责路由、render-server 负责创建 NextServer、base-server 负责 OTEL/缓存/路由匹配的通用调度、route-module 负责具体类型路由的渲染、app-render 负责 RSC + SSR + PPR。
workAsyncStorage.run是 React 渲染世界与 Next.js 调度世界的边界,下一讲我们就从这里继续往下挖。- invokePath / locale / RSC headers / cacheKey / next.route / workStore 这 6 个数据是请求在调用链上携带的"身份证",串起了整条管道。
下讲预告
第 16 讲《Server Components 渲染:renderToReadableStream 与 RSC payload》。我们会深入到 renderToHTMLOrFlightImpl 内部,看 React Server Components 是怎么被序列化成 RSC payload 的、Flight protocol 长什么样、组件是怎么按需 chunk 化的。