面向哲思的编程与架构
笔记哲思阅读动态搜索RSS 订阅
切换到深色模式
搜索
RSS 订阅
切换到深色模式
© 2026 Vic Chen. All rights reserved.CC BY-NC-ND 4.0
← 笔记
Prefetch 的完整图景

Prefetch 的完整图景

2026年7月12日2,8719分钟

「Prefetch」在前端语境里至少指三件不同的事:浏览器的 <link rel=prefetch> 资源提示、Next.js 基于 Router Cache 的路由预取、以及开发者手控触发时机的策略。三层各有边界,也有摩擦。文章从浏览器机制讲起,拆解 Next.js 静态路由与动态路由预取行为的差异,分析「进视口触发」的浪费与「完全关闭」的体验代价,最终落到 HoverPrefetchLink 这个悬停触发方案。


目录
  • TL;DR
  • 1. 浏览器层:`` 资源提示
  • 2. Next.js 层:路由预取的实现原理
  • 2.1 Router Cache 是什么
  • 2.2 静态路由 vs 动态路由的预取差异
  • 2.3 prefetch prop 的三个值
  • 3. 问题的具体形状
  • 4. HoverPrefetchLink 实现拆解
  • 5. 三个方案叠加后的完整时序
  • 6. 适用边界
  • 7. 用 Network 面板验证
  • 8. 回到「为什么不直接用 prefetch={true}」
目录
  • TL;DR
  • 1. 浏览器层:`` 资源提示
  • 2. Next.js 层:路由预取的实现原理
  • 2.1 Router Cache 是什么
  • 2.2 静态路由 vs 动态路由的预取差异
  • 2.3 prefetch prop 的三个值
  • 3. 问题的具体形状
  • 4. HoverPrefetchLink 实现拆解
  • 5. 三个方案叠加后的完整时序
  • 6. 适用边界
  • 7. 用 Network 面板验证
  • 8. 回到「为什么不直接用 prefetch={true}」
目录
  1. TL;DR
  2. 1. 浏览器层:`` 资源提示
  3. 2. Next.js 层:路由预取的实现原理
  4. 2.1 Router Cache 是什么
  5. 2.2 静态路由 vs 动态路由的预取差异
  6. 2.3 prefetch prop 的三个值
  7. 3. 问题的具体形状
  8. 4. HoverPrefetchLink 实现拆解
  9. 5. 三个方案叠加后的完整时序
  10. 6. 适用边界
  11. 7. 用 Network 面板验证
  12. 8. 回到「为什么不直接用 prefetch={true}」
Next.js性能前端
相关文章
  • 01
    用 View Transitions + Skeleton 消灭页面跳转的割裂感2026/07
  • 02
    「批注」功能的设计与演进2026/05
  • 03
    「相关文章」功能的设计与实现2026/05
← 上一篇用 View Transitions + Skeleton 消灭页面跳转的割裂感
下一篇 →Claude Code 规模化实践(一):如何在大型代码库中工作

评论

© 2026 Vic Chen · 面向哲思的编程与架构CC BY-NC-ND 4.0

TL;DR

本站有一个叫 HoverPrefetchLink 的组件,在个人发布平台的演进和用 View Transitions + Skeleton 消灭页面跳转的割裂感里都提到过,但都只有寥寥几行。这次把它背后的完整逻辑展开说清楚。

「Prefetch」这个词在前端语境里至少指三件不同的事:浏览器原生的资源提示、JavaScript 框架自己实现的路由预取、以及开发者手工控制触发时机的策略。三层之间有继承关系,也有摩擦。不搞清楚这个层次,就很难理解为什么 Next.js 的 prefetch prop 设计成现在这个样子,也不知道 HoverPrefetchLink 到底改变了什么。


1. 浏览器层:<link rel> 资源提示

最底层是浏览器自己的机制。HTML 的 <link> 标签支持多个 rel 值,让开发者向浏览器表达「我将来可能需要这个资源」:

