发布日期

第 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 把"约定优于配置"做到了极致——目录结构本身就是路由系统。所有特殊文件的语义都集中在一个常量表里:

94:packages/next/src/build/webpack/loaders/next-app-loader/index.ts
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 就在它上面:

74:packages/next/src/build/webpack/loaders/next-app-loader/index.ts
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.tsxdefault React ComponentURL 终点,定义这一段对应的 UI✅ leaf
route.tsGET / POST / ...URL 终点,定义 HTTP handler(不参与渲染管线)✅ leaf
layout.tsxdefault React Component with children这一段及子段共享的 UI 外壳;导航时不重新挂载
template.tsxdefault React Component with children与 layout 同位但每次导航重新挂载,可用于动画/状态重置
loading.tsxdefault React Component自动包一层 Suspense fallback
error.tsxdefault React Component ('use client')自动包一层 Error Boundary(仅当前段及以下)
not-found.tsxdefault React ComponentnotFound() 抛出后的 fallback
forbidden.tsxdefault React Componentforbidden() 抛出后的 fallback(实验)
unauthorized.tsxdefault React Componentunauthorized() 抛出后的 fallback(实验)
default.tsxdefault React Componentparallel route 在 URL 不变时的占位 UI(必备!)
global-error.tsxdefault React Component替代 app/error.tsx 用于根布局错误(包到 <html> 之外)
global-not-found.tsxdefault React Component根级 404 页面
middleware.tsdefault middleware function请求中间件(不在 app/ 内,必须在项目根)
instrumentation.tsregister() / 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 剥离
@modalparallel route(slot)✅ 作为 LoaderTree 的兄弟分支
(.)photointercepting (同级)✅ 路径与被拦截路由一致✅ 特殊处理
(..)photointercepting (上一级)同上
(...)photointercepting (根)同上
(..)(..)photointercepting (上两级)同上
_lib私有文件夹❌ 完全不参与路由

下划线前缀(_lib_components)是社区约定的"私有文件夹",next.js 显式不识别下划线开头的文件夹为路由段——这是放工具/组件的安全位置。

1.3 normalize 的力学

route group 与 parallel route 这两个"不出现在 URL"的段,由 normalizeAppPath 在编译期剥离:

52:packages/next/src/shared/lib/router/utils/app-paths.ts
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 的判断异常简单:

14:packages/next/src/shared/lib/segment.ts
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

几个关键点:

  1. (marketing)(shop)route group 隔离两个独立 layout,不会在 URL 上多一段——/about 仍然是 /about,不是 /marketing/about
  2. (shop)/loading.tsx 在所有商城子页面共享 Suspense fallback;类目页又能用更细粒度的 [category]/loading.tsx
  3. [category]/[id]/not-found.tsx商品详情notFound() 时只刷新局部,根 layout、商城 layout、类目 layout 都保留——这是 App Router 相比 Pages Router 的核心红利。
  4. _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.tsxURL 切换时的占位(必备)
├── @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 同级的优先级:

57:packages/next/src/server/lib/app-dir-module.ts
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>

四种拦截标记的语义:

9:packages/next/src/shared/lib/router/utils/interception-routes.ts
// 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

101:packages/next/src/shared/lib/router/utils/interception-routes.ts
  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 这里:

62:packages/next/src/build/normalize-catchall-routes.ts
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)
          }
        }
      }
    }
  }
}

读这段时把握三个细节:

  1. interception 路由被显式过滤掉——它们不参与 catch-all 跨段匹配,因为拦截语义需要精确控制。
  2. hasMatchedSlots 避免重复:如果某个 slot 已经有自己的版本(如 @sidebar/[tab]),不再拉 catch-all 过来。
  3. optional vs 非 optional catch-all 行为不同:[[...slug]] 可以匹配空段(即根),[...slug] 不能。

这一段是"catch-all 在 parallel route 下行为怪异时"必读的源码。

6. 路由合法性校验:validateAppPaths

并不是所有目录结构都合法。Next.js 在 build 阶段会跑一遍校验:

281:packages/next/src/build/validate-app-paths.ts
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,
      })
    }
  }

