Read OSS

客户端路由器:导航、缓存与状态管理

高级

前置知识

  • 第 1-3 篇:架构、服务端生命周期与 App Router 渲染机制
  • React context 与 useReducer 模式
  • 浏览器 History API(pushState、popstate)
  • 第 3 篇中介绍的 RSC Flight payload 格式

客户端路由器:导航、缓存与状态管理

当用户在 Next.js App Router 应用中点击一个链接时,页面不会重新加载。取而代之的是,一个精密的客户端状态机从服务器获取 RSC Flight payload,将其应用到已缓存的 React 元素树上,并只更新发生变化的 segment——未变动的 segment 则完整保留其布局状态、滚动位置和 React 组件状态。本文将深入探讨这一机制的工作原理。

AppRouter:根组件

app-router.tsx 中的 AppRouter 组件是 App Router 客户端树的根节点,它负责初始化以下内容:

  1. 路由 reducer(状态机)
  2. History 事件监听器(popstate)
  3. 为导航 hooks 提供数据的 context providers
  4. 全局错误处理的 error boundaries
flowchart TD
    AppRouter["AppRouter Component"] --> ActionQueue["useActionQueue()\n(reducer state)"]
    AppRouter --> History["HistoryUpdater\n(pushState/popstate)"]
    AppRouter --> Providers["Context Providers"]
    AppRouter --> ErrorBoundary["RootErrorBoundary"]

    Providers --> AppRouterContext["AppRouterContext\n(router instance)"]
    Providers --> LayoutRouterContext["LayoutRouterContext\n(segment tree)"]
    Providers --> PathnameContext["PathnameContext"]
    Providers --> SearchParamsContext["SearchParamsContext"]

    ActionQueue --> Reducer["clientReducer()\n(router-reducer.ts)"]

HistoryUpdater 组件值得特别关注——它是一个 React 组件,唯一的职责就是在状态变化时调用 window.history.pushState()replaceState()。将这个逻辑封装成组件,使其在 React 的 commit 阶段执行,从而确保 history 更新与已渲染的 UI 保持同步。

初始状态来自服务端渲染的页面。服务器会下发初始的 FlightRouterState 树和 CacheNode 树(正如第 3 篇中所见,通过内联的 <script> 标签传递)。客户端路由器从这个初始状态完成 hydration,之后接管所有后续的更新。

路由 Reducer 状态机

客户端路由器的核心是 clientReducer 函数。它遵循类 Redux 的模式——一个纯函数,接收当前状态和一个 action,返回新的状态:

function clientReducer(
  state: ReadonlyReducerState,
  action: ReducerActions
): ReducerState {
  switch (action.type) {
    case ACTION_NAVIGATE:
      return navigateReducer(state, action)
    case ACTION_SERVER_PATCH:
      return serverPatchReducer(state, action)
    case ACTION_RESTORE:
      return restoreReducer(state, action)
    case ACTION_REFRESH:
      return refreshReducer(state, action)
    case ACTION_HMR_REFRESH:
      return hmrRefreshReducer(state)
    case ACTION_SERVER_ACTION:
      return serverActionReducer(state, action)
    default:
      throw new Error('Unknown action')
  }
}

每种 action 对应一种不同的导航场景:

stateDiagram-v2
    [*] --> Idle: Initial render

    Idle --> Navigate: ACTION_NAVIGATE\n(Link click, router.push)
    Idle --> Restore: ACTION_RESTORE\n(Browser back/forward)
    Idle --> Refresh: ACTION_REFRESH\n(router.refresh())
    Idle --> ServerAction: ACTION_SERVER_ACTION\n('use server' call)
    Idle --> HMRRefresh: ACTION_HMR_REFRESH\n(Dev hot reload)

    Navigate --> FetchFlight: Fetch RSC payload
    FetchFlight --> ServerPatch: ACTION_SERVER_PATCH\n(Apply response)
    ServerPatch --> Idle: Update tree + cache

    Restore --> Idle: Restore from history state

    Refresh --> FetchFlight
    ServerAction --> FetchFlight

    HMRRefresh --> Idle: Force re-render

这里有一个微妙但重要的架构选择:reducer 同样会被编译到服务端(通过 serverReducer),但它是一个空操作(no-op)。这样一来,相同的路由组件代码在 SSR 期间也能正常运行,而实际的状态管理只在浏览器中激活。第 60 行的注释对此有所说明:"we don't run the client reducer on the server, so we use a noop function for better tree shaking。"

