发布日期

第 08 讲|Layout、Template、并行路由与 LoaderTree 装配

Layout、Template、并行路由的组合逻辑与 LoaderTree 的装配过程

阶段二:App Router 核心机制 · 第 8 / 40 讲 难度:⭐⭐⭐⭐⭐ · 预计耗时:3.5 小时 配套 fixture:fixtures/lecture-08/

学习目标

  • 把 LoaderTree(数据) → React Element Tree(渲染目标) → FlightRouterState(协议) 三种数据结构理顺。
  • 能复述 createComponentTree 的递归逻辑:什么时候递归、什么时候插 Suspense / ErrorBoundary。
  • 理解 LayoutTemplate 的"挂载语义"差异、parallel route 的 default.tsx 解析路径、intercepting route 的特殊化。
  • 能用 dev tools 识别 React 树里的 <LayoutRouter> / <RenderFromTemplateContext> / <ErrorBoundary> / <HTTPAccessFallbackBoundary>
  • 在 fixture 中通过 console.log 打印 LoaderTree 结构,确认理论与实际一致。

1. 三种数据结构

App Router 在一次请求生命周期里会把"目录结构"反复变形。理解这三种结构是这一讲的全部基础:

数据结构形态谁产生谁消费
LoaderTree4 元组:[segment, parallelRoutes, modules, staticSiblings]webpack/turbopack 的 next-app-loader(编译期)服务端 createComponentTree(运行期)
React Element TreeJSX 元素服务端 createComponentTree(运行期)React renderToReadableStream(SSR)
FlightRouterState5 元组:[segment, parallelRoutes, refreshState?, hint?, prefetchHints?]服务端 createFlightRouterStateFromLoaderTree客户端 app-router 的 reducer(导航时回传给服务端)

速记:LoaderTree 是 import 图,Element Tree 是渲染目标,FlightRouterState 是协议

LoaderTree 的定义你已经在第 5 讲见过:

29:packages/next/src/server/lib/app-dir-module.ts
export type LoaderTree = [
  segment: string,
  parallelRoutes: { [parallelRouterKey: string]: LoaderTree },
  modules: AppDirModules,
  /**
   * At build time, for each dynamic segment, we compute the list of static
   * sibling segments that exist at the same URL path level. This is used by
   * the client router to determine if a prefetch can be reused.
   *
   * For example, given the following file structure:
   *   /app/(group1)/products/sale/page.tsx -> /products/sale
   *   /app/(group2)/products/[id]/page.tsx -> /products/[id]
   *
   * The [id] segment would have staticSiblings: ['sale']
   *
   * This accounts for route groups, which may place sibling routes in
   * different parts of the file system tree but at the same URL level.
   *
   * A value of `null` means the static siblings are unknown (e.g., in webpack
   * dev mode where routes are compiled on-demand).
   */
  staticSiblings: readonly string[] | null,
]

FlightRouterState 略不同——多了 prefetch 元数据,用于客户端路由:

138:packages/next/src/shared/lib/app-router-types.ts
export type Segment = string | DynamicSegmentTuple

/**
 * Router state
 */
