- 发布日期
第 05 讲|文件系统约定与 Segment 树
深入文件系统约定:Segment 树的构建规则与特殊文件语义
阶段二:App Router 核心机制 · 第 5 / 40 讲 难度:⭐⭐⭐ · 预计耗时:3 小时 配套 fixture:
test/e2e/lecture-05/
学习目标
学完本讲,你应当能做到:
- 看到一个
app/目录,5 秒内说出它会生成哪些 URL、哪些是 dynamic、哪些走 catch-all。 - 看到一个 URL,5 秒内反推出它最终命中了
app/里的哪个page.tsx、经过哪些layout.tsx。 - 能用
(group)/@slot/(.)(..)(...)等高级语法解决三个真实业务问题:导航不刷新、并行多面板、模态框拦截。 - 默写出
LoaderTree的数据结构、知道它从哪里来、到哪里去。 - 在 fixture 项目里跑出一个含 parallel route + intercepting route 的最小可工作示例。
1. 文件约定速查表
App Router 把"约定优于配置"做到了极致——目录结构本身就是路由系统。所有特殊文件的语义都集中在一个常量表里:
const FILE_TYPES = {
layout: 'layout',
template: 'template',
error: 'error',
loading: 'loading',
'global-error': 'global-error',
'global-not-found': 'global-not-found',
...HTTP_ACCESS_FALLBACKS,
} as const
const GLOBAL_ERROR_FILE_TYPE = 'global-error'
const GLOBAL_NOT_FOUND_FILE_TYPE = 'global-not-found'
const PAGE_SEGMENT = 'page$'
const PARALLEL_VIRTUAL_SEGMENT = 'slot$'
HTTP_ACCESS_FALLBACKS 就在它上面:
const HTTP_ACCESS_FALLBACKS = {
'not-found': 'not-found',
forbidden: 'forbidden',
unauthorized: 'unauthorized',
} as const
合起来一共 9 种特殊文件 + page + route + default 三种 leaf,加上 4 种"段标记"(route group / dynamic / parallel / intercepting)。下面这张速查表你应当完全背下来——这是 App Router 的"字母表"。
1.1 特殊文件(File Conventions)
| 文件名 | 必须 export | 作用 | 是否参与 segment tree |
|---|---|---|---|
page.tsx | default React Component | URL 终点,定义这一段对应的 UI | ✅ leaf |
route.ts | GET / POST / ... | URL 终点,定义 HTTP handler(不参与渲染管线) | ✅ leaf |
layout.tsx | default React Component with children | 这一段及子段共享的 UI 外壳;导航时不重新挂载 | ✅ |
template.tsx | default React Component with children | 与 layout 同位但每次导航重新挂载,可用于动画/状态重置 | ✅ |
loading.tsx | default React Component | 自动包一层 Suspense fallback | ✅ |
error.tsx | default React Component ('use client') | 自动包一层 Error Boundary(仅当前段及以下) | ✅ |
not-found.tsx | default React Component | notFound() 抛出后的 fallback | ✅ |
forbidden.tsx | default React Component | forbidden() 抛出后的 fallback(实验) | ✅ |
unauthorized.tsx | default React Component | unauthorized() 抛出后的 fallback(实验) | ✅ |
default.tsx | default React Component | parallel route 在 URL 不变时的占位 UI(必备!) | ✅ |
global-error.tsx | default React Component | 替代 app/error.tsx 用于根布局错误(包到 <html> 之外) | ✅ |
global-not-found.tsx | default React Component | 根级 404 页面 | ✅ |
middleware.ts | default middleware function | 请求中间件(不在 app/ 内,必须在项目根) | ⛔ |
instrumentation.ts | register() / onRequestError() | 服务启动 / 错误回调(不在 app/ 内) | ⛔ |
注意:
global-error.tsx必须自己渲染完整的<html>和<body>,因为它"接管"了根 layout 失败的场景。
1.2 段标记(Segment Modifiers)
| 写法 | 名字 | 是否影响 URL | 是否影响 segment tree |
|---|---|---|---|
[id] | dynamic segment | ✅ 占位 | ✅ |
[...slug] | catch-all | ✅ 多段 | ✅ |
[[...slug]] | optional catch-all | ✅ 可空 | ✅ |
(marketing) | route group | ❌ 不出现在 URL | ✅ 但被 normalizeAppPath 剥离 |
@modal | parallel route(slot) | ❌ | ✅ 作为 LoaderTree 的兄弟分支 |
(.)photo | intercepting (同级) | ✅ 路径与被拦截路由一致 | ✅ 特殊处理 |
(..)photo | intercepting (上一级) | 同上 | ✅ |
(...)photo | intercepting (根) | 同上 | ✅ |
(..)(..)photo | intercepting (上两级) | 同上 | ✅ |
_lib | 私有文件夹 | ❌ | ❌ 完全不参与路由 |
下划线前缀(_lib、_components)是社区约定的"私有文件夹",next.js 显式不识别下划线开头的文件夹为路由段——这是放工具/组件的安全位置。
1.3 normalize 的力学
route group 与 parallel route 这两个"不出现在 URL"的段,由 normalizeAppPath 在编译期剥离:
export function normalizeAppPath(route: string) {
return ensureLeadingSlash(
route.split('/').reduce((pathname, segment, index, segments) => {
// Empty segments are ignored.
if (!segment) {
return pathname
}
// Groups are ignored.
if (isGroupSegment(segment)) {
return pathname
}
// Parallel segments are ignored.
if (segment[0] === '@') {
return pathname
}
// The last segment (if it's a leaf) should be ignored.
if (
(segment === 'page' || segment === 'route') &&
index === segments.length - 1
) {
return pathname
}
return `${pathname}/${segment}`
}, '')
)
}
读懂这 30 行你就读懂了 90% 的 segment 规则:
(marketing)、@modal不出现在 URL;- 末尾的
page/route不出现在 URL(它们是文件名而非段名); - 其它段按顺序拼接。
isGroupSegment 的判断异常简单:
export function isGroupSegment(segment: string) {
// Use array[0] for performant purpose
return segment[0] === '(' && segment.endsWith(')')
}
export function isParallelRouteSegment(segment: string) {
return segment.startsWith('@') && segment !== '@children'
}
注意
@children是 Next.js 内部保留的 slot 名,代表"主 children"——你不能拿它当 parallel route 名字。
2. 业务示例 1:电商网站
把上面 11 种约定一次性用上的最小业务模型。需求:
- 公网营销页(
/、/about、/pricing),由市场团队维护,单独 layout(窄、有 footer CTA)。 - 商城(
/products、/products/[category]、/products/[category]/[id]),有独立的"商城 layout"(带筛选侧栏、购物车 icon)。 - 用户中心(
/account、/account/orders、/account/orders/[id]),强制登录、带 sidebar 导航。 - 错误处理:商品不存在 → 商品详情局部 not-found;账户被锁 → 全局
forbidden。
对应的目录结构:
app/
├── layout.tsx ← 根 layout:<html>/<body>、Provider
├── error.tsx ← 根错误兜底
├── global-error.tsx ← layout 自身崩溃才走这里
├── not-found.tsx ← 全局 404
├── forbidden.tsx ← 全局 403(实验)
│
├── (marketing)/ ← 路由组:纯组织代码
│ ├── layout.tsx ← 营销页 layout(窄页 + CTA)
│ ├── page.tsx ← /
│ ├── about/page.tsx ← /about
│ └── pricing/page.tsx ← /pricing
│
├── (shop)/ ← 商城路由组
│ ├── layout.tsx ← 商城 layout(筛选侧栏 + 购物车)
│ ├── loading.tsx ← 商城首屏 skeleton
│ ├── products/
│ │ ├── page.tsx ← /products
│ │ └── [category]/
│ │ ├── page.tsx ← /products/[category]
│ │ ├── loading.tsx ← 类目页 skeleton
│ │ └── [id]/
│ │ ├── page.tsx ← /products/[category]/[id]
│ │ ├── not-found.tsx ← 商品不存在的局部 fallback
│ │ └── error.tsx ← 商品页错误边界
│
├── account/
│ ├── layout.tsx ← 账户 sidebar
│ ├── page.tsx ← /account
│ └── orders/
│ ├── page.tsx ← /account/orders
│ └── [id]/page.tsx ← /account/orders/[id]
│
└── _components/ ← 私有组件,不参与路由
├── product-card.tsx
└── price-tag.tsx
几个关键点:
(marketing)和(shop)用 route group 隔离两个独立 layout,不会在 URL 上多一段——/about仍然是/about,不是/marketing/about。(shop)/loading.tsx在所有商城子页面共享 Suspense fallback;类目页又能用更细粒度的[category]/loading.tsx。[category]/[id]/not-found.tsx在商品详情页notFound()时只刷新局部,根 layout、商城 layout、类目 layout 都保留——这是 App Router 相比 Pages Router 的核心红利。_components/不参与路由,单纯组织代码。
2.1 在 fixture 中复现
本讲的配套 fixture 已经把上面的结构搭好(含 mock 数据)。你直接:
cd test/e2e/lecture-05
node ../../../packages/next/dist/bin/next.js dev --port 3005
然后在浏览器分别访问 /、/about、/products、/products/shoes、/products/shoes/abc123、/products/shoes/nonexistent(触发 not-found),观察哪几层 layout 被保留。
3. 业务示例 2:dashboard 的并行多面板
需求:
/dashboard主面板,左侧是team团队信息、右侧是analytics实时图表,二者并行加载、互不阻塞。- 如果当前 URL 是
/dashboard/settings,team 面板可以仍然显示(不重新加载),但 analytics 退回默认状态。
这就是 parallel route 解决的问题:
app/dashboard/
├── layout.tsx ← 用 props 接收 team / analytics 两个 slot
├── page.tsx ← 中央主面板
├── default.tsx ← URL 切换时的占位(必备)
├── @team/
│ ├── page.tsx
│ └── default.tsx ← 切走时显示
├── @analytics/
│ ├── page.tsx
│ ├── loading.tsx ← 独立 Suspense
│ └── default.tsx
└── settings/
└── page.tsx
layout.tsx 接 slot 作为 props:
export default function DashboardLayout({
children,
team,
analytics,
}: {
children: React.ReactNode;
team: React.ReactNode;
analytics: React.ReactNode;
}) {
return (
<div className="grid grid-cols-2 gap-4">
<aside>{team}</aside>
<section>{analytics}</section>
<main className="col-span-2">{children}</main>
</div>
);
}
default.tsx 是 parallel route 的"灵魂"——当 URL 从 /dashboard 切到 /dashboard/settings 时,settings 段没有 @team 或 @analytics,框架会去找它们各自的 default.tsx 作为 fallback。少了 default.tsx,导航直接会报错。
从源码看,default 在 getLayoutOrPageModule 中是与 layout / page 同级的优先级:
export async function getLayoutOrPageModule(loaderTree: LoaderTree) {
const { layout, page, defaultPage } = loaderTree[2]
const isLayout = typeof layout !== 'undefined'
const isPage = typeof page !== 'undefined'
const isDefaultPage =
typeof defaultPage !== 'undefined' && loaderTree[0] === DEFAULT_SEGMENT_KEY
let mod = undefined
let modType: 'layout' | 'page' | undefined = undefined
let filePath = undefined
if (isLayout) {
mod = await layout[0]()
modType = 'layout'
filePath = layout[1]
} else if (isPage) {
mod = await page[0]()
modType = 'page'
filePath = page[1]
} else if (isDefaultPage) {
mod = await defaultPage[0]()
modType = 'page'
filePath = defaultPage[1]
}
return { mod, modType, filePath }
}
loaderTree[0] === DEFAULT_SEGMENT_KEY(即 '__DEFAULT__')就是这个 fallback 的内部标识。
4. 业务示例 3:模态框 intercepting route
需求:在商品列表页 /products 点击商品卡片:
- 同一标签页内:弹出模态框显示商品详情(URL 变成
/products/[id])。 - 直接刷新或外链进入:显示完整商品详情页(同一个 URL)。
这个"同一 URL 两种渲染方式"的能力就是 intercepting route 的杀手锏。
目录结构:
app/
├── products/
│ ├── page.tsx ← 列表页
│ ├── [id]/page.tsx ← 完整详情页(直接访问走这里)
│ └── @modal/
│ ├── default.tsx ← 没模态框时
│ └── (.)[id]/ ← 同级拦截
│ └── page.tsx ← 模态框版详情(从列表点进来走这里)
└── layout.tsx ← 用 modal slot 渲染 <dialog>
四种拦截标记的语义:
// order matters here, the first match will be used
export const INTERCEPTION_ROUTE_MARKERS = [
'(..)(..)',
'(.)',
'(..)',
'(...)',
] as const
| 标记 | 含义 | 类比 |
|---|---|---|
(.) | 同级(sibling)拦截 | ./ |
(..) | 上一级拦截 | ../ |
(..)(..) | 上两级拦截 | ../../ |
(...) | 根拦截 | / |
注意:拦截标记不是相对文件系统,而是相对 URL 段。normalizeAppPath 已经剥离了 route group,所以"上一级"指的是 URL 上的上一级,不是文件系统的父目录。源码里这段逻辑用了 switch case 一字不漏写在 extractInterceptionRouteInformation:
switch (marker) {
case '(.)':
// (.) indicates that we should match with sibling routes, so we just need to append the intercepted route to the intercepting route
if (interceptingRoute === '/') {
interceptedRoute = `/${interceptedRoute}`
} else {
interceptedRoute = interceptingRoute + '/' + interceptedRoute
}
break
case '(..)':
// (..) indicates that we should match at one level up, so we need to remove the last segment of the intercepting route
if (interceptingRoute === '/') {
throw new Error(
`Invalid interception route: ${path}. Cannot use (..) marker at the root level, use (.) instead.`
)
}
interceptedRoute = interceptingRoute
.split('/')
.slice(0, -1)
.concat(interceptedRoute)
.join('/')
break
case '(...)':
// (...) will match the route segment in the root directory, so we need to use the root directory to prepend the intercepted route
interceptedRoute = '/' + interceptedRoute
break
case '(..)(..)':
// (..)(..) indicates that we should match at two levels up, so we need to remove the last two segments of the intercepting route
生产排查提示:当 intercepting route 表现异常(直接访问跳到模态框、或客户端导航没拦截)时,先看
Next-Url请求头。客户端 SPA 导航会带上这个 header,服务器据此判断"是从哪里导航过来的",并选择 intercepting 还是 intercepted 版本。isInterceptionRouteAppPath()是判定函数。
5. Catch-all 与 parallel route 的"内部联姻"
只有 catch-all 没什么稀奇,但 catch-all + parallel route 会产生一个有趣的问题:当一个 URL 命中了 catch-all 时,并行 slot 应该用哪个版本?
举例:
app/
├── @sidebar/
│ ├── default.tsx
│ └── [tab]/page.tsx
└── [...rest]/page.tsx
访问 /foo/bar:
children槽走[...rest]/page.tsx没问题。@sidebar怎么办?@sidebar/[tab]只接受一段,但 URL 给的是两段。
Next.js 的解决方式是让 catch-all 跨越 parallel 边界匹配。源码在 normalizeCatchAllRoutes 这里:
export function normalizeCatchAllRoutes(
appPaths: Record<string, string[]>,
normalizer = new AppPathnameNormalizer()
) {
const catchAllRoutes = [
...new Set(
Object.values(appPaths)
.flat()
.filter(isCatchAllRoute)
// Sorting is important because we want to match the most specific path.
.sort((a, b) => b.split('/').length - a.split('/').length)
),
]
// interception routes should only be matched by a single entrypoint
// we don't want to push a catch-all route to an interception route
// because it would mean the interception would be handled by the wrong page component
const filteredAppPaths = Object.keys(appPaths).filter(
(route) => !isInterceptionRouteAppPath(route)
)
for (const appPath of filteredAppPaths) {
for (const catchAllRoute of catchAllRoutes) {
const normalizedCatchAllRoute = normalizer.normalize(catchAllRoute)
const normalizedCatchAllRouteBasePath = normalizedCatchAllRoute.slice(
0,
normalizedCatchAllRoute.search(catchAllRouteRegex)
)
if (
// check if the appPath could match the catch-all
appPath.startsWith(normalizedCatchAllRouteBasePath) &&
// check if there's not already a slot value that could match the catch-all
!appPaths[appPath].some((path) => hasMatchedSlots(path, catchAllRoute))
) {
// optional catch-all routes are not currently supported, but leaving this logic in place
// for when they are eventually supported.
if (isOptionalCatchAll(catchAllRoute)) {
// optional catch-all routes should match both the root segment and any segment after it
// for example, `/[[...slug]]` should match `/` and `/foo` and `/foo/bar`
appPaths[appPath].push(catchAllRoute)
} else if (isCatchAll(catchAllRoute)) {
// regular catch-all (single bracket) should only match segments after it
// for example, `/[...slug]` should match `/foo` and `/foo/bar` but not `/`
if (normalizedCatchAllRouteBasePath !== appPath) {
appPaths[appPath].push(catchAllRoute)
}
}
}
}
}
}
读这段时把握三个细节:
- interception 路由被显式过滤掉——它们不参与 catch-all 跨段匹配,因为拦截语义需要精确控制。
hasMatchedSlots避免重复:如果某个 slot 已经有自己的版本(如@sidebar/[tab]),不再拉 catch-all 过来。- optional vs 非 optional catch-all 行为不同:
[[...slug]]可以匹配空段(即根),[...slug]不能。
这一段是"catch-all 在 parallel route 下行为怪异时"必读的源码。
6. 路由合法性校验:validateAppPaths
并不是所有目录结构都合法。Next.js 在 build 阶段会跑一遍校验:
export function validateAppPaths(
appPaths: readonly string[]
): NormalizedAppRoute[] {
// First, validate each path individually
const paramsByPath = new Map<string, NormalizedAppRoute>()
for (const path of appPaths) {
paramsByPath.set(path, parseAndValidateAppPath(path))
}
// Group paths by their normalized structure for ambiguity detection
const structureMap = new Map<string, string[]>()
for (const [path, route] of paramsByPath) {
// ...
}
// Check for ambiguous routes (different slug names, same structure)
const conflicts: Array<{ paths: string[]; normalizedPath: string }> = []
for (const [structure, paths] of structureMap) {
if (paths.length > 1) {
// Multiple paths map to the same structure - this is ambiguous
conflicts.push({
paths,
normalizedPath: structure,
})
}
}
它做三件事:
单路径合法性:catch-all 必须在末尾、slug 名字不能重复、不能用
…三点字符等。131:packages/next/src/build/validate-app-paths.tsfor (let i = 0; i < route.segments.length; i++) { const segment = route.segments[i] // Type narrowing - only process dynamic segments if (segment.type === 'dynamic') { // First, validate syntax validateSegmentParam(segment.param, route.pathname) const properties = getParamProperties(segment.param.paramType) if (properties.repeat) { if (properties.optional) { hasOptionalCatchAllInPath = true } else { hasCatchAll = true } catchAllPosition = i } // Check to see if the parameter name is already in use. if (slugNames.has(segment.param.paramName)) { throw new Error( `You cannot have the same slug name "${segment.param.paramName}" repeat within a single dynamic path in route "${route.pathname}".` ) }跨路径不可歧义:例如同时存在
app/[user]/page.tsx和app/[id]/page.tsx——结构相同、URL 等价但 slug 名不同,无法静态解析。optional catch-all 与同位静态路由冲突:例如
app/[[...slug]]/page.tsx与app/page.tsx都能匹配/,必须报错。
生产排查提示:当
next build报 "Ambiguous app routes detected" 时,错误信息会直接列出冲突的几个路径——按 slug 名归一即可。
7. LoaderTree:把目录变成数据结构
文件系统是"扁平"的,但 React 渲染是"嵌套"的。中间这层转换的产物叫 LoaderTree,定义如下:
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,
]
读懂这个 4 元组就读懂了 App Router 的内核:
| 索引 | 含义 |
|---|---|
[0] segment | 段名('products' / '[id]' / '__PAGE__' / '__DEFAULT__') |
[1] parallelRoutes | { children: LoaderTree, '@modal': LoaderTree, ... } |
[2] modules | { layout, page, template, loading, error, default, ... } 各自的动态 import 元组 |
[3] staticSiblings | 静态兄弟段列表(给客户端 prefetch 用) |
而 modules 这一段的类型:
export type AppDirModules = {
readonly [moduleKey in ValueOf<typeof FILE_TYPES>]?: ModuleTuple
} & {
readonly page?: ModuleTuple
} & {
readonly metadata?: CollectedMetadata
} & {
readonly defaultPage?: ModuleTuple
}
每个 module 是 [() => Promise<unknown>, filePath] 这样的二元组——第一个元素是懒加载 import,第二个是绝对路径。这就保证了只有真正被渲染到的段才会被 require。
7.1 LoaderTree 是谁、什么时候生成的
next-app-loader 是一个 webpack/turbopack 通用的 loader(路径 packages/next/src/build/webpack/loaders/next-app-loader/),它在编译期:
- 接收一个
app/page.tsx的入口; - 沿目录向上爬,收集 layout / template / loading / error / 同级 parallel slot 等;
- 把这棵树以 JS 源码字符串形式拼出来,作为入口模块的 default export。
最终编译出来的模块大致长这样(简化):
export const tree = [
"", // 根段
{
children: [
"(shop)",
{
children: [
"products",
{
children: [
"__PAGE__",
{},
{
page: [
() => import("./app/(shop)/products/page.tsx"),
".../page.tsx",
],
},
null,
],
},
{
layout: [() => import("./app/(shop)/layout.tsx"), ".../layout.tsx"],
},
null,
],
},
{},
null,
],
},
{ layout: [() => import("./app/layout.tsx"), ".../layout.tsx"] },
null,
];
重要:LoaderTree 是编译期产物。HMR 时如果某个 page 新增,整棵树会重新生成;这也是为什么 dev 模式下 on-demand entry 要按 page 维度组织缓存。
8. 从 LoaderTree 到 React 树
LoaderTree 不是 React 元素树——它需要被"装配"成真正的 React 元素。装配器是 createComponentTree:
- 入口文件:
packages/next/src/server/app-render/create-component-tree.tsx - 配套:
create-flight-router-state-from-loader-tree.ts、walk-tree-with-flight-router-state.tsx
第 8 讲会专门拆这个转换过程。本讲你只需要知道:LoaderTree 是数据,ComponentTree 是元素,FlightRouterState 是协议——三者一对一映射。
9. 路由匹配:从 URL 到 LoaderTree
build 完成后,所有可能的 path 被写进 app-paths-manifest.json(第 6 讲专门讲)。运行时,请求进来后由"matcher manager"做匹配:
src/server/route-matcher-managers/default-route-matcher-manager.ts
└ dev-route-matcher-manager.ts (dev 多一些)
src/server/route-matchers/
├ app-page-route-matcher.ts
├ app-route-route-matcher.ts
└ ... (pages 也在这里)
src/server/route-matcher-providers/
├ app-page-route-matcher-provider.ts
├ app-route-route-matcher-provider.ts
└ dev/ (dev 子目录有专属 provider)
注意 matcher 本身极简——AppPageRouteMatcher 只是给定义补一个 identity:
import { RouteMatcher } from './route-matcher'
import type { AppPageRouteDefinition } from '../route-definitions/app-page-route-definition'
export class AppPageRouteMatcher extends RouteMatcher<AppPageRouteDefinition> {
public get identity(): string {
return `${this.definition.pathname}?__nextPage=${this.definition.page}`
}
}
真正的"匹配"动作发生在 default-route-matcher-manager,它按优先级依次试一组 matcher:app-route → app-page → pages-api → pages。
生产排查提示:用户报"我有
app/foo/page.tsx也有pages/foo.tsx,访问/foo不知道走哪个"——查default-route-matcher-manager.ts的 provider 注册顺序。当前规则是 app router 优先,但 dev 模式会给警告。
10. 业务示例 4:route 文件 vs page 文件
app/api/orders/route.ts 和 app/orders/page.tsx 是两种完全不同的端点:
route.ts走app-route路由模块,导出 HTTP method(GET / POST / ...),返回Response或NextResponse。page.tsx走app-page路由模块,导出 default React Component,返回 JSX,框架负责渲染成 HTML + RSC payload。
isAppRouteRoute 和 isAppPageRoute 是两个极简的判定函数:
export function isAppPageRoute(route: string): boolean {
return route.endsWith('/page')
}
export function isAppRouteRoute(route: string): boolean {
return route.endsWith('/route')
}
注意它们判定的是 normalized route(已经被
normalizeAppPath处理过的),所以末尾保留/page或/route——这是 framework internal 用的"段标识",不是 URL。
11. 配套 fixture:动手做实验
本讲的 fixture 已经放在 test/e2e/lecture-05/,目录结构覆盖了上面 4 个业务示例的最小可工作版本。
11.1 启动方式
# 在仓库根
cd test/e2e/lecture-05
node ../../../packages/next/dist/bin/next.js dev --port 3005
第一次跑前确保已
pnpm --filter=next build;watch 模式开着也行。
11.2 推荐实验顺序
认识普通 layout 嵌套 访问
/、/about、/pricing,刷新页面,在 DevTools 中打开 React DevTools 看组件层级,确认根 layout 和(marketing)layout 都在。观察 route group 不出现在 URL 修改
app/(marketing)/about/page.tsx的标题,访问/about(不是/marketing/about)观察变化。观察 layout 不重新挂载 在
(shop)/layout.tsx里console.log('shop layout mount'),分别访问/products、/products/shoes,看日志是否只输出一次。dynamic + not-found 访问
/products/shoes/abc123(正常) →/products/shoes/nonexistent(触发notFound())。注意根 layout 和商城 layout 都还在,只有[id]段被替换。parallel route 访问
/dashboard看到team+analytics同时显示;访问/dashboard/settings,URL 变了但teamslot 仍在(因为有default.tsx)。试着删除@analytics/default.tsx,重启,再访问/dashboard/settings,应当报错。intercepting route
/products列表页里有<Link href="/products/shoes/abc123">。点击它——弹出模态框(URL 仍变成/products/shoes/abc123)。直接刷新这个 URL——展示完整详情页。
11.3 fixture 的关键文件
fixture 内只放最小必要代码(每个 page / layout 都很短),让你能在一屏里看清楚。所有 mock 数据都内联在文件里,不依赖外部 fetch,方便离线学习。
12. 重难点
12.1 (group) vs _folder:什么时候用哪个?
- 需要"统一 layout 但不改 URL":
(group)。 - 仅仅放工具/组件,不想被识别为路由:
_folder。
错误用法:用 (components) 当组件文件夹——这会在编译期把里面所有 page.tsx 文件意外当成路由。
12.2 parallel route 的"导航语义"
childrenslot 总是被填充:URL 切到哪它就跟到哪。- 其它 slot 在 URL 不变时保留旧值;URL 变了且新位置没有定义这个 slot,就走
default.tsx。 default.tsx不是可选——少了它,URL 切换会报错 "missing default page for slot..."。
12.3 intercepting route 触发条件
只有客户端 SPA 导航才会触发拦截。直接打开 URL(首次加载、刷新、外链跳入)一律走 intercepted 版本——这是 design intent,不是 bug。
技术实现是:客户端 Link 点击触发的请求会带 Next-Url header,服务器据此匹配 intercepting variant。SSR 没有这个 header,所以走默认。
12.4 同一段同时存在 page.tsx 和 route.ts?
不行。一个 URL 端点只能有一种语义(要么 UI,要么 HTTP handler)。validateAppPaths 不直接检查这个,但 route-discovery / entries 阶段会冲突。
12.5 catch-all 与 dynamic 共存
app/[user]/page.tsx 与 app/[...rest]/page.tsx 同时存在时,dynamic 优先级高于 catch-all——URL /john 走 [user],/john/posts/123 走 [...rest]。但 / 都不会走(因为 [...rest] 不匹配空段,需要 [[...rest]])。
13. 检验问题
(group)不出现在 URL,那它在 LoaderTree 里出现吗?为什么?- 一个段如果同时存在
page.tsx和route.ts,会怎么样? default.tsx与page.tsx都没定义时,parallel slot 在 URL 不匹配的情况下显示什么?(.)photo和(..)photo在文件系统上的位置差异是什么?拦截语义差异?- 写一个最简的
validateAppPaths失败的例子(即必须抛错的 app/ 结构)。 - LoaderTree 是数据还是 React 元素?它什么时候生成、什么时候被消费?
_components/与(components)/行为差异是什么?[id]与[[...id]]在/fooURL 下行为差异?Next-Urlheader 是干什么的?为什么 intercepting route 强依赖它?app-paths-manifest.json与 LoaderTree 是同一份东西吗?关系是什么?(提示:下一讲)
14. 延伸阅读
- 官方文档:
docs/01-app/03-api-reference/03-file-conventions/(每个特殊文件都有专页) - 源码:
packages/next/src/build/webpack/loaders/next-app-loader/index.ts(LoaderTree 生成器,1100+ 行,建议精读 90 行附近的常量与最外层结构) - 源码:
packages/next/src/server/lib/app-dir-module.ts(LoaderTree 类型定义 + getter helper) - 配套 fixture:
test/e2e/lecture-05/(建议把它当成自己的"训练靶场",鼓励改 / 删 / 加来理解每条约定)
下一讲预告
第 06 讲|Manifest 体系:App 是怎么"打开"的:build 完成后 .next/ 里那一堆 *-manifest.json 是怎么产生、谁在消费的?我们会拆 app-paths-manifest.json、app-build-manifest.json、client-reference-manifest.json 与 server-reference-manifest.json 这 4 张关键表,把 LoaderTree → Manifest → 运行时匹配 这条线全部串起来。