为导航获取 RSC Payload

navigateReducer 执行时,会触发一个服务器请求,获取新路由的 RSC payload。fetch-server-response.ts 模块负责处理这一过程:

sequenceDiagram
    participant Router as Client Router
    participant Fetch as fetch-server-response
    participant Server as Next.js Server
    participant Flight as React Flight Client

    Router->>Fetch: Navigate to /dashboard
    Fetch->>Server: GET /dashboard
    Note over Fetch,Server: Headers: RSC: 1, Next-Router-State-Tree: [current tree], Next-Url: /dashboard
    Server-->>Fetch: Flight payload (binary stream)
    Fetch->>Flight: createFromFetch(response)
    Flight-->>Router: NavigationFlightResponse
    Router->>Router: Apply to CacheNode tree

请求中包含若干特殊请求头,服务器依据这些信息来决定返回的内容:

  • RSC: 1 — 标识这是一个 RSC 请求(返回 Flight 数据,而非 HTML)
  • Next-Router-State-Tree — 客户端当前的 FlightRouterState,供服务器计算差异
  • Next-Url — 当前 URL,用于 middleware 的判断
  • Next-Router-Prefetch — 预取请求时设置,此时仅返回布局数据

响应内容通过 react-server-dom-webpack/client 中 React 提供的 createFromFetch() API 进行解析。该函数读取二进制 Flight 流,并重建出可供客户端 React 实例渲染的 React 元素树。

提示: Flight payload 并非 JSON——它是 React 专有的二进制格式,能够表示 React 元素、client references、promises 以及流式数据。无法用 response.json() 读取,只能通过 createFromFetchcreateFromReadableStream 函数进行解码。

布局保持与 CacheNode 树

App Router 最直观的特性之一是布局保持——当你从 /dashboard/analytics 导航到 /dashboard/settings 时,dashboard 布局会保留其状态(滚动位置、表单输入、组件状态)。这一能力依赖 CacheNode 树来实现:

export type CacheNode = {
  rsc: React.ReactNode        // Full RSC data
  prefetchRsc: React.ReactNode // Prefetched static shell
  prefetchHead: HeadData | null
  head: HeadData
  slots: Record<string, CacheNode> | null
  // ...
}
graph TD
    Root["CacheNode: /"] --> Dashboard["CacheNode: dashboard"]
    Root --> Settings["CacheNode: settings"]

    Dashboard --> Analytics["CacheNode: analytics\n(page content)"]
    Dashboard --> DashSettings["CacheNode: settings\n(page content)"]

    style Analytics fill:#9f9,stroke:#333
    style DashSettings fill:#ff9,stroke:#333

    classDef active fill:#9f9
    classDef navigating fill:#ff9

layout-router.tsx 组件负责渲染嵌套布局树的每一层。它通过 context 接收对应 segment 的 CacheNode,并渲染其中的 rsc 内容——如果完整数据尚未到达,则回退到 prefetchRsc(借助 React 的 useDeferredValue 实现)。

导航发生时,路由器会对比当前和目标的 FlightRouterState 树。未发生变化的 segment 保留原有的 CacheNode,React 不会重新渲染它们;只有变化的 segment 才会获得新的 CacheNode,并从 Flight payload 中填入新的 RSC 数据。这是路由 segment 层面的结构共享(structural sharing)。

CacheNode 中的 slots 字段将并行路由的键映射到其子节点。例如 dashboard segment 可能包含 children(默认 slot)和 @modal(并行路由)两个 slot,每个 slot 各自独立维护自己的缓存树。

面向 PPR 预取的 Segment Cache

segment-cache/ 目录实现了针对启用 PPR 的路由的按 segment 缓存。当 PPR 开启时,预取操作不会获取整个路由的 Flight 数据,而是独立地按 segment 进行获取:

