网页还能更快吗:Speculation Rules、bfcache 和"感觉没加载"这件事

有段时间我一直在跟博客的首屏数字较劲。压缩、拆包、图片转 AVIF、字体子集化,能做的都做了,LCP 停在两秒出头,再也下不去。

后来我换了个思路:别让它更快,让它根本不用加载。

深色桌面上放着一台笔记本,屏幕里的网页瞬间出现,四周有速度光线和发光的网络连线汇聚,深蓝青色调

点开文章页的那一刻,页面已经在后台渲染好了,切换是零延迟的。这不是什么黑科技,是浏览器这几年悄悄补齐的一组能力:预取、预渲染、bfcache、以及配套的 View Transitions。这篇把它们串起来讲一遍,顺便记录我踩过的几个坑。

# 一、导航为什么慢:它默认是串行的

一次普通的页面跳转,时间大致花在这些事情上,而且是排队发生的:

  1. 用户点击 → 浏览器发请求
  2. 服务器响应 HTML(一个 RTT 起步)
  3. 解析 HTML → 发现 CSS、JS、字体 → 再发请求(又一个 RTT)
  4. 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 才动手,过滤掉了大部分"鼠标扫过去"的误判。

发光的 JSON 规则表悬浮在浏览器前方,光束指向若干条被提前准备的候选链接,深蓝赛博风格插画

# 跨站和参数:两个要注意的点

跨站预渲染需要对方点头。 同站(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 执行了,图片请求发出去了,埋点也发出去了。如果你的统计代码不加区分,会出现三种难看的后果:

  1. 页面浏览量虚高(预渲染了一次没被点击)
  2. 转化漏斗对不上(点了才算的核心事件被提前上报)
  3. 后台出现"幽灵请求",消耗带宽和配额

防护手段,客户端和服务端各一套:

// 客户端:预渲染阶段先别上报
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; }

浏览器会把旧页面和新页面里同名元素配对,自动做位移、缩放和淡入淡出的插值。列表页标题变成详情页标题、卡片展开成整篇文章,这类效果是免费拿到的。

两个不同网页布局之间的平滑变形过渡,卡片和图片渐变移动到新位置,柔和金色光轨,现代 UI 动效插画

底层的动画可以用伪元素单独控制:

::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,否则你会把一部分用户直接劝退。

# 七、一份可以照着做的清单

按"投入产出比"排序,我自己的落地顺序是这样的:

  1. 先删掉所有 unload 监听,换 pagehide。成本最低,立刻收获 bfcache。
  2. 用 notRestoredReasons 或 DevTools 面板测一遍后退,把剩下的阻断项清掉(多半是没关的 IndexedDB 连接)。
  3. 给主要目标页加 prefetch 规则,eagerness: "conservative"。几乎零风险。
  4. 确认你的埋点能识别 document.prerendering 和 Sec-Purpose,再上 prerender,否则数据会先坏掉。
  5. 给详情页加 @view-transition + view-transition-name,把"零加载"升级成"零打断"。
  6. 用 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 的浏览器支持情况与字段细节截至本文写作时,实际使用前请以当前版本的实测结果为准。)