- 发布日期
第 09 讲|客户端 App Router:reducer + segment cache + LayoutRouter
客户端 App Router:reducer 状态机、segment cache 与 LayoutRouter 的协作
阶段二:App Router 核心机制 · 第 9 / 40 讲 难度:⭐⭐⭐⭐⭐ · 预计耗时:4 小时 配套 fixture:
fixtures/lecture-09/
学习目标
- 理解 App Router 在浏览器侧的"三层架构":Reducer 状态机 + Segment Cache + LayoutRouter。
- 能区分 6 个 router action(navigate / restore / refresh / hmr-refresh / server-patch / server-action)的触发与差异。
- 能说清 prefetch 链路:
<Link>→pingVisibleLinks→schedulePrefetchTask→ segment cache。 - 能解释 hard navigation vs soft navigation;bfcache 的兼容;
window.next.__pendingUrl与__PRIVATE_NEXTJS_INTERNALS_TREE。 - 在 fixture 中通过
window.nd实时观察 cache + tree。
1. 三层架构鸟瞰
第 8 讲我们看到服务端把 LoaderTree 装配为 CacheNodeSeedData。这一讲把镜头切到客户端:种子数据落地以后,由谁接住?由谁更新?由谁触发渲染?
App Router 在浏览器里的实现可以拆成三层:
| 层 | 责任 | 关键文件 |
|---|---|---|
| Reducer 状态机 | 接收 action,返回新的 AppRouterState | client/components/router-reducer/router-reducer.ts 与同目录 reducers/*.ts |
| Segment Cache | 缓存 prefetch 与导航产物,调度网络请求 | client/components/segment-cache/cache.ts 与 prefetch.ts scheduler.ts |
| LayoutRouter | 把 CacheNode 映射到 React 元素树,订阅状态 | client/components/layout-router.tsx 与 app-router.tsx |
整体调用链:
<Link href> --visible--> pingVisibleLinks
--> prefetch(href, ...)
--> schedulePrefetchTask
--> Segment Cache 写入
用户点击 Link
--> dispatchAppRouterAction({ type: ACTION_NAVIGATE })
--> useActionQueue 收到
--> navigateReducer
--> navigateUsingSegmentCache (读取 cache 命中 / 触发 fetch)
--> 返回新的 AppRouterState
useActionQueue 触发 React 重渲染
--> AppRouter 把新的 cache + tree 通过 Context 注入
--> LayoutRouter 在每个 segment 节点拿到对应的 CacheNode 并渲染
速记:Reducer 改状态,Cache 装数据,LayoutRouter 显示画面。三层职责清晰,调试时按这个顺序定位问题。
2. AppRouterState 与 6 个 Action
客户端唯一的"全局状态"是 AppRouterState。它的关键字段:
tree: FlightRouterState— 当前 URL 对应的路由树(与服务端协议同构)。cache: CacheNode— Cache 树根节点,按 segment 嵌套,每个节点存放 React node + 子 cache。canonicalUrl— 当前规范化 URL(用于 history.pushState 与读取)。pushRef/focusAndScrollRef— 副作用标记(要不要 push/replace、要不要滚动)。nextUrl/previousNextUrl— interception route 用的特殊 URL。
action 一共 6 种,定义在 router-reducer-types.ts:
export const ACTION_REFRESH = 'refresh'
export const ACTION_NAVIGATE = 'navigate'
export const ACTION_RESTORE = 'restore'
export const ACTION_SERVER_PATCH = 'server-patch'
export const ACTION_HMR_REFRESH = 'hmr-refresh'
export const ACTION_SERVER_ACTION = 'server-action'
每个 action 都有专属 reducer:
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)
}
// This case should never be hit as dispatch is strongly typed.
default:
throw new Error('Unknown action')
}
}
注意第 53-62 行还有个"server reducer"——只在 SSR 阶段执行,永远返回原 state。原因:客户端 reducer 引用了 segment cache 里大量浏览器专属 API,把它们打进 server bundle 体积大且无意义。这是 Next.js 中很常见的 tree-shaking 范式。
2.1 六种 action 的语义对照
| Action | 触发场景 | 是否走 segment cache | 是否更新 history |
|---|---|---|---|
| navigate | router.push / replace / Link 点击 | 是 | 是(push or replace) |
| restore | popstate / pageshow(含 bfcache) | 是(尽量复用) | 否(仅同步 state 到 history) |
| refresh | router.refresh() / 显式刷新 | 部分失效 | 否 |
| hmr-refresh | 仅 dev:HMR 触发的整页刷新 | 全部失效 | 否 |
| server-patch | 子 segment 拉取完成后回填 | 是 | 否 |
| server-action | 表单 / useFormState / 手动调用 server action | 视 revalidatePath/Tag 而定 | 否 |
生产排查提示:浏览器 console 输入
window.nd.tree查看当前 FlightRouterState;输入window.nd.cache查看 CacheNode 树。这两个对象只在 dev 模式下挂出(见后文 §6),但调试体感非常好。
3. navigate 全流程
navigateReducer 自身只有 30 行——它是一个"路由器",把工作下放给 segment cache:
export function navigateReducer(
state: ReadonlyReducerState,
action: NavigateAction
): ReducerState {
const { url, isExternalUrl, navigateType, scrollBehavior } = action
if (isExternalUrl) {
return completeHardNavigation(state, url, navigateType)
}
// Handles case where `<meta http-equiv="refresh">` tag is present,
// which will trigger an MPA navigation.
if (document.getElementById('__next-page-redirect')) {
return completeHardNavigation(state, url, navigateType)
}
// Temporary glue code between the router reducer and the new navigation
// implementation. Eventually we'll rewrite the router reducer to a
// state machine.
const currentUrl = new URL(state.canonicalUrl, location.origin)
const currentRenderedSearch = state.renderedSearch
return navigateUsingSegmentCache(
state,
url,
currentUrl,
currentRenderedSearch,
state.cache,
state.tree,
state.nextUrl,
FreshnessPolicy.Default,
scrollBehavior,
navigateType
)
}
它做的三件事:
- 检查是否外部 URL 或 meta refresh —— 退化为 hard navigation(整页刷新)。
- 把
state.cachestate.treestate.nextUrl一并交给navigateUsingSegmentCache。 - 由 segment cache 决定:能不能用现有缓存?要不要 fetch?要走 PPR 流程吗?
3.1 hard vs soft 的判定
App Router 内部对一次导航有三种结果:
- soft:完全命中 cache,直接更新 state。零网络。
- hard:cache 中找不到目标 segment,需要重新 fetch。最常见。
- mpa:跨 root layout / 跨 buildId,必须整页跳转(hard navigation 升级版)。
判定 should-hard-navigate.ts 的核心思想:在 FlightRouterState 上从根向下走,找出"最深的公共 layout"——它以下要重渲染。如果这一层涉及到 dynamicParam(如 /posts/[id]),就触发 hard。
生产排查提示:用户反馈"切 tab 后 form 状态丢了"——多半是触发了 hard navigation。判断法:在 console 监听 popstate,记录每次
window.nd.tree是否在 form 所在 segment 之上断裂。
3.2 navigate 期间的 UI
navigate 进 reducer 后可能 suspend(fetch 还在跑)。useActionQueue 用 use(stateWithDebugInfo) 来挂起渲染:
return isThenable(stateWithDebugInfo)
? use(stateWithDebugInfo)
: stateWithDebugInfo
挂起后 React 走 Suspense fallback。最近的 loading.tsx 接管 UI——这就是为什么 App Router 的 loading 状态自然属于 React Suspense 而不是路由钩子。
4. Segment Cache:客户端的"数据库"
Segment Cache 与传统 SPA 路由最大的不同:它不是按路由 URL 缓存整页,而是按 segment 切片缓存。
4.1 数据结构层次
cache.ts 顶部定义了三层结构:
RouteTree:每个 URL 的"路由形态",即 FlightRouterState 在 cache 中的镜像。包含prefetchHints位掩码与各 slot 子树。RouteCacheEntry:URL → RouteTree 的映射,状态机有 4 态(Empty / Pending / Fulfilled / Rejected)。SegmentCacheEntry:每个 segment(如/products/[id])的具体 React 节点 + CSS / scripts 的缓存条目。
/**
* Tracks the status of a cache entry as it progresses from no data (Empty),
* waiting for server data (Pending), and finished (either Fulfilled or
* Rejected depending on the response from the server.
*/
export const enum EntryStatus {
Empty = 0,
Pending = 1,
Fulfilled = 2,
Rejected = 3,
}
关键认知:RouteCacheEntry 与 SegmentCacheEntry 是两张分开的表。这样设计的好处:
- 同一个 layout segment 在多个 URL 下复用(比如
/products/[id]的根 layout)。- 路由结构变更(rewrites/redirects)只更新 RouteTree,不必丢弃所有 segment 数据。
- 静态/动态 stale time 各自维护——
STATIC_STALETIME_MS默认 5 分钟、DYNAMIC_STALETIME_MS默认 0 秒。
4.2 stale time 与 invalidate
export const DYNAMIC_STALETIME_MS =
Number(process.env.__NEXT_CLIENT_ROUTER_DYNAMIC_STALETIME) * 1000
export const STATIC_STALETIME_MS = getStaleTimeMs(
Number(process.env.__NEXT_CLIENT_ROUTER_STATIC_STALETIME)
)
注意:环境变量是编译期 inlining(define-env-plugin),所以 stale time 不能在运行时改——必须在 next.config.js 设 experimental.staleTimes,重新构建。
getStaleTimeMs 还有个有意思的下限:
/**
* Ensures a minimum stale time of 30s to avoid issues where the server sends a too
* short-lived stale time, which would prevent anything from being prefetched.
*/
export function getStaleTimeMs(staleTimeSeconds: number): number {
return Math.max(staleTimeSeconds, 30) * 1000
}
至少 30 秒——避免极端的 revalidate: 0 导致 prefetch 完全失效。
4.3 prefetch 链路
export function prefetch(
href: string,
nextUrl: string | null,
treeAtTimeOfPrefetch: FlightRouterState,
fetchStrategy: PrefetchTaskFetchStrategy,
onInvalidate: null | (() => void)
) {
const url = createPrefetchURL(href)
if (url === null) {
// ...
}
schedulePrefetchTask(
cacheKey,
treeAtTimeOfPrefetch,
fetchStrategy,
PrefetchPriority.Default,
onInvalidate
)
}
调用入口:
<Link>滑入视口(IntersectionObserver)→pingVisibleLinks→ 这里。router.prefetch(href)→ 这里。- 表单 / 程序化导航 → 视情况 prefetch。
schedulePrefetchTask 把任务塞进队列,scheduler 用 requestIdleCallback(或 fallback 到 setTimeout)依次执行。
生产排查提示:用户反馈"鼠标移上去就 flash"——多半是 prefetch 抢占了主线程;可以把
<Link prefetch={false}>关掉这条链路验证。
5. LayoutRouter:状态到 React 的桥
<AppRouter> 是整棵客户端树的根。它做的事:
<HistoryUpdater appRouterState={state} />
useInsertionEffect(() => {
if (process.env.__NEXT_APP_NAV_FAIL_HANDLING) {
// clear pending URL as navigation is no longer
// in flight
window.next.__pendingUrl = undefined
}
const { tree, pushRef, canonicalUrl, renderedSearch } = appRouterState
const appHistoryState: AppHistoryState = {
tree,
renderedSearch,
}
// TODO: Use Navigation API if available
const historyState = {
...(pushRef.preserveCustomHistoryState ? window.history.state : {}),
// Identifier is shortened intentionally.
// __NA is used to identify if the history entry can be handled by the app-router.
// __N is used to identify if the history entry can be handled by the old router.
__NA: true,
__PRIVATE_NEXTJS_INTERNALS_TREE: appHistoryState,
}
if (
pushRef.pendingPush &&
// Skip pushing an additional history entry if the canonicalUrl is the same as the current url.
// This mirrors the browser behavior for normal navigation.
createHrefFromUrl(new URL(window.location.href)) !== canonicalUrl
) {
// This intentionally mutates React state, pushRef is overwritten to ensure additional push/replace calls do not trigger an additional history entry.
pushRef.pendingPush = false
window.history.pushState(historyState, '', canonicalUrl)
} else {
window.history.replaceState(historyState, '', canonicalUrl)
}
__PRIVATE_NEXTJS_INTERNALS_TREE 这个键名是 App Router 与 history.state 的契约——下次 popstate 时它会被还原成 RestoreAction 的输入。
<LayoutRouter> 在每个 segment 边界出现一次,它的核心动作是:
- 从 GlobalLayoutRouterContext 拿到当前 cache + tree。
- 根据自己
parallelRouterKey(如'children''@modal')切出对应子树。 - 渲染对应 React 节点;如果 cache 命中是 Pending,则
Suspense fallback。
生产排查提示:在 React DevTools 里搜索
LayoutRouter,定位"卡在哪一层"非常方便——卡哪儿就在哪儿插桩。
6. 调试钩子 window.nd
只在 process.env.NODE_ENV !== 'production' 时挂出(见 Router 函数 183 行附近):
// This hook is in a conditional but that is ok because `process.env.NODE_ENV` never changes
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
// Add `window.nd` for debugging purposes.
// This is not meant for use in applications as concurrent rendering will affect the cache/tree/router.
// @ts-ignore this is for debugging
window.nd = {
router: publicAppRouterInstance,
cache,
tree,
}
}, [cache, tree])
可以在 dev console 里:
window.nd.tree— 当前 FlightRouterState(你能看到[segment, parallelRoutes, ...])。window.nd.cache— CacheNode 树。window.nd.router.push(href)— 程序化导航。
生产环境不挂这个对象——
process.env.NODE_ENV !== 'production'在 prod build 中被 DCE 掉。
7. bfcache 与 popstate
bfcache(back-forward cache)是浏览器在你按"后退/前进"时复用旧 DOM 的机制。Next.js 通过 pageshow 事件支持它:
useEffect(() => {
// If the app is restored from bfcache, it's possible that
// pushRef.mpaNavigation is true, which would mean that any re-render of this component
// would trigger the mpa navigation logic again from the lines below.
// This will restore the router to the initial state in the event that the app is restored from bfcache.
function handlePageShow(event: PageTransitionEvent) {
if (
!event.persisted ||
!window.history.state?.__PRIVATE_NEXTJS_INTERNALS_TREE
) {
return
}
// Clear the pendingMpaPath value so that a subsequent MPA navigation to the same URL can be triggered.
// This is necessary because if the browser restored from bfcache, the pendingMpaPath would still be set to the value
// of the last MPA navigation.
globalMutable.pendingMpaPath = undefined
dispatchAppRouterAction({
type: ACTION_RESTORE,
url: new URL(window.location.href),
historyState: window.history.state.__PRIVATE_NEXTJS_INTERNALS_TREE,
})
}
window.addEventListener('pageshow', handlePageShow)
return () => {
window.removeEventListener('pageshow', handlePageShow)
}
}, [])
要点:
event.persisted为true时表示"从 bfcache 恢复"。- 必须读取
__PRIVATE_NEXTJS_INTERNALS_TREE,否则会和外站随机的 history.state 冲突。 - 触发的是
ACTION_RESTORE——这是一个"尽量复用 cache"的 action,不会强制重新 fetch。
生产排查提示:如果你的"前进/后退"页面 form 状态丢失,但点 link 不丢——多半是 bfcache 没有命中(响应头加了
Cache-Control: no-store之类)。检查后端响应头,而不是改前端逻辑。
8. 业务案例:购物车列表 + 详情 modal
需求:商品列表页 /products + 详情 modal /products/[id] + 直接访问 /products/[id] 走满屏。
期望体验:
- 用户从列表点开 modal:不重新 fetch 列表 segment,只 fetch 详情 segment。
- 用户在 modal 里关闭:不重新 fetch 列表,列表 segment 仍在 cache 中。
- 用户按浏览器后退:bfcache 命中时直接还原,否则按 cache 命中重渲染。
如何利用 segment cache 的特性:
- 列表里每个
<Link>默认开启 prefetch。pingVisibleLinks会自动调度。 - 用户点击触发 navigate,
navigate-reducer走navigateUsingSegmentCache,列表 segment 是公共 layout,命中 cache → 不重 fetch。 - 详情 segment 在 prefetch 时已部分填充(loading.tsx 那部分先到),navigate 后立刻渲染骨架,剩余在 layout-router 的 Suspense 中等待。
- 关闭 modal:dispatch navigate 回
/products,依然命中列表 cache → 零网络。 - 浏览器后退:先尝试 bfcache(pageshow 事件),失败则走 ACTION_RESTORE → 复用现有 cache。
8.1 这套逻辑能挖出什么坑
坑 A:列表是动态的(依赖 cookie 个性化)
STATIC_STALETIME_MS 不适用,列表实际属于动态。DYNAMIC_STALETIME_MS 默认 0 秒,意味着列表回退时仍要 re-fetch。要么改 experimental.staleTimes.dynamic,要么把列表标记为 prefetched layout(让它能被 segment cache 缓存)。
坑 B:详情 modal 加了 query string ?ref=xxx
query 不影响 LayoutRouter 选择哪一段,但影响 cache key(renderedSearch 字段)。一旦 ?ref 变了,整个 page segment 错过 cache。可以在 Link 上把 ?ref 放到 searchParams API 维度而不是 URL(如用 useSearchParams 自己处理)。
坑 C:Modal 里调用了 revalidatePath('/products/[id]')
会让目标 segment 全部 invalidate,下次 navigate 必须 hard fetch。如果业务上 modal 操作只影响一个商品,应改用 revalidateTag(product:${id}),让 cache 粒度收紧。
9. 重难点
9.1 Reducer 不可变 vs Cache 可变
Reducer 严格遵守 React 不可变更新原则,每次 navigate 返回新的 AppRouterState。但 state.cache 内部的 CacheNode 是可变的——SegmentCacheEntry 的 EntryStatus 会从 Pending 转 Fulfilled(这是 cache.ts 注释里强调的设计)。
为什么这样混合?因为 React 渲染期间需要"快照"——状态必须不可变;但缓存写入是异步的,需要"边渲染边填"——必须可变。Next.js 通过让 cache 节点持有 ref 把两者打通。
9.2 useActionQueue 不是 useReducer
use-action-queue.ts 的设计很特别:
- 用
useState持有当前 state,而非useReducer。 - 用模块级
let dispatch记录派发函数(而不是 context)。 - 因为 reducer 可能返回 Promise(fetch 期间);用
use(state)让 React 自然挂起。
这种结构与 React Router、Redux 的常规模式都不同——记住这一点你才能在调试时找对断点位置。
9.3 ACTION_RESTORE vs ACTION_NAVIGATE
很多人把"后退"当作 navigate 的反向。它不是——restore 不更新 history(history 已经因为浏览器自然回退而更新过了),且尽量复用 cache 中已有的节点;而 navigate 主动 push history 并可能触发 fetch。
误用后果:在 popstate 里手动 push 会导致 history 双重前进。
9.4 hard navigation 的"完成函数" completeHardNavigation
外部 URL / meta refresh 触发的硬导航,用 completeHardNavigation 直接更新 state 让外层做 mpa 跳转。注意这个函数不会做 fetch——它只是设置 pushRef.mpaNavigation = true,由 <AppRouter> 的 useEffect 检查并 window.location.assign(url)。
9.5 segment cache 与 fetch cache 的区别
- segment cache:客户端,按 segment 切片缓存 React 节点 + RSC payload。
- fetch cache:服务端,缓存
fetch()调用的响应(与 Cache-Control /next: { revalidate }配合)。
两者无直接关系——客户端 cache 命中并不阻止服务端在 navigate 时绕过 fetch cache。在排查"为什么数据没变"时,要分开思考:是不是 fetch cache 没失效?还是 segment cache 还在 stale time 内?
10. 配套 fixture:动手观察 reducer 与 cache
fixtures/lecture-09/ 包含 4 个示例:
app/example-1-prefetch/— 列表 + 视口检测,dev console 观察 prefetch 与 cache 写入。app/example-2-modal/— 列表 + 详情 modal(intercepting),观察 navigate 命中 vs miss。app/example-3-restore-bfcache/— 跨页跳转 + 后退/前进,对比 bfcache 与 ACTION_RESTORE。app/example-4-server-action/— 服务端 actionrevalidateTag触发 ACTION_SERVER_ACTION 与后续 server-patch。
10.1 推荐实验
cd learning/nextjs-40-lectures/fixtures/lecture-09
pnpm install --ignore-workspace
pnpm dev
# 浏览器打开 http://localhost:3009/
实验 A:观察 prefetch
- 打开
/example-1,开 DevTools Network 面板筛选RSC。 - 滚动列表让链接进入视口,看到形如
?_rsc=...的请求。 - 在 console 输入
window.nd.cache,观察parallelRoutes.children字典里出现新增条目。
实验 B:navigate 命中 vs miss
- 列表点击商品 → 详情 modal 显示。
- 关闭 modal → URL 回到
/example-2。 - Network 面板:第一次点击有 RSC 请求;关闭无网络。
- console:
window.nd.tree在 modal 打开/关闭时变化,但子 segment 不重 fetch。
实验 C:后退/前进 与 bfcache
- 进入
/example-3/page-a→/example-3/page-b→ 浏览器后退。 - 在 console 输出"is from bfcache"标记,看 pageshow 事件
event.persisted。 - 服务端响应头加
Cache-Control: no-store让 bfcache 失效,再实验一次对比。
实验 D:server action revalidate
/example-4表单提交后调用revalidateTag('count')。- 观察 console 中
[reducer] action server-action与[reducer] action server-patch顺序触发。 - 服务端日志确认对应 segment 重新渲染。
11. 检验问题
- 客户端 router 三层架构是哪三层?分别由哪些文件实现?
- 6 种 router action 各自的触发场景与典型用法是什么?
navigateReducer自身只有 30 行,复杂逻辑去哪了?为什么这样拆?- RouteCacheEntry 与 SegmentCacheEntry 为什么是两张表?合并行不行?
STATIC_STALETIME_MS与DYNAMIC_STALETIME_MS默认多少?为什么getStaleTimeMs还有 30s 下限?<Link>的 prefetch 链路从触发到入 cache 经过哪几个函数?__PRIVATE_NEXTJS_INTERNALS_TREE是什么?为什么需要它?- bfcache 命中后框架走哪条 action?相对于普通 popstate 有什么差别?
- 客户端 dev 模式下挂在哪个全局对象?怎么用它调试 cache 命中?
- 服务端 reducer 与客户端 reducer 为什么要分开?这种 tree-shaking 模式还在哪些地方出现过(联想第 7 讲)?
12. 延伸阅读
- 源码:
packages/next/src/client/components/router-reducer/(6 个 reducer 全在这里) - 源码:
packages/next/src/client/components/segment-cache/cache.ts(3000+ 行,按"先看类型再看 mutator"顺序读) - 源码:
packages/next/src/client/components/app-router.tsx(顶层组件 + HistoryUpdater) - 源码:
packages/next/src/client/components/layout-router.tsx(与 segment cache 的桥) - 配套 fixture:
fixtures/lecture-09/
下一讲预告
第 10 讲|内置组件原理:Link / Form / Image / Script:客户端 router 的"用户接口"是 <Link> 与 <Form>;图像/脚本则有专门的 <Image> <Script>。我们将拆解四个组件的实现:prefetch 触发点、自动表单序列化、图像优化路径与脚本加载策略,并解释它们与 segment cache、Server Actions 的协作关系。