发布日期

第 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>pingVisibleLinksschedulePrefetchTask → 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,返回新的 AppRouterStateclient/components/router-reducer/router-reducer.ts 与同目录 reducers/*.ts
Segment Cache缓存 prefetch 与导航产物,调度网络请求client/components/segment-cache/cache.tsprefetch.ts scheduler.ts
LayoutRouter把 CacheNode 映射到 React 元素树,订阅状态client/components/layout-router.tsxapp-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

11:packages/next/src/client/components/router-reducer/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:

51:packages/next/src/client/components/router-reducer/router-reducer.ts
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
navigaterouter.push / replace / Link 点击是(push or replace)
restorepopstate / pageshow(含 bfcache)是(尽量复用)否(仅同步 state 到 history)
refreshrouter.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:

55:packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts
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
  )
}

它做的三件事:

  1. 检查是否外部 URL 或 meta refresh —— 退化为 hard navigation(整页刷新)。
  2. state.cache state.tree state.nextUrl 一并交给 navigateUsingSegmentCache
  3. 由 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 还在跑)。useActionQueueuse(stateWithDebugInfo) 来挂起渲染:

140:packages/next/src/client/components/use-action-queue.ts
  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 的缓存条目。
196:packages/next/src/client/components/segment-cache/cache.ts
/**
 * 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

21:packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts
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)
)

注意:环境变量是编译期 inliningdefine-env-plugin),所以 stale time 不能在运行时改——必须在 next.config.jsexperimental.staleTimes,重新构建。

getStaleTimeMs 还有个有意思的下限:

118:packages/next/src/client/components/segment-cache/cache.ts
/**
 * 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 链路

47:packages/next/src/client/components/segment-cache/prefetch.ts
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> 是整棵客户端树的根。它做的事:

548:packages/next/src/client/components/app-router.tsx
      <HistoryUpdater appRouterState={state} />
99:packages/next/src/client/components/app-router.tsx
  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 行附近):

198:packages/next/src/client/components/app-router.tsx
    // 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 事件支持它:

240:packages/next/src/client/components/app-router.tsx
  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.persistedtrue 时表示"从 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 的特性:

  1. 列表里每个 <Link> 默认开启 prefetch。pingVisibleLinks 会自动调度。
  2. 用户点击触发 navigate,navigate-reducernavigateUsingSegmentCache列表 segment 是公共 layout,命中 cache → 不重 fetch。
  3. 详情 segment 在 prefetch 时已部分填充(loading.tsx 那部分先到),navigate 后立刻渲染骨架,剩余在 layout-router 的 Suspense 中等待。
  4. 关闭 modal:dispatch navigate 回 /products,依然命中列表 cache → 零网络。
  5. 浏览器后退:先尝试 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可变的——SegmentCacheEntryEntryStatus 会从 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 个示例:

  1. app/example-1-prefetch/ — 列表 + 视口检测,dev console 观察 prefetch 与 cache 写入。
  2. app/example-2-modal/ — 列表 + 详情 modal(intercepting),观察 navigate 命中 vs miss。
  3. app/example-3-restore-bfcache/ — 跨页跳转 + 后退/前进,对比 bfcache 与 ACTION_RESTORE。
  4. app/example-4-server-action/ — 服务端 action revalidateTag 触发 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. 检验问题

  1. 客户端 router 三层架构是哪三层?分别由哪些文件实现?
  2. 6 种 router action 各自的触发场景与典型用法是什么?
  3. navigateReducer 自身只有 30 行,复杂逻辑去哪了?为什么这样拆?
  4. RouteCacheEntry 与 SegmentCacheEntry 为什么是两张表?合并行不行?
  5. STATIC_STALETIME_MSDYNAMIC_STALETIME_MS 默认多少?为什么 getStaleTimeMs 还有 30s 下限?
  6. <Link> 的 prefetch 链路从触发到入 cache 经过哪几个函数?
  7. __PRIVATE_NEXTJS_INTERNALS_TREE 是什么?为什么需要它?
  8. bfcache 命中后框架走哪条 action?相对于普通 popstate 有什么差别?
  9. 客户端 dev 模式下挂在哪个全局对象?怎么用它调试 cache 命中?
  10. 服务端 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 的协作关系。