export type FlightRouterState = [
  segment: Segment,
  parallelRoutes: { [parallelRouterKey: string]: FlightRouterState },

注意区分:

  • LoaderTree 的 parallelRoutes 值是 LoaderTree 自身(递归数据);FlightRouterState 的 parallelRoutes 值是 FlightRouterState 自身。两者结构同构、内容不同
  • LoaderTree 在请求中是只读的;FlightRouterState 是可序列化的,会跨 server/client 传输(这就是它叫 "Flight" 的原因)。

2. 装配器:createComponentTree 入口

服务端把 LoaderTree 变成 React 元素的入口函数:

81:packages/next/src/server/app-render/create-component-tree.tsx
/**
 * Use the provided loader tree to create the React Component tree.
 */
// TODO convert these arguments to non-object form. the entrypoint doesn't need most of them
export function createComponentTree(props: {
  loaderTree: LoaderTree
  parentParams: Params
  parentOptionalCatchAllParamName: string | null
  parentRuntimePrefetchable: false
  rootLayoutIncluded: boolean
  injectedCSS: Set<string>
  injectedJS: Set<string>
  injectedFontPreloadTags: Set<string>
  ctx: AppRenderContext
  missingSlots?: Set<string>
  preloadCallbacks: PreloadCallbacks
  authInterrupts: boolean
  MetadataOutlet: ComponentType
  prerenderHTTPError?: PrerenderHTTPErrorState
}): Promise<CacheNodeSeedData> {
  return getTracer().trace(
    NextNodeServerSpan.createComponentTree,
    {
      spanName: 'build component tree',
    },
    () => createComponentTreeInternal(props, true)
  )
}

它返回的不是 ReactElement 本身,而是一个 CacheNodeSeedData

268:packages/next/src/shared/lib/app-router-types.ts
export type CacheNodeSeedData = [
  node: React.ReactNode | null,
  parallelRoutes: {
    [parallelRouterKey: string]: CacheNodeSeedData | null
  },
  // TODO: This field is no longer used. Remove it.
  loading: null,
  isPartial: boolean,
  /**

理解这个返回值是关键:CacheNodeSeedData 是 React Element Tree 的"种子"——客户端 router 接收到 RSC payload 后,会用它填充自己的 cache 节点。所以服务端不直接吐 React tree,而是吐"按 segment 拆分的 React tree 片段集合"。

生产排查提示:客户端导航后某个 layout 没有更新(陈旧),多半是 CacheNodeSeedData 在客户端 cache 里被错误复用——第 9 讲会展开。

3. 装配过程的 5 步

createComponentTreeInternal 拆成 5 步,每一步对应一组源码块。

3.1 第 1 步:parse 当前节点

165:packages/next/src/server/app-render/create-component-tree.tsx
  const { page, conventionPath, segment, modules, parallelRoutes } =
    parseLoaderTree(tree)

  const {
    layout,
    template,
    error,
    loading,
    'not-found': notFound,
    forbidden,
    unauthorized,
  } = modules

parseLoaderTree 是个 4 行小工具,把 tree 拆成 5 个字段:

23:packages/next/src/shared/lib/router/utils/parse-loader-tree.ts
export function parseLoaderTree(tree: LoaderTree) {
  const [segment, parallelRoutes, modules, staticSiblings] = tree
  const { layout, template } = modules
  let { page } = modules
  // a __DEFAULT__ segment means that this route didn't match any of the
  // segments in the route, so we should use the default page
  page = segment === DEFAULT_SEGMENT_KEY ? modules.defaultPage : page

  const conventionPath = layout?.[1] || template?.[1] || page?.[1]

  return {
    page,
    segment,
    modules,
    /* it can be either layout / template / page */
    conventionPath,
    parallelRoutes,
    staticSiblings,
  }
}

注意第 7 行——当当前 segment 是 __DEFAULT__ 时,page 不取常规 page,而取 defaultPage。这是 parallel route 的 "fallback 走 default.tsx" 在最底层的实现锚点。

3.2 第 2 步:把每个约定文件加载成 React 组件

每个约定文件(template/error/loading/not-found 等)的处理模式一样,都通过 createComponentStylesAndScripts 异步加载:

