网页还能更快吗:Speculation Rules、bfcache 和"感觉没加载"这件事
- 作者:Bougie
- 创建于:2026-09-27
有段时间我一直在跟博客的首屏数字较劲。压缩、拆包、图片转 AVIF、字体子集化,能做的都做了,LCP 停在两秒出头,再也下不去。
后来我换了个思路:别让它更快,让它根本不用加载。

点开文章页的那一刻,页面已经在后台渲染好了,切换是零延迟的。这不是什么黑科技,是浏览器这几年悄悄补齐的一组能力:预取、预渲染、bfcache、以及配套的 View Transitions。这篇把它们串起来讲一遍,顺便记录我踩过的几个坑。
# 一、导航为什么慢:它默认是串行的
一次普通的页面跳转,时间大致花在这些事情上,而且是排队发生的:
- 用户点击 → 浏览器发请求
- 服务器响应 HTML(一个 RTT 起步)
- 解析 HTML → 发现 CSS、JS、字体 → 再发请求(又一个 RTT)
- CSSOM 就绪 → 布局 → 绘制 → 首屏
前面这些优化(preload、Early Hints、内联关键 CSS)本质上都是在压缩第 3 步和第 2 步之间的等待。但它们没法消灭"点击之后才开始干活"这件事——从点击到第一个字节回来之间,至少有一个 RTT 是纯浪费的,在移动网络上这可能是几百毫秒。
而 SPA 之所以给人"快"的错觉,是因为路由切换时它跳过了第 2 步:HTML 早就在手里了,只取数据。代价是你得维持一整套客户端路由、数据层和状态管理。
浏览器原生给出的答案是:把第 2、3、4 步提前到用户点击之前。

# 二、老办法都挺别扭
这条路其实走过一轮,而且留下了不少废墟:
<link rel="prefetch">:只把资源抓进 HTTP 缓存,不解析不执行。对 JS、图片有效,对"下一页的 HTML"有效性一般,而且没有任何"何时抓"的策略,写上去就抓。<link rel="prerender">:Chrome 很早就支持,但因为各家实现不一致、且没有节制机制,被两次废弃,最后彻底让位给新的 API。现在写它等于没写。- Quicklink / Guess.js:这两类库做的事情是"视口内 / 大概率被点的链接,用 prefetch 抓一下"。思路是对的,但它们只能 prefetch(抓资源),做不到真正的预渲染,而且规则写在 JS 里,浏览器看不见意图,也就没法做资源调度。
所以 2026 年再看,正确的入口是 Speculation Rules API——它把"猜用户会点哪"这件事变成了一份浏览器能读懂的声明。
# 三、Speculation Rules:把猜测写成声明
用法就是一段 JSON,放在 <script type="speculationrules"> 里:
<script type="speculationrules">
{
"prerender": [
{
"where": {
"href_matches": "/blog/*"
},
"eagerness": "moderate"
}
],
"prefetch": [
{
"where": {
"selector_matches": ".card a"
},
"eagerness": "conservative"
}
]
}
</script>
它有两档动作:
prefetch:把目标文档抓下来放进一个特殊缓存,不执行 JS、不渲染。成本低,安全,但只能省掉网络时间。prerender:在后台开一个隐藏页面,完整加载、渲染、执行 JS。用户点下去时是直接切过去,连布局都做完了。成本高,收益也最大。
# 两种写法
// 1) 直接列 URL(list 规则)
{ "prerender": [{ "source": "list", "urls": ["/a", "/b"], "eagerness": "immediate" }] }
// 2) 按条件匹配页面里的链接(document 规则)
{ "prefetch": [{ "source": "document",
"where": { "and": [
{ "href_matches": "/blog/*" },
{ "not": { "selector_matches": ".no-prefetch" } }
]},
"eagerness": "moderate" }] }
where 里可以用 href_matches(URL 模式,支持 * 通配)、selector_matches / selector_exclude(CSS 选择器)、and / or / not。我自己的用法是只对文章详情页用 prerender,对列表页只 prefetch——因为列表页可能很重,而详情页是用户最终的目标。
# eagerness 是这套 API 的节流阀
这是个关键参数,四个档位:
| 值 | 触发时机 | 默认用于 |
|---|---|---|
immediate | 规则一解析完就动手 | URL list 规则 |
eager | 目前与 immediate 行为一致(留作将来更激进的调度) | — |
moderate | 鼠标悬停约 200ms,或 mousedown/touchstart 立即触发 | — |
conservative | 只在 mousedown / touchstart(也就是真的按下去了) | document 规则 |
默认是保守的,这点设计得很好——它意味着你随便写一份规则也不会把用户的流量烧光。我一般给详情页 moderate:悬停 200ms 才动手,过滤掉了大部分"鼠标扫过去"的误判。