rel 值作用优先级
dns-prefetch提前做 DNS 解析极低
preconnect提前建 TCP + TLS 连接低
prefetch低优先级下载资源,存入 HTTP 缓存最低(Idle)
preload高优先级下载当前页面必要资源高
modulepreload同上,针对 ES Module高

prefetch 和 preload 经常被混淆。两者的核心区别是时机意图:

  • preload 是「这个页面现在就要用,快点」——浏览器按资源类型分配合适优先级,会影响首屏渲染
  • prefetch 是「下一个页面可能要用,有空再拉」——浏览器在主线程空闲时才下载,不影响当前页面
<!-- 提前解析 CDN 的 DNS -->
<link rel="dns-prefetch" href="https://cdn.example.com" />
 
<!-- 提前建好连接(含 DNS + TCP + TLS) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
 
<!-- 低优先级预取下一页可能用到的脚本 -->
<link rel="prefetch" href="/about.js" as="script" />
 
<!-- 高优先级加载当前页面的关键字体 -->
<link rel="preload"

这一层的「prefetch」完全由浏览器控制调度——开发者只是提示,浏览器可以忽略。在移动设备上,浏览器可能因为电量或流量限制而跳过所有 prefetch 请求。

浏览器对 prefetch 的支持和行为不完全一致。(Chen 注:Chrome 对 prefetch 的执行策略在不同版本里有调整。早期版本在页面卸载后会继续下载 prefetch 的资源;后来改成「必须在用户主动导航前完成下载」。Safari 长期不支持 prefetch,直到 16.4 才实现。)

2. Next.js 层:路由预取的实现原理

Next.js 的 <Link> 在 prefetch 这个 prop 上封装了一套完整的逻辑,和浏览器原生的 <link rel=prefetch> 是两码事。它不只是把资源提前拉下来,还涉及路由缓存(Router Cache)这个运行时的数据结构。

2.1 Router Cache 是什么

Router Cache 是 Next.js 客户端维护的一个内存缓存,存储访问过的路由的 RSC(React Server Component)Payload(Chen 注:RSC Payload 是 Next.js 专有的二进制序列化格式,用来描述一棵 React Server Component 树的渲染结果。它不是 HTML,而是一种紧凑的「已渲染的服务端组件树快照」,客户端拿到后可以直接 hydrate,不需要重新执行服务端逻辑。请求时 URL 带有 `?_rsc=` 参数,Content-Type 为 `text/x-component`,可以在 Network 面板里过滤到。)。

图1:Router Cache 查询流程——命中则直接渲染,未命中则 fetch 后存入再渲染

当 prefetch 发生时,Next.js 不是在拉 HTML 文件,而是在提前请求目标路由的 RSC Payload(一种序列化的 React 树),然后把它存进 Router Cache。等用户真正点击时,直接从内存读,不发网络请求。

2.2 静态路由 vs 动态路由的预取差异

这是理解 HoverPrefetchLink 的关键背景。

图2:静态路由 vs 动态路由的默认预取行为差异

「静态」和「动态」的定义是:构建时能否确定所有参数。/about 是静态的——Next.js 在构建时生成了静态 HTML;/notes/[slug] 是动态的——slug 在运行时才知道,页面需要服务端按需渲染。

动态路由即便有 loading.tsx,预取的也只是 layout 层和骨架屏,不是完整的页面内容——因为那个内容根本不存在于构建产物里。这就是为什么即使做了预取,动态路由的第一次访问仍然需要等服务端渲染。

Router Cache 的有效期在 Next.js 15 有过一次调整。(Chen 注:Next.js 15 调整了 Router Cache 的默认行为:静态路由缓存时间从无限期改为 5 分钟,动态路由的 prefetch 缓存从 30 秒改为由 staleTime 控制(默认 0)。如果你看到文档里的「5 分钟 / 30 秒」,注意对应的是哪个版本。)

2.3 prefetch prop 的三个值

// prefetch={true}:强制完整预取(同静态路由行为)
<Link href="/notes/foo" prefetch={true}>...</Link>
 