210:packages/next/src/server/app-render/create-component-tree.tsx
  const [Template, templateStyles, templateScripts] = template
    ? await createComponentStylesAndScripts({
        ctx,
        filePath: template[1],
        getComponent: template[0],
        injectedCSS: injectedCSSWithCurrentLayout,
        injectedJS: injectedJSWithCurrentLayout,
      })
    : [Fragment]

  const [ErrorComponent, errorStyles, errorScripts] = error
    ? await createComponentStylesAndScripts({
        ctx,
        filePath: error[1],
        getComponent: error[0],
        injectedCSS: injectedCSSWithCurrentLayout,
        injectedJS: injectedJSWithCurrentLayout,
      })
    : []

  const [Loading, loadingStyles, loadingScripts] = loading
    ? await createComponentStylesAndScripts({
        ctx,
        filePath: loading[1],
        getComponent: loading[0],
        injectedCSS: injectedCSSWithCurrentLayout,
        injectedJS: injectedJSWithCurrentLayout,
      })
    : []

注意三个细节:

  1. template 缺省 fallback 是 Fragment,意味着 template 不存在时这一层"透明"。
  2. error / loading / not-found 缺省 fallback 是 [](即不存在),后续生成 boundary 时会 skip。
  3. 每个组件返回三元组 [Component, styles, scripts]——CSS / 字体 / chunk 在装配阶段就已经被收集,最终通过 useServerInsertedHTML 注入。

3.3 第 3 步:递归处理 parallel routes

这是装配器最核心的一段——遍历 parallelRoutes 的每个 slot,递归生成子 SeedData:

670:packages/next/src/server/app-render/create-component-tree.tsx
          if (childCacheNodeSeedData === null) {
            const seedData = await createComponentTreeInternal(
              {
                loaderTree: parallelRoute,
                parentParams: currentParams,
                parentOptionalCatchAllParamName: optionalCatchAllParamName,
                parentRuntimePrefetchable: isRuntimePrefetchable,
                rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove,
                injectedCSS: injectedCSSWithCurrentLayout,
                injectedJS: injectedJSWithCurrentLayout,
                injectedFontPreloadTags:
                  injectedFontPreloadTagsWithCurrentLayout,
                ctx,
                missingSlots,
                preloadCallbacks,
                authInterrupts,
                // `StreamingMetadataOutlet` is used to conditionally throw. In the case of parallel routes we will have more than one page
                // but we only want to throw on the first one.
                MetadataOutlet: isChildrenRouteKey ? MetadataOutlet : null,
                prerenderHTTPError,
              },
              false
            )

            childCacheNodeSeedData = seedData
          }

每个 slot 都会得到一个独立的 SeedData。这些 SeedData 会被装到一个 dict 里,供客户端 cache 使用。

3.4 第 4 步:生成 <Template> + <RenderFromTemplateContext>

template 与 layout 的核心区别在这里实现:

677:packages/next/src/server/app-render/create-component-tree.tsx
        const templateNode = createElement(
          Template,
          null,
          createElement(RenderFromTemplateContext, null)
        )

每次渲染都创建一个新的 Template 元素(不携带 children),children 通过 RenderFromTemplateContext 这个客户端组件从 React Context 中读取。这就是 template 每次导航重新挂载、layout 不重新挂载的实现机制

  • Layout 在 React 中是一个 stable 的 element,children 直接传入(同 children 不同位置同 instance)。
  • Template 通过 Context 解耦,每次导航都是新的 instance,从而触发 unmount/mount 周期、重置内部 state、重新跑 effect。

3.5 第 5 步:包 boundary

最外层依次包 ErrorBoundary、HTTPAccessFallbackBoundary、Suspense(loading)。包的顺序很重要:

<HTTPAccessFallbackBoundary notFound forbidden unauthorized>
  <ErrorBoundary error={ErrorComponent}>
    <Suspense fallback={Loading}>
      <Template>
        <RenderFromTemplateContext />  ← children 由 Context 注入
      </Template>
    </Suspense>
  </ErrorBoundary>
</HTTPAccessFallbackBoundary>

HTTPAccessFallbackBoundary 在最外层,因为 notFound() / forbidden() / unauthorized() 会抛出特殊的 redirect-like 异常,需要被它捕获——并且它要在 ErrorBoundary 外层,避免被误判成普通错误。

