「Prefetch」在前端语境里至少指三件不同的事:浏览器的 <link rel=prefetch> 资源提示、Next.js 基于 Router Cache 的路由预取、以及开发者手控触发时机的策略。三层各有边界,也有摩擦。文章从浏览器机制讲起,拆解 Next.js 静态路由与动态路由预取行为的差异,分析「进视口触发」的浪费与「完全关闭」的体验代价,最终落到 HoverPrefetchLink 这个悬停触发方案。
本站有一个叫 HoverPrefetchLink 的组件,在个人发布平台的演进和用 View Transitions + Skeleton 消灭页面跳转的割裂感里都提到过,但都只有寥寥几行。这次把它背后的完整逻辑展开说清楚。
「Prefetch」这个词在前端语境里至少指三件不同的事:浏览器原生的资源提示、JavaScript 框架自己实现的路由预取、以及开发者手工控制触发时机的策略。三层之间有继承关系,也有摩擦。不搞清楚这个层次,就很难理解为什么 Next.js 的 prefetch prop 设计成现在这个样子,也不知道 HoverPrefetchLink 到底改变了什么。
<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 才实现。)Next.js 的 <Link> 在 prefetch 这个 prop 上封装了一套完整的逻辑,和浏览器原生的 <link rel=prefetch> 是两码事。它不只是把资源提前拉下来,还涉及路由缓存(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 面板里过滤到。)。
当 prefetch 发生时,Next.js 不是在拉 HTML 文件,而是在提前请求目标路由的 RSC Payload(一种序列化的 React 树),然后把它存进 Router Cache。等用户真正点击时,直接从内存读,不发网络请求。
这是理解 HoverPrefetchLink 的关键背景。
「静态」和「动态」的定义是:构建时能否确定所有参数。/about 是静态的——Next.js 在构建时生成了静态 HTML;/notes/[slug] 是动态的——slug 在运行时才知道,页面需要服务端按需渲染。
动态路由即便有 loading.tsx,预取的也只是 layout 层和骨架屏,不是完整的页面内容——因为那个内容根本不存在于构建产物里。这就是为什么即使做了预取,动态路由的第一次访问仍然需要等服务端渲染。
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 个服务端渲染请求。对大多数场景这是浪费,甚至会拖慢服务端。
现在可以清楚地描述本站遇到的问题了。
文章列表页可能同时展示 20–40 篇文章链接。每篇文章是一个动态路由(/notes/[slug])。文章页的服务端工作量不小:
MDX 编译(同步,CPU 密集)
+ 评论数据请求(Upstash Redis, ~50ms)
+ 浏览量请求(Upstash Redis, ~50ms)
= 总等待时间 150–400ms这三步是串行的,全部完成才能开始响应客户端。
如果用 prefetch={true} 或默认进视口预取:列表页加载时立刻触发 20–40 个 RSC 请求,绝大多数永远不会被使用(用户只会点一两篇)。浪费了带宽、服务器计算和 Upstash 配额。
如果用 prefetch={false}:彻底关闭,点击时才开始请求,用户要等 150–400ms 的空白,体验差。
悬停触发是两者之间的平衡点:
用户悬停到点击之间通常有 200ms 到几秒不等的间隔——这个间隔足够服务端完成渲染并把结果推入缓存。在这个策略下,每次真正有意图的「点击」都能命中缓存,而列表页首次加载不会触发任何额外请求。
HoverPrefetchLink 实现拆解"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,收益是零。
把 View Transitions、Skeleton、HoverPrefetchLink 放在一起看,它们处理的是导航体验里三个不同时段的问题:
缓存命中意味着点击后没有网络往返;View Transition 给了 150ms 的视觉过渡;整个过程对用户来说是流畅的衔接,不是「等待 + 突然出现」。
如果缓存没有命中(冷点击),骨架屏立刻填充等待期,View Transition 在内容就绪后继续工作。三个方案各自独立,叠加是加分,没有强耦合。
悬停触发不是万能的。几个需要考虑的场景:
触摸屏设备:没有 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。
打开 Chrome DevTools → Network 面板,过滤 ?_rsc= 请求(这是 RSC Payload 请求的 URL 特征),然后在文章列表页鼠标悬停一个链接。
如果实现正确,应该看到:
GET /notes/foo?_rsc=xxxxx 请求text/x-component(RSC Payload 的 MIME type)对比 prefetch={false} 的情况:悬停时没有请求,点击后才出现 RSC 请求,URL 相同,但时机晚了用户意图确认的那段时间。
prefetch={true}」现在可以完整回答这个问题了。
prefetch={true} 等于把「进入视口」作为触发时机,而文章列表页的链接大多数会同时进入视口。假设列表里有 40 篇文章,页面加载后全部进入视口,Next.js 会立刻发出 40 个 RSC 请求。
这 40 个请求:
悬停触发把触发条件从「可见」收紧到「有明确意图」(hover)。在信息密度高的列表页,这两者差距极大——可见的链接有几十个,真正悬停的通常只有一两个。
最后列举下其他框架的做法:
<Link prefetch> 也支持 intent(等同于 hover)和 viewport 两种策略。这说明悬停触发不是什么冷门的优化技巧,而是框架设计者普遍认可的权衡点。
<link rel=prefetch> 是这个思路在资源层的实现,Next.js Router Cache 是在路由层的实现,HoverPrefetchLink 是在触发策略上的收紧。理解了这个层次结构,很多 API 设计上「奇怪」的地方就顺理成章了。