flowchart TD
    Link["<Link> visible\nin viewport"] --> Prefetch["prefetch()"]
    Prefetch --> SegmentCache["Segment Cache"]

    SegmentCache --> RootSegment["/ segment\n(static shell)"]
    SegmentCache --> DashSegment["/dashboard segment\n(static shell)"]
    SegmentCache --> PageSegment["/dashboard/analytics segment\n(may be dynamic)"]

    Navigate["User clicks link"] --> CheckCache["Check segment cache"]
    CheckCache --> Hit["Cache hit:\nServe static shell instantly"]
    CheckCache --> Miss["Cache miss:\nFetch dynamic data"]
    Hit --> Merge["Merge shells +\ndynamic data"]
    Miss --> Merge
    Merge --> Render["Render updated tree"]

segment cache 模块由以下几个文件组成:

文件 职责
cache.ts 核心缓存数据结构与操作
cache-key.ts 从路由 segment 生成缓存键
prefetch.ts 预取调度与执行
navigation.ts 感知缓存的导航逻辑
scheduler.ts 基于优先级的预取调度
lru.ts 用于内存管理的 LRU 淘汰策略

这是客户端路由器中最精妙的部分之一。它不缓存完整路由,而是缓存单个 segment——这样一来,PPR 路由的静态部分可以在共享布局的不同页面间复用,而动态 segment 则按需获取。

导航 Hooks:useRouter、usePathname、useSearchParams

公开的导航 API 通过 navigation.ts 中的 React context 实现:

flowchart TD
    AppRouter["AppRouter"] --> Ctx1["AppRouterContext\n(provides: router instance)"]
    AppRouter --> Ctx2["PathnameContext\n(provides: pathname string)"]
    AppRouter --> Ctx3["SearchParamsContext\n(provides: URLSearchParams)"]
    AppRouter --> Ctx4["PathParamsContext\n(provides: dynamic params)"]

    Ctx1 --> useRouter["useRouter()\nreturns: push, replace, refresh, back, forward, prefetch"]
    Ctx2 --> usePathname["usePathname()\nreturns: string"]
    Ctx3 --> useSearchParams["useSearchParams()\nreturns: ReadonlyURLSearchParams"]
    Ctx4 --> useParams["useParams()\nreturns: Params object"]

useRouter() hook 返回在 app-router-instance.ts 中创建的路由实例。该模块创建了 publicAppRouterInstance——一个提供 push()replace()refresh()back()prefetch() 等方法的对象,每个方法都会向路由 reducer 派发相应的 action。

navigation.ts 中有一个值得关注的模式:双环境处理。在服务端(SSR)运行时,useSearchParamsuseParams 需要为 PPR 触发动态渲染降级(bailout)。这是通过仅在服务端有条件地引入 useDynamicRouteParamsuseDynamicSearchParams 来实现的:

const useDynamicRouteParams =
  typeof window === 'undefined'
    ? require('../../server/app-render/dynamic-rendering').useDynamicRouteParams
    : undefined

这种通过 typeof window 判断来有条件引入服务端模块的模式,在客户端代码中随处可见。它确保服务端专属代码在构建浏览器 bundle 时被 tree-shaking 移除。

提示: App Router 中的 useRouter()next/router(Pages Router)中的 useRouter() 返回的是不同的对象,二者 API 不同,底层状态管理系统也完全独立。如果你正在从 Pages Router 迁移,这是一个常见的混淆点。

路由实例与命令式导航

app-router-instance.ts 模块创建了 AppRouterActionQueue——负责协调 React 状态与 reducer 之间通信的调度器:

export type AppRouterActionQueue = {
  state: AppRouterState
  dispatch: (payload: ReducerActions, setState: DispatchStatePromise) => void
  action: (state: AppRouterState, action: ReducerActions) => ReducerState
}

该队列负责处理 action 的串行化——当多个导航快速触发时(例如用户快速点击不同链接),action 会被排队并按顺序处理。队列还与 React 的 startTransition API 集成,确保导航状态更新被视为低优先级的 transition,不会阻塞用户输入。

路由实例上的 prefetch() 方法与 segment cache 系统相连。当 <Link> 组件进入视口时,会通过 prefetchWithSegmentCache() 触发预取。预取调度器会根据视口可见性和用户交互模式来决定优先级。

下一篇

至此,我们已经完整地梳理了 App Router 的整个生命周期——从第 3 篇的服务端渲染,到本篇的客户端导航与状态管理。下一篇文章将聚焦于构建系统:next build 如何发现路由、生成代码、配置三个独立的 webpack 编译流程,以及如何生成连接服务端与客户端模块图的 manifest 文件。