这一段在源码里散布在 600–800 行附近,元素被一层一层 createElement 包裹,过程繁琐但逻辑清晰。建议自己跟着读 create-component-tree.tsx 的最后 200 行。

4. Layout vs Template:实战对比

业务场景:page 切换时,"侧栏 banner 倒计时"这种状态:

  • 要保留(共享 banner 跨页导航)→ 放在 layout.tsx
  • 要每次重新计时(如登录失败提示,跳走再回来重置)→ 放在 template.tsx

4.1 验证 layout 不重挂载

// app/dashboard/layout.tsx
"use client";
import { useEffect } from "react";

export default function Layout({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    console.log("[layout] mount");
    return () => console.log("[layout] unmount");
  }, []);
  return <div>{children}</div>;
}

打开 /dashboard → 控制台 mount。导航到 /dashboard/settings → 控制台没有 mount/unmount 输出

4.2 验证 template 每次重挂载

// app/dashboard/template.tsx
"use client";
import { useEffect } from "react";

export default function Template({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    console.log("[template] mount");
    return () => console.log("[template] unmount");
  }, []);
  return <>{children}</>;
}

每次导航 /dashboard/dashboard/settings 都会输出 unmount + mount

4.3 业务建议

template.tsx 在以下场景特别有用:

  • 进入页面就要播一次入场动画(如 framer-motion)。
  • 表单页希望"导航走再回来"清空草稿。
  • 埋点中需要统计"页面进入次数"(layout 只算一次,template 算多次)。

但它不是免费的——每次重挂载会丢失 React state、丢失 effect 中拿到的资源。默认都用 layout,只有需要重挂载语义时才换 template

5. parallel route 的"路径选择":default.tsx 的内部解析

第 5 讲学过 parallel slot 在 URL 不匹配时走 default.tsx。这个 fallback 路径的真正终点是一个内置组件:

9:packages/next/src/client/components/builtin/default.tsx
import { notFound } from '../not-found'

export const PARALLEL_ROUTE_DEFAULT_PATH =
  'next/dist/client/components/builtin/default.js'

export default function ParallelRouteDefault() {
  notFound()
}

这是一个 9 行的关键文件——读懂它你就明白为什么 parallel route 没有 default.tsx 时会 404:

  • 路径常量 PARALLEL_ROUTE_DEFAULT_PATH 是 next-app-loader 生成 LoaderTree 时使用的 fallback 文件。
  • 如果用户没写 default.tsx,loader 会让 defaultPage 指向这个内置组件——它直接 throw notFound(),被外层的 HTTPAccessFallbackBoundary 捕获,渲染 not-found.tsx

所以**"少了 default.tsx 报错"的物理原因,是装配器走到这里抛了 notFound**。这个 fallback 设计的好处是:用户要么主动写 default.tsx 提供 fallback UI,要么自动走 not-found 流程,绝不会出现"slot 渲染了 undefined"的破窗效果

6. hasLoadingComponentInTree:Suspense 提升

这个工具函数在 PPR 与 prefetch 路径下都重要:

13:packages/next/src/server/app-render/has-loading-component-in-tree.tsx
export function hasLoadingComponentInTree(tree: LoaderTree): boolean {
  const [, parallelRoutes, { loading }] = tree

  if (loading) {
    return true
  }

  return Object.values(parallelRoutes).some((parallelRoute) =>
    hasLoadingComponentInTree(parallelRoute)
  ) as boolean
}

它递归检查整个子树是否含 loading.tsx。在客户端 prefetch 时,有 loading 边界的子树会被作为一个独立的"prefetch unit"——服务端可以提前停在 loading 边界,把已渲染部分流给客户端,剩下的等真实导航再继续

第 19 讲(PPR)会展开这一机制。

7. 装配器在 dev tools 中的可视化

打开 React DevTools,访问 fixture /example-1,你会看到(简化):

