客户端路由器:导航、缓存与状态管理
前置知识
- ›第 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 客户端树的根节点,它负责初始化以下内容:
- 路由 reducer(状态机)
- History 事件监听器(popstate)
- 为导航 hooks 提供数据的 context providers
- 全局错误处理的 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()读取,只能通过createFromFetch和createFromReadableStream函数进行解码。
布局保持与 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)运行时,useSearchParams 和 useParams 需要为 PPR 触发动态渲染降级(bailout)。这是通过仅在服务端有条件地引入 useDynamicRouteParams 和 useDynamicSearchParams 来实现的:
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 文件。