Next.js Instant Navigations:壳先到
前端约 6 分钟阅读

Next.js Instant Navigations:壳先到

Next.js 16.3 用 cacheComponents 与 partialPrefetching 做 Instant Navigations:视口预取共享壳,数据流式后到。对照 next-beats,谈迁移坑与 instant() 回归。

Next.js 16.3 把「点链接像 SPA」这件事正式推到了可开关的配置上:cacheComponents 加上 partialPrefetching。官方博客写得很直白——动态默认、缓存显式,导航不再靠猜。之前那篇写过 16.3 的内存回收和 AGENTS.md,这篇只谈导航:壳怎么预取、数据怎么后到、你怎么测「变慢了」。

参考:Next.js 16.3、Instant Navigations、示例仓 vercel-labs/next-beats。

为什么以前的导航会「顿一下」

App Router 把大量 UI 推到服务端,首屏 JS 少了,但路由切换常变成:等 RSC 载荷 → 再画。预取如果太狠,列表页一进视口就拉全量目标页,带宽和缓存都被拖累;太懒又像 MPA。

团队里常见抱怨就两类:

  • 点了链接,白一下或骨架闪太久
  • 开了 prefetch,首页在后台偷偷打出一串请求

我自己在后台类产品里踩过第二种:表格页挂了二十个「详情」链接,默认预取把接口 QPS 打成锯齿。关掉全局 prefetch 又回到「点一下等一下」。问题不在 React 慢,而在预取粒度和缓存合同没写清楚。

16.3 的答案不是「再隐式缓存一层」,而是让你写清楚:哪些是可复用的壳,哪些是带 tag 的数据。

打开 Instant Navigations

图:壳先提交,数据随后流式补上的导航体感

ts
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
}

export default nextConfig

两面一起开才有完整行为。cacheComponents 把 'use cache'、cacheTag、cacheLife 接到客户端导航链路;partialPrefetching 让链接进入视口时,优先预取共享 App Shell,导航先提交壳,数据再流式补上。

官方示范仓 next-beats 是个音乐播放器:列表进详情时,布局几乎立刻切过去,封面和曲目信息随后到。你本地对照它,比只读 changelog 快。听歌场景对「先出壳」特别敏感——用户要的是连续操作,不是完美像素同一帧到达。

壳和数据分开想

旧习惯是「整页要么 static 要么 dynamic」。新模型更像:

  1. Shell:导航栏、侧栏、列表骨架——可被 partial prefetch 提前拿到
  2. Cached data:带 cacheTag 的查询,失效时按 tag 刷
  3. Live stream:必须实时的块继续用 Suspense,别堵在顶层 await 里

按产品页举例:

tsx
// app/products/[id]/page.tsx
import { cacheLife, cacheTag } from 'next/cache'

async function ProductTitle({ id }: { id: string }) {
  'use cache'
  cacheTag(`product-${id}`)
  cacheLife('hours')
  const p = await db.product.findUnique({ where: { id } })
  return <h1>{p?.name}</h1>
}

export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  return (
    <article>
      <ProductTitle id={id} />
      {/* 库存等易变块继续流式,不要和标题绑死 */}
    </article>
  )
}

标题可以缓存几小时,库存另开一块。用户从列表点进来时,至少标题区域有机会落在已预取的壳或缓存里,而不是整页一起等。

需要更深预取时,用 <Link prefetch> 拉更多目标内容;默认 partial 则偏保守。别一上来全站 prefetch={true},列表页会把自己打成爬虫。

写 cache tag 时我习惯和资源 ID 对齐:product-123、user-settings-9。失效从 mutation / webhook 里 revalidateTag,不要在「感觉数据旧了」时整站硬刷。tag 设计差,Instant Navigations 只会让你更快地展示过期壳。

迁移时会撞的墙

文档迁移指南里列过一批与 Cache Components 冲突的旧配置(dynamic、revalidate 等 segment 写法)。实践里我见过三类翻车:

顶层阻塞。 页面根上直接 await getSession() 再 return,壳出不来,partial prefetch 也救不了。把鉴权边界下沉,或拆成不阻塞外壳的分支。登录态可以用布局里的轻量 cookie 分支 + 内部再拉完整 session。

缓存粒度太大。 一个 'use cache' 包住整页,tag 一失效等于整页重算。按查询拆,tag 跟资源 ID 对齐。你若把「用户 + 推荐 + 广告」捆成一块缓存,任何一个字段变都得整包丢掉,壳策略再漂亮也白搭。

测不了「变慢」。 16.3 带了 @next/playwright 的 instant() 一类 helper,用来回归「这次重构有没有把导航拖慢」。没有自动化时,至少用官方 Instant Insights / Navigation Inspector 看慢路由,别只靠体感。把一两条核心路径的导航耗时写进 CI,比加一堆微交互动画值。

还有一类隐蔽问题:中间件重定向和国际化前缀。壳预取的是 A 路径,实际落地被 rewrite 到 B,用户会看到闪一下错布局。开 partial 之前,先把 canonical 路由和 redirect 表捋直。

和 View Transition 别混为一谈

昨天写过共享元素转场。View Transition 管的是视觉连续;Instant Navigations 管的是数据与预取策略。可以一起用,但排查时先分清:是动画卡,还是 RSC 还没到。先保证壳秒切,再谈 FLIP。

动效爱好者容易一上来全站 crossfade。对信息密度高的工具型页面,共享元素只留在「列表缩略图 → 详情主图」这种真正有空间对应的地方就够。导航策略错了,动画只是把等待包装得更丝滑,用户照样烦。

和 16.3 其它改动怎么一起看

同版本还有预取内联(小载荷合并请求)、SSR 换 Node stream、immutable 静态资源跨部署复用。它们和 Instant Navigations 是同一条线上的螺丝:少打请求、少阻塞、少把动态页当静态页骗自己。

升级时我建议顺序:先升 16.3 吃默认性能(内存、build cache),再开两个 flag 做一条列表→详情试点,最后才考虑全站 Cache Components 迁移。一口气改完,出了问题你分不清是 flag 还是业务代码。

我会怎么落

  1. 新项目或可接受 breaking 的分支:直接开两个 flag,对照 next-beats 搭一条列表→详情。
  2. 老项目:先给 1~2 条核心路径加 'use cache' + tag,再开 partialPrefetching,用 Playwright 钉住导航耗时。
  3. 观测:看预取请求数、导航到首次有意义绘制、tag 失效后的回流。数字比「感觉快了」管用。
  4. 文档:在仓库 AGENTS.md 或内部 RFC 里写清「哪些 tag 谁负责失效」,否则三个月后没人敢碰缓存。

带走一句: 16.3 的 Instant Navigations 不是特效开关,是把「壳先到、数据后到、缓存显式」写成默认心智。配置两行就能试;难的是拆缓存边界和别让顶层 await 堵死外壳。

相关文章