<HTTPAccessFallbackBoundary>
  <RedirectBoundary>
    <ErrorBoundary>           ← 来自 error.tsx
      <Suspense>              ← 来自 loading.tsx
        <Template>            ← 来自 template.tsx
          <RenderFromTemplateContext>
            <Layout>          ← 来自 layout.tsx
              <Page />        ← 来自 page.tsx
            </Layout>
          </RenderFromTemplateContext>
        </Template>
      </Suspense>
    </ErrorBoundary>
  </RedirectBoundary>
</HTTPAccessFallbackBoundary>

每一层都对应一份源码——背下这个嵌套顺序你就能识别 "我点的某个组件被哪一层 boundary 捕获"

7.1 识别 layer boundary 与组件实例

DevTools 里:

  • <LayoutRouter> 元素是真正持有 segment cache 的内部组件,定义在 client/components/layout-router.tsx
  • <RenderFromTemplateContext /> 是 template 子树消费 children 的桥梁。
  • <HTTPAccessFallbackBoundary> / <RedirectBoundary> 来自 client/components/,第 20 讲专题。

8. 业务示例:嵌套 dashboard 与 modal 的装配树

需求(fixture lecture-08 的实现):

  • /dashboard:根布局 + 看板布局,含 @team @analytics 两个 slot。
  • /dashboard/settings:进 settings 后 @team 仍显示,@analytics 走 default。
  • /dashboard 点开任一 team 成员卡片:弹模态框(intercepting (.))。

LoaderTree 的精简结构(concept 写法):

['', { children: ['dashboard', { children: ['__PAGE__', {}, { page: ... }, null], '@team': ..., '@analytics': ... }, { layout: ... }, null] }, { layout: rootLayout }, null]

装配器递归后产出的 React Element Tree(去掉 boundary):

<RootLayout>
  <DashboardLayout team={<TeamPanel />} analytics={<AnalyticsPanel />}>
    <DashboardPage />
  </DashboardLayout>
</RootLayout>

切到 /dashboard/settings

<RootLayout>
  <DashboardLayout team={<TeamPanel />} analytics={<AnalyticsDefault />}>
    <SettingsPage />
  </DashboardLayout>
</RootLayout>

注意:

  • team slot 仍是 <TeamPanel />(因为 URL 切换没影响这个 slot)。
  • analytics slot 退回 <AnalyticsDefault />(来自 default.tsx)。
  • <DashboardLayout> 实例不变(layout 不重挂载)。

如果你的 <DashboardLayout> 顶部有 console.log只在第一次进入 /dashboard* 子树时会输出。这是 React 的 component identity 在 segment 层级稳定的物理体现。

9. 重难点

9.1 __PAGE__ / __DEFAULT__

PAGE_SEGMENT_KEY = '__PAGE__'DEFAULT_SEGMENT_KEY = '__DEFAULT__' 在 LoaderTree 里作为 leaf segment 名字出现。它们不来自用户文件夹,是装配器的"伪段"——区分"页面终点"与"fallback 终点"。

9.2 parallelRoutes['children'] 是默认通道

每一层 LoaderTree 都至少有 children 这个 slot。children 不算 parallel route(它没有 @ 前缀),它就是默认的"主分支"——page.tsx 始终挂在它下面。其它 @xxx slot 才是真正的 parallel。

9.3 dev 模式下 staticSiblings 是 null

LoaderTree 的第 4 项 staticSiblings 在 webpack dev mode 下是 null(注释里明说)。这意味着客户端 prefetch 复用判断会更保守。production build 后这个值才齐全。

9.4 装配在 React render 之前完成

很多人误以为 createComponentTree 是 React 渲染过程的一部分。它不是——它是在 React renderToReadableStream 之前完成的"准备阶段",输出 Element Tree 让 React 去渲染。这意味着:

  • 装配阶段就能 await(getLayoutOrPageModule 是 async)。
  • 装配阶段就能跑 segment-level config(runtimerevalidatedynamic)。
  • 装配阶段就能收集 CSS / font / preload。

