发布日期

第 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)都串起来。

学习目标

读完本讲,你应该能够:

  1. 画出 next start 模式下一次 GET 请求的完整调用链:从 http.createServer → router-server → render-server → base-server → AppPageRouteModule → renderToHTMLOrFlight
  2. 区分 router-server / render-server / base-server 三个角色:它们各自负责什么、互相通过什么数据通信。
  3. 在源码里精准定位每一个关键转折点(headers 过滤、locale 解析、middleware、resolveRoutes、findPageComponents、responseCache、renderHTML、workAsyncStorage.run)。
  4. 在生产排障时用调用栈反推到具体源码位置(看到 at handleRequestImpl 时立即知道走到了哪一步)。
  5. 知道为什么 dev 和 start 模式下调用链会有分叉(hotReloader、devBundler、minimalMode)。

本讲对应代码:packages/next/src/server/lib/router-server.tsrender-server.tspackages/next/src/server/base-server.tspackages/next/src/server/next-server.tspackages/next/src/server/route-modules/app-page/module.tspackages/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 看板上聚合错误。这背后就是 handleRequestnext.route attribute 的传递逻辑。
  • 场景 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.tsinitialize 返回。所以你可以理解为:

router-server 是真正意义上的"请求接收方",它是 Next.js 内部的"入口路由器",负责对一个 HTTP 请求做所有 路由相关 的工作(headers 过滤、locale、rewrite、middleware、文件系统检查),然后把"已经知道该渲染哪个 page"的请求 转交 给 render-server。

一个观察 trick:进程标题

如果你在生产机器上 ps aux | grep next,常常会看到几个不同名字的进程:

  • next-start — 主进程(router-server 跑在这里)
  • next-render-worker-pagesnext-render-worker-app — 渲染子进程(render-server 跑在这里)

源码在 render-server.ts 第 107-110 行:

111:packages/next/src/server/lib/render-server.ts
}): 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 闭包:

230:packages/next/src/server/lib/router-server.ts
const requestHandlerImpl: WorkerRequestHandler = async (req, res) => {

我们把这个函数的逻辑抽象成 7 个步骤:

  1. 过滤内部 headersfilterInternalHeaders),防止外部伪造内部 headers 串入。
  2. dev 模式:先走 hotReloader(处理 _next/webpack-hmr 等开发态请求)。
  3. resolveRoutes:执行 redirects、rewrites、headers、middleware、fs check,得到 matchedOutputparsedUrl
  4. dev devVirtualFsItem:对开发态虚拟文件系统的请求直接交给 devBundler。
  5. 应用响应 headers / status / body stream(如果 resolveRoutes 已经决定终止响应)。
  6. invokeRender:把请求 forward 给 render-server。
  7. 错误兜底(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

412:packages/next/src/server/lib/router-server.ts
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 — 路由的"大脑"

430:packages/next/src/server/lib/router-server.ts
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 — 真正的"转交"

351:packages/next/src/server/lib/router-server.ts
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)

注意这里几个关键点:

  1. 通过 addRequestMeta 把 invokePath 写到 req 上。后面 base-server 里就靠它判断"我应该渲染哪个 page",不会再做一次路由匹配。
  2. initResult.requestHandler(req, res):这就是 render-server 暴露的入口。
  3. 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 实例

170:packages/next/src/server/lib/render-server.ts
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)

1693:packages/next/src/server/base-server.ts
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 的三段式

910:packages/next/src/server/base-server.ts
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(() => {

逻辑分三段:

  1. await this.prepare():第一次请求时加载 manifests、初始化 IncrementalCache、构建 matchers。后续请求会命中缓存,几乎零开销。
  2. tracer.withPropagatedContext:从请求 headers(traceparenttracestate 等 W3C trace context)恢复上游 span,让 OpenTelemetry 链路无缝衔接。
  3. tracer.trace(BaseServerSpan.handleRequest, ...):创建本次请求的 root span,所有后续渲染、缓存、fetch 的 span 都挂在它下面。

注意 next.route 的传递

954:packages/next/src/server/base-server.ts
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

1542:packages/next/src/server/base-server.ts
// 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

2543:packages/next/src/server/base-server.ts
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 行起)。它的核心动作是:

  1. 解析 page 文件路径(从 manifests 拿到 chunks)。
  2. require() page module(dev 模式还会先 ensure,即触发编译)。
  3. 返回一个 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

683:packages/next/src/server/next-server.ts
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(/* ... */)
    }
  }
}

注意几个关键事实:

  1. process.env.NEXT_MINIMAL 的分支保护:在 minimalMode(Vercel 部署常用)下,renderHTML 不应该被调用——所有渲染逻辑应该走 routeModule.render 直连。这是树摇优化的关键:minimal 模式下 NextNodeServer 不会把 lazyRenderAppPage 打进 bundle。
  2. lazyRenderAppPage 是动态 import 的包装,最终调用的就是 AppPageRouteModule.render
170:packages/next/src/server/route-modules/app-page/module.ts
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 入口的预处理

3225:packages/next/src/server/app-render/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 件事:

  1. parseRequestHeaders:从 headers 里抽出 RSCNext-Router-PrefetchNext-Router-State-TreeNext-URL 等内部头,得到 isPrefetchRequestisRSCRequest 等布尔。
  2. interpolateParallelRouteParams:把 LoaderTree 上的并行路由 slot 也展开到 params(这是上一讲讲过的内容)。
  3. parsePostponedState:PPR 模式下如果有 postponed state(恢复点),先解析。
  4. 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.run3. 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 前缀的 URLrouter-server fsChecker.handleLocalerouter-server.ts:308
Attempted to handle request too many timesrouter-server handleIndex 熔断router-server.ts:373
Vary header 不对base-server.setVaryHeader → AppPageRouteModule.getVaryHeaderbase-server.ts:2138, app-page/module.ts:184
RSC 请求被 CDN 缓存到错路由base-server.computeCacheBustingSearchParambase-server.ts:2089
Error: ENOENT .next/server/app/...findPageComponents.requirenext-server.ts:805+
OTEL span 名字是 GET / 而不是 GET /products/[id]base-server.handleRequest finally 没拿到 next.routebase-server.ts:943
cookies() called outside request scopeworkAsyncStorage 没有挂上去app-render.tsx:3206
Page used "X" inside generateMetadataworkStore.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 — 一次普通 GET
  • http://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,方便本地实验。

十五、本讲小结

  1. 整条调用链的层级关系是 router-server → render-server → base-server → AppPageRouteModule → app-render
  2. 每一层都有自己的"职责单一"边界:router 负责路由、render-server 负责创建 NextServer、base-server 负责 OTEL/缓存/路由匹配的通用调度、route-module 负责具体类型路由的渲染、app-render 负责 RSC + SSR + PPR。
  3. workAsyncStorage.run 是 React 渲染世界与 Next.js 调度世界的边界,下一讲我们就从这里继续往下挖。
  4. invokePath / locale / RSC headers / cacheKey / next.route / workStore 这 6 个数据是请求在调用链上携带的"身份证",串起了整条管道。

下讲预告

第 16 讲《Server Components 渲染:renderToReadableStream 与 RSC payload》。我们会深入到 renderToHTMLOrFlightImpl 内部,看 React Server Components 是怎么被序列化成 RSC payload 的、Flight protocol 长什么样、组件是怎么按需 chunk 化的。