- 发布日期
第 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。 - 理解
Layout与Template的"挂载语义"差异、parallel route 的default.tsx解析路径、intercepting route 的特殊化。 - 能用 dev tools 识别 React 树里的
<LayoutRouter>/<RenderFromTemplateContext>/<ErrorBoundary>/<HTTPAccessFallbackBoundary>。 - 在 fixture 中通过
console.log打印 LoaderTree 结构,确认理论与实际一致。
1. 三种数据结构
App Router 在一次请求生命周期里会把"目录结构"反复变形。理解这三种结构是这一讲的全部基础:
| 数据结构 | 形态 | 谁产生 | 谁消费 |
|---|---|---|---|
LoaderTree | 4 元组:[segment, parallelRoutes, modules, staticSiblings] | webpack/turbopack 的 next-app-loader(编译期) | 服务端 createComponentTree(运行期) |
| React Element Tree | JSX 元素 | 服务端 createComponentTree(运行期) | React renderToReadableStream(SSR) |
FlightRouterState | 5 元组:[segment, parallelRoutes, refreshState?, hint?, prefetchHints?] | 服务端 createFlightRouterStateFromLoaderTree | 客户端 app-router 的 reducer(导航时回传给服务端) |
速记:LoaderTree 是 import 图,Element Tree 是渲染目标,FlightRouterState 是协议。
LoaderTree 的定义你已经在第 5 讲见过:
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 元数据,用于客户端路由:
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 元素的入口函数:
/**
* 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:
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 当前节点
const { page, conventionPath, segment, modules, parallelRoutes } =
parseLoaderTree(tree)
const {
layout,
template,
error,
loading,
'not-found': notFound,
forbidden,
unauthorized,
} = modules
parseLoaderTree 是个 4 行小工具,把 tree 拆成 5 个字段:
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 异步加载:
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,
})
: []
注意三个细节:
template缺省 fallback 是Fragment,意味着 template 不存在时这一层"透明"。error/loading/not-found缺省 fallback 是[](即不存在),后续生成 boundary 时会 skip。- 每个组件返回三元组
[Component, styles, scripts]——CSS / 字体 / chunk 在装配阶段就已经被收集,最终通过useServerInsertedHTML注入。
3.3 第 3 步:递归处理 parallel routes
这是装配器最核心的一段——遍历 parallelRoutes 的每个 slot,递归生成子 SeedData:
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 的核心区别在这里实现:
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 路径的真正终点是一个内置组件:
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指向这个内置组件——它直接 thrownotFound(),被外层的HTTPAccessFallbackBoundary捕获,渲染not-found.tsx。
所以**"少了 default.tsx 报错"的物理原因,是装配器走到这里抛了 notFound**。这个 fallback 设计的好处是:用户要么主动写 default.tsx 提供 fallback UI,要么自动走 not-found 流程,绝不会出现"slot 渲染了 undefined"的破窗效果。
6. hasLoadingComponentInTree:Suspense 提升
这个工具函数在 PPR 与 prefetch 路径下都重要:
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>
注意:
teamslot 仍是<TeamPanel />(因为 URL 切换没影响这个 slot)。analyticsslot 退回<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(
runtime、revalidate、dynamic)。 - 装配阶段就能收集 CSS / font / preload。
第 15 讲(请求一生)会把装配阶段放回完整的渲染管线里。
9.5 Template 的 props 限制
<Template> 编译期会被装配器特殊处理:传入它的只有 children(其它 props 会被忽略,因为 children 改走 Context)。所以你不能给 template 加复杂 props——它的语义是"通用包装器"。
10. 配套 fixture:动手观察装配过程
fixtures/lecture-08/ 包含 4 个示例:
app/example-1-layout-vs-template/— Layout 和 Template 各自有useEffect,导航观察控制台。app/example-2-parallel-default/—@team、@analytics两个 slot,访问 settings 看 default fallback。app/example-3-modal-intercept/— 列表 + 模态框拦截,开 React DevTools 看装配差异。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. 检验问题
- LoaderTree、CacheNodeSeedData、FlightRouterState 三者的差异?分别由谁产生、谁消费?
createComponentTree返回的不是 React Element 而是 SeedData,为什么这么设计?Template与Layout在装配器中分别被怎么处理?为什么 Template 用 RenderFromTemplateContext?default.tsx缺省时框架最终走到哪个文件?这个文件做了什么?__PAGE__与__DEFAULT__是用户写的吗?它们出现在 LoaderTree 里的什么位置?- 在 LoaderTree 中,
children与@team这两个 key 有什么本质区别? hasLoadingComponentInTree为什么要递归整个子树?这个返回值用在哪里?- 装配阶段是否能 await?是否能读 segment-level config?
- parallel slot 在 React DevTools 里以什么形式呈现?怎么区分 team slot 与 analytics slot?
- 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 的设计意图。