第 15 讲(请求一生)会把装配阶段放回完整的渲染管线里。

9.5 Template 的 props 限制

<Template> 编译期会被装配器特殊处理:传入它的只有 children(其它 props 会被忽略,因为 children 改走 Context)。所以你不能给 template 加复杂 props——它的语义是"通用包装器"。

10. 配套 fixture:动手观察装配过程

fixtures/lecture-08/ 包含 4 个示例:

  1. app/example-1-layout-vs-template/ — Layout 和 Template 各自有 useEffect,导航观察控制台。
  2. app/example-2-parallel-default/@team@analytics 两个 slot,访问 settings 看 default fallback。
  3. app/example-3-modal-intercept/ — 列表 + 模态框拦截,开 React DevTools 看装配差异。
  4. app/example-4-print-tree/ — 服务端组件里调试输出 LoaderTree 关键字段。

10.1 推荐实验

cd learning/nextjs-40-lectures/fixtures/lecture-08
pnpm install --ignore-workspace
pnpm dev
# 访问 http://localhost:3008/

实验 A:layout 不重挂载

  • 打开 /example-1,进入 page A → page B → page A。
  • 控制台只在第一次进 /example-1* 子树时输出 [layout-1] mount
  • 切到 /example-1/page-b 时只输出 [template-1] mount + [template-1] unmount,不再触发 layout 重挂载。

实验 B:parallel default fallback

  • 打开 /example-2,看到 team + analytics 两个面板。
  • 点击 "Go to settings",URL 变成 /example-2/settings
  • @team 仍显示原内容(因为没有切换 slot 路由),@analytics 替换为 analytics default

实验 C:直接读 LoaderTree(仅理解用)

example-4 里的服务端组件给出一个最小读法(注意:这只是教学用法;生产代码不要这么做,因为它依赖内部 module shape)。

11. 检验问题

  1. LoaderTree、CacheNodeSeedData、FlightRouterState 三者的差异?分别由谁产生、谁消费?
  2. createComponentTree 返回的不是 React Element 而是 SeedData,为什么这么设计?
  3. TemplateLayout 在装配器中分别被怎么处理?为什么 Template 用 RenderFromTemplateContext?
  4. default.tsx 缺省时框架最终走到哪个文件?这个文件做了什么?
  5. __PAGE____DEFAULT__ 是用户写的吗?它们出现在 LoaderTree 里的什么位置?
  6. 在 LoaderTree 中,children@team 这两个 key 有什么本质区别?
  7. hasLoadingComponentInTree 为什么要递归整个子树?这个返回值用在哪里?
  8. 装配阶段是否能 await?是否能读 segment-level config?
  9. parallel slot 在 React DevTools 里以什么形式呈现?怎么区分 team slot 与 analytics slot?
  10. fixture 里如果删除 @analytics/default.tsx,访问 /example-2/settings 会发生什么?源码层为什么会这样?

12. 延伸阅读

  • 源码:packages/next/src/server/app-render/create-component-tree.tsx(1300+ 行,建议按本讲提到的 5 步顺序读)
  • 源码:packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx(FlightRouterState diff/merge 算法)
  • 源码:packages/next/src/server/app-render/create-flight-router-state-from-loader-tree.ts(LoaderTree → FlightRouterState)
  • 源码:packages/next/src/client/components/layout-router.tsx(LayoutRouter 是装配树在 client 的镜像)
  • 配套 fixture:fixtures/lecture-08/

下一讲预告

第 09 讲|客户端 App Router:服务端把 SeedData 流给客户端后,由谁接住?我们将拆 reducer + segment cache + LayoutRouter 三层,把 navigate / prefetch / restore / refresh 的状态机彻底说清,并解释 createRouterAct 测试 API 的设计意图。