// prefetch={null}(默认):由 Next.js 根据路由类型决定
<Link href="/notes/foo">...</Link>
 
// prefetch={false}:完全禁用预取
<Link href="/notes/foo" prefetch={false}>...</Link>

prefetch={true} 会让动态路由也尝试完整预取——这意味着 Next.js 会在链接进视口时就发出完整的 RSC 请求。如果文章列表有 50 篇文章,同时进入视口,这相当于立刻发出 50 个服务端渲染请求。对大多数场景这是浪费,甚至会拖慢服务端。


3. 问题的具体形状

现在可以清楚地描述本站遇到的问题了。

文章列表页可能同时展示 20–40 篇文章链接。每篇文章是一个动态路由(/notes/[slug])。文章页的服务端工作量不小:

MDX 编译(同步,CPU 密集)
  + 评论数据请求(Upstash Redis, ~50ms)
  + 浏览量请求(Upstash Redis, ~50ms)
  = 总等待时间 150–400ms

这三步是串行的,全部完成才能开始响应客户端。

  • 如果用 prefetch={true} 或默认进视口预取:列表页加载时立刻触发 20–40 个 RSC 请求,绝大多数永远不会被使用(用户只会点一两篇)。浪费了带宽、服务器计算和 Upstash 配额。

  • 如果用 prefetch={false}:彻底关闭,点击时才开始请求,用户要等 150–400ms 的空白,体验差。

悬停触发是两者之间的平衡点:

图3:悬停预取时序——用户意图窗口期内完成缓存预热,点击时直接命中

用户悬停到点击之间通常有 200ms 到几秒不等的间隔——这个间隔足够服务端完成渲染并把结果推入缓存。在这个策略下,每次真正有意图的「点击」都能命中缓存,而列表页首次加载不会触发任何额外请求。


4. HoverPrefetchLink 实现拆解

src/components/ui/HoverPrefetchLink.tsx
"use client";
 
import Link from "next/link";
import { useState } from "react";
 
interface Props extends React.ComponentProps<typeof Link> {}
 