它做三件事:

  1. 单路径合法性:catch-all 必须在末尾、slug 名字不能重复、不能用 三点字符等。

    131:packages/next/src/build/validate-app-paths.ts
      for (let i = 0; i < route.segments.length; i++) &#123;
        const segment = route.segments[i]
    
        // Type narrowing - only process dynamic segments
        if (segment.type === 'dynamic') &#123;
          // First, validate syntax
          validateSegmentParam(segment.param, route.pathname)
    
          const properties = getParamProperties(segment.param.paramType)
    
          if (properties.repeat) &#123;
            if (properties.optional) &#123;
              hasOptionalCatchAllInPath = true
            &#125; else &#123;
              hasCatchAll = true
            &#125;
    
            catchAllPosition = i
          &#125;
    
          // Check to see if the parameter name is already in use.
          if (slugNames.has(segment.param.paramName)) &#123;
            throw new Error(
              `You cannot have the same slug name "${segment.param.paramName}" repeat within a single dynamic path in route "${route.pathname}".`
            )
          &#125;
    
  2. 跨路径不可歧义:例如同时存在 app/[user]/page.tsxapp/[id]/page.tsx——结构相同、URL 等价但 slug 名不同,无法静态解析。

  3. optional catch-all 与同位静态路由冲突:例如 app/[[...slug]]/page.tsxapp/page.tsx 都能匹配 /,必须报错。

生产排查提示:当 next build 报 "Ambiguous app routes detected" 时,错误信息会直接列出冲突的几个路径——按 slug 名归一即可。

7. LoaderTree:把目录变成数据结构

文件系统是"扁平"的,但 React 渲染是"嵌套"的。中间这层转换的产物叫 LoaderTree,定义如下:

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,
]

读懂这个 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 这一段的类型:

123:packages/next/src/build/webpack/loaders/next-app-loader/index.ts
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/),它在编译期:

  1. 接收一个 app/page.tsx 的入口;
  2. 沿目录向上爬,收集 layout / template / loading / error / 同级 parallel slot 等;
  3. 把这棵树以 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.tswalk-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:

9:packages/next/src/server/route-matchers/app-page-route-matcher.ts
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.tsapp/orders/page.tsx 是两种完全不同的端点:

  • route.tsapp-route 路由模块,导出 HTTP method(GET / POST / ...),返回 ResponseNextResponse
  • page.tsxapp-page 路由模块,导出 default React Component,返回 JSX,框架负责渲染成 HTML + RSC payload。

isAppRouteRouteisAppPageRoute 是两个极简的判定函数:

3:packages/next/src/lib/is-app-page-route.ts
export function isAppPageRoute(route: string): boolean {
  return route.endsWith('/page')
}
3:packages/next/src/lib/is-app-route-route.ts
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 推荐实验顺序

  1. 认识普通 layout 嵌套 访问 //about/pricing,刷新页面,在 DevTools 中打开 React DevTools 看组件层级,确认根 layout 和 (marketing) layout 都在。

  2. 观察 route group 不出现在 URL 修改 app/(marketing)/about/page.tsx 的标题,访问 /about(不是 /marketing/about)观察变化。

  3. 观察 layout 不重新挂载(shop)/layout.tsxconsole.log('shop layout mount'),分别访问 /products/products/shoes,看日志是否只输出一次。

  4. dynamic + not-found 访问 /products/shoes/abc123(正常) → /products/shoes/nonexistent(触发 notFound())。注意根 layout 和商城 layout 都还在,只有 [id] 段被替换。

  5. parallel route 访问 /dashboard 看到 team + analytics 同时显示;访问 /dashboard/settings,URL 变了但 team slot 仍在(因为有 default.tsx)。试着删除 @analytics/default.tsx,重启,再访问 /dashboard/settings,应当报错。

  6. 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 的"导航语义"

  • children slot 总是被填充: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.tsxroute.ts

不行。一个 URL 端点只能有一种语义(要么 UI,要么 HTTP handler)。validateAppPaths 不直接检查这个,但 route-discovery / entries 阶段会冲突。

12.5 catch-all 与 dynamic 共存

app/[user]/page.tsxapp/[...rest]/page.tsx 同时存在时,dynamic 优先级高于 catch-all——URL /john[user]/john/posts/123[...rest]。但 / 都不会走(因为 [...rest] 不匹配空段,需要 [[...rest]])。

13. 检验问题

  1. (group) 不出现在 URL,那它在 LoaderTree 里出现吗?为什么?
  2. 一个段如果同时存在 page.tsxroute.ts,会怎么样?
  3. default.tsxpage.tsx 都没定义时,parallel slot 在 URL 不匹配的情况下显示什么?
  4. (.)photo(..)photo 在文件系统上的位置差异是什么?拦截语义差异?
  5. 写一个最简的 validateAppPaths 失败的例子(即必须抛错的 app/ 结构)。
  6. LoaderTree 是数据还是 React 元素?它什么时候生成、什么时候被消费?
  7. _components/(components)/ 行为差异是什么?
  8. [id][[...id]]/foo URL 下行为差异?
  9. Next-Url header 是干什么的?为什么 intercepting route 强依赖它?
  10. 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.jsonapp-build-manifest.jsonclient-reference-manifest.jsonserver-reference-manifest.json 这 4 张关键表,把 LoaderTree → Manifest → 运行时匹配 这条线全部串起来。