# 跨站和参数:两个要注意的点
跨站预渲染需要对方点头。 同站(same-site)规则可以直接用;跨站 prerender 要目标站点返回 Supports-Loading-Mode: credentialed-prerender,否则浏览器会降级甚至放弃。这条是对隐私的硬保护——你不能替用户去别的站点"假装访问"。
查询参数会让预渲染白干。 如果链接是 /post?id=1&from=list,而用户点的是 /post?id=1&from=list&t=1234,那后台渲染好的那一份对不上,预渲染作废。解决方式是服务端(或 <meta>)声明:
No-Vary-Search: params=("t"), except=("id")
配合规则里的 "expects_no_vary_search": true,告诉浏览器"这些参数不影响页面内容,可以对上就用"。
# 四、别让预渲染把你的数据搞脏
这是我最想强调的一条,也是最容易出事的。
预渲染是真的把页面跑了一遍。 JS 执行了,图片请求发出去了,埋点也发出去了。如果你的统计代码不加区分,会出现三种难看的后果:
- 页面浏览量虚高(预渲染了一次没被点击)
- 转化漏斗对不上(点了才算的核心事件被提前上报)
- 后台出现"幽灵请求",消耗带宽和配额
防护手段,客户端和服务端各一套:
// 客户端:预渲染阶段先别上报
if (document.prerendering) {
document.addEventListener('prerenderingchange', () => {
// 页面被真正激活后才开始
reportPageView()
}, { once: true })
} else {
reportPageView()
}
// 事后判断是否来自预渲染
const nav = performance.getEntriesByType('navigation')[0]
if (nav.activationStart > 0) {
// 这次导航命中了 prerender,activationStart 是激活时刻
}
服务端这边,Chrome 会在预取请求上带一个 Sec-Purpose 头:
- prefetch:
Sec-Purpose: prefetch - prerender:
Sec-Purpose: prefetch;prerender - 需要匿名 IP 的跨站场景:
Sec-Purpose: prefetch;anonymous-client-ip
看到这个头,你可以选择照常响应,也可以直接回 204 / 503 拒绝它。至少应该在日志里把它标出来,否则你永远不知道那些多出来的请求是哪来的。
反过来也成立:不要在预渲染页面里做任何"有副作用"的事——发送站内消息、扣减额度、写数据库。这些应该等到
prerenderingchange之后。
# 五、bfcache:后退键本来应该是零成本的
讲完"往前走",再说"往回走"。
浏览器有个 bfcache(Back/Forward cache):离开页面时不销毁它,而是把整个页面(包括 JS 堆、DOM、滚动位置、未完成的定时器)冻结下来放进内存。用户按后退时直接解冻,没有任何网络请求,也没有重新执行 JS。
理论上后退应该是瞬间的。实际上它经常不生效,而且你很难察觉——只是"感觉后退有点卡"。