export function HoverPrefetchLink({ href, ...props }: Props) {
  const [prefetch, setPrefetch] 









这里有几个值得注意的设计细节:

  • prefetch ? null : false 而不是 prefetch ? true : false

切换到 null(默认值)而不是 true(Chen 注:如果改成 prefetch={true},对动态路由会触发「强制完整预取」,效果等同于静态路由的行为——发出完整的 RSC 请求。这在某些场景下是合理的,但意味着每次悬停都会触发一个服务端渲染请求,而不是只拉 loading shell。对于服务端工作量大的页面要慎用。),让 Next.js 自己决定预取深度。对于动态路由,这意味着预取 layout + loading shell,不强求完整的 RSC Payload 预取(哪怕服务端正好渲染好了也存进来)。这样即便悬停时预取没来得及完成完整渲染,点击后至少骨架屏是即时的。

  • onFocus 覆盖键盘导航

只监听 onMouseEnter 会遗漏用 Tab 键导航的场景。键盘用户在按回车确认前,焦点会先停在链接上,onFocus 补齐了这个路径。

  • setPrefetch(true) 是单向的

状态只会从 false → true,不会在 onMouseLeave 时重置。这是刻意的:一旦触发了预取,没有理由撤销。缓存在 Router Cache 里,占用内存很小,而且很快就会过期。重置状态的成本是多余的 re-render,收益是零。


5. 三个方案叠加后的完整时序

把 View Transitions、Skeleton、HoverPrefetchLink 放在一起看,它们处理的是导航体验里三个不同时段的问题:

图4:三方案在单次导航中的时间分工——预取、缓存命中、过渡动画各负责一段

缓存命中意味着点击后没有网络往返;View Transition 给了 150ms 的视觉过渡;整个过程对用户来说是流畅的衔接,不是「等待 + 突然出现」。

如果缓存没有命中(冷点击),骨架屏立刻填充等待期,View Transition 在内容就绪后继续工作。三个方案各自独立,叠加是加分,没有强耦合。


6. 适用边界

悬停触发不是万能的。几个需要考虑的场景:

  • 触摸屏设备:没有 hover 事件。onMouseEnter 在手机上不触发,意味着移动用户得不到 prefetch 的好处,始终是冷点击 + 骨架屏路径。这是可以接受的降级——骨架屏本来就是为这个场景兜底的。

  • 服务端工作量轻的页面:如果目标页面的服务端响应在 50ms 以内(比如纯静态内容),悬停预取的收益很小,因为即便不预取,等待时间也不明显。这种情况下 loading.tsx 骨架屏单独就够用了。

  • 高频悬停(列表 hover 扫描):用户快速扫过列表时,每个链接都会触发 setPrefetch(true) → Next.js 发出预取请求。这可能在短时间内产生多个并发 RSC 请求。本站文章数量不大,可以接受;如果是上百条目的长列表,可能需要加防抖(Chen 注:防抖的实现是在 onMouseEnter 里用 setTimeout 延迟触发 setPrefetch,并在 onMouseLeave 里 clearTimeout。延迟设 100–150ms 可以过滤掉快速扫过的情况,只对真正停留的悬停触发 prefetch。本站目前没有实现这个,因为文章列表不超过 50 条,不值得加复杂度。)。

  • next dev 环境:prefetch 在开发模式下不生效。next dev 关闭了所有预取逻辑,方便调试。要验证 HoverPrefetchLink 的效果,必须用 next build && next start。


7. 用 Network 面板验证

打开 Chrome DevTools → Network 面板,过滤 ?_rsc= 请求(这是 RSC Payload 请求的 URL 特征),然后在文章列表页鼠标悬停一个链接。

如果实现正确,应该看到:

  1. 悬停瞬间出现一个 GET /notes/foo?_rsc=xxxxx 请求
  2. 请求状态码 200,Content-Type 为 text/x-component(RSC Payload 的 MIME type)
  3. 点击后,Network 面板不出现新的 RSC 请求——Router Cache 命中,直接用缓存

对比 prefetch={false} 的情况:悬停时没有请求,点击后才出现 RSC 请求,URL 相同,但时机晚了用户意图确认的那段时间。

图5:prefetch=false 与 HoverPrefetchLink 的 Network 请求时序对照

8. 回到「为什么不直接用 prefetch={true}」

现在可以完整回答这个问题了。

prefetch={true} 等于把「进入视口」作为触发时机,而文章列表页的链接大多数会同时进入视口。假设列表里有 40 篇文章,页面加载后全部进入视口,Next.js 会立刻发出 40 个 RSC 请求。

这 40 个请求:

  • 绝大多数永远不会被用(用户只点一两篇)
  • 每个都需要服务端跑 MDX 编译 + Redis 查询
  • 它们是并发的,瞬间打满 Vercel Function 并发配额

悬停触发把触发条件从「可见」收紧到「有明确意图」(hover)。在信息密度高的列表页,这两者差距极大——可见的链接有几十个,真正悬停的通常只有一两个。

最后列举下其他框架的做法:

  • Astro 的 prefetch 系统默认用的是 hover 策略,而不是进视口。
  • Remix 的 <Link prefetch> 也支持 intent(等同于 hover)和 viewport 两种策略。

这说明悬停触发不是什么冷门的优化技巧,而是框架设计者普遍认可的权衡点。


✓
Prefetch 的本质是把「等待」从点击后挪到点击前——挪到用户有意图但还没行动的那段时间里。浏览器原生 <link rel=prefetch> 是这个思路在资源层的实现,Next.js Router Cache 是在路由层的实现,HoverPrefetchLink 是在触发策略上的收紧。理解了这个层次结构,很多 API 设计上「奇怪」的地方就顺理成章了。
href
=
"/fonts/inter.woff2"
as
=
"font"
crossorigin
/>
=
useState
(
false
);
return (
<Link
href={href}
prefetch={prefetch ? null : false}
onMouseEnter={() => setPrefetch(true)}
onFocus={() => setPrefetch(true)}
{...props}
/>
);
}