我查过自己博客为什么没命中,原因很典型:
// 1) unload 监听器:最经典的杀手
window.addEventListener('unload', () => { /* 上报退出 */ })
// → 浏览器无法安全地冻结一个有 unload 处理的页面,直接放弃 bfcache
常见的阻断项:
| 阻断原因 | 说明 |
|---|---|
unload 监听器 | 头号杀手,用 pagehide / visibilitychange 替代 |
主文档 Cache-Control: no-store | 常见于带登录态的页面 |
| 未关闭的连接 | IndexedDB 事务、WebSocket、WebRTC 连接仍在活动 |
打开的子窗口 / window.opener 引用 | 引用链还在就无法冻结 |
页面用了 SharedWorker | 需要特殊处理 |
怎么查:
// 页面被从 bfcache 恢复时会触发,persisted 为 true
window.addEventListener('pageshow', (e) => {
if (e.persisted) console.log('从 bfcache 恢复')
})
// 想知道为什么没被缓存
const nav = performance.getEntriesByType('navigation')[0]
console.log(nav.notRestoredReasons)
// blocked / reasons 里会写明具体原因
Chrome DevTools 也有个 Application → Back/forward cache 面板,直接点一下就能跑测试并给出原因。
我把那个 unload 上报换成 pagehide 之后,后退终于变成零延迟了。这个改动五分钟,收益比调一周打包配置还明显。
# 六、"快"到最后是个感知问题
做到这里,你会发现一个有意思的现象:bfcache 和 prerender 都把加载时间降到了接近零,但体验上的提升并不等于数字上的提升。因为人对"快"的感受,很大一部分来自连续性。
如果页面内容瞬间就位,但因为整屏白闪了一下,用户依然会觉得"卡了一下"。
这就是 View Transitions API 要补的另一半。同一文档内:
document.startViewTransition(() => {
// 在这里更新 DOM,浏览器会自动截图并做过渡
renderNextPage()
})
跨文档(也就是真正的 MPA 跳转)更简单,两端页面的 CSS 里都写:
@view-transition {
navigation: auto;
}
然后给需要"连续"的元素起名字:
.article-title { view-transition-name: article-title; }
浏览器会把旧页面和新页面里同名元素配对,自动做位移、缩放和淡入淡出的插值。列表页标题变成详情页标题、卡片展开成整篇文章,这类效果是免费拿到的。

底层的动画可以用伪元素单独控制:
::view-transition-old(article-title) { animation-duration: 200ms; }
::view-transition-new(article-title) { animation-duration: 200ms; }
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*) { animation: none !important; }
}
注意最后那段 —— 转场动画一定要尊重 prefers-reduced-motion,否则你会把一部分用户直接劝退。
# 七、一份可以照着做的清单
按"投入产出比"排序,我自己的落地顺序是这样的:
- 先删掉所有
unload监听,换pagehide。成本最低,立刻收获 bfcache。 - 用
notRestoredReasons或 DevTools 面板测一遍后退,把剩下的阻断项清掉(多半是没关的 IndexedDB 连接)。 - 给主要目标页加
prefetch规则,eagerness: "conservative"。几乎零风险。 - 确认你的埋点能识别
document.prerendering和Sec-Purpose,再上prerender,否则数据会先坏掉。 - 给详情页加
@view-transition+view-transition-name,把"零加载"升级成"零打断"。 - 用
nav.activationStart > 0做监控,看预渲染的命中率到底有多少——命中率低于 20% 说明你的猜测规则不准,别硬开。
关于生效范围也要说实话:prefetch / prerender 目前主要是 Chromium 系(Chrome、Edge 及同源内核)支持,Firefox 与 Safari 的跟进程度不一致,不支持的浏览器会静默忽略这段脚本,页面照常工作——这是渐进增强,不是依赖。上之前最好实测一遍。
# 八、一点感想
我一开始抗拒这类 API,理由是"这不就是 SPA 换了个壳吗"。
后来想明白了区别在哪:SPA 是用 JS 在应用层重新实现了一遍导航,你得自己管路由、管数据、管滚动恢复、管转场;而 Speculation Rules + bfcache + View Transitions 是浏览器在平台层把这件事做了,你只写一份 JSON 和几行 CSS,剩下的调度、节制、内存管理都由浏览器负责——包括"用户流量不够时自动不做"这种你自己很难写对的策略。
Web 平台花了十年时间在补一个本该早就有的能力:让多页应用也能有原生应用的那种顺滑,而不需要付出整套 SPA 的复杂度。 我这个博客是纯静态 MPA,接上这些东西之后,点文章的体验已经接近本地应用了——而我的代码里没有一行客户端路由。
这大概就是平台进步的模样:不是给你新玩具,是让你把旧包袱扔掉。
(文中各 API 的浏览器支持情况与字段细节截至本文写作时,实际使用前请以当前版本的实测结果为准。)