1. 项目缘起:为什么我决定"手搓"一套预取系统
大概半年前,我负责维护一个内容型站点,页面数量不少,首屏也算不上慢,但有一个体验问题一直堵在心里:用户从列表页点进详情页时,总会有一段明显白屏等待。虽然服务器响应挺快,可网络传输、HTML解析、样式和脚本执行加起来,在弱网下轻轻松松超过两三秒。用户反复点击、退回,那种等待感就像在旧时代上网。
我一开始也想过直接用现成的预取库,但仔细调研之后发现,这些库大多绑定特定框架,或者内部逻辑黑盒化,不好针对我们自己的用户行为做精细控制。于是我做了一个决定:不引入重型依赖,直接手写一套适用于普通HTML页面的预取系统。核心思路很简单,就是"预测用户接下来会点哪里,提前把页面内容拉过来"。整个方案基于原生HTML、JavaScript和浏览器缓存接口实现,没有使用任何框架,也没有复杂的构建流程。
这个内容适合谁看?我觉得有两类人最合适:一类是做内容站、电商站、展示站,被页面跳转体验困扰的前端开发者;另一类是想要理解预取原理,想在项目里做轻量性能优化但不想背框架包袱的工程师。读完你就能按照同样的思路,用不到200行代码,给自己的站点加一层"预取加速"。
2. 用户行为预测:本质上是概率问题,不是算命
2.1 哪些行为信号值得采集
很多人听到"用户行为预测"就觉得要上机器学习、要埋点统计、要搞用户画像,其实在预取这个场景里,我们完全不需要那么复杂。预取要回答的问题只有一个:用户下一次点击最可能落在哪里?这个问题可以通过一些低成本的行为信号来判断。
我在实际项目中主要采集以下三类信号:
第一类是比较直观的鼠标行为。鼠标移动方向、悬停停留时长、悬停目标元素。当用户在一个链接上停留超过一定阈值,他点击这个链接的概率会显著上升。这里有个细节,鼠标从列表第一项滑向第三项的过程中,会经过第二项,如果你把所有经过的链接都当作候选,预取请求会爆炸。所以必须设置一个"悬停稳定时间",比如120毫秒,只有鼠标在某个链接上停留超过这个时间,才把它标记为高概率候选。
第二类是键盘操作。当时我用的是Tab键焦点变化,键盘用户在页面中按Tab切换焦点时,行动路径是线性且清晰的,当前聚焦的链接基本就是下一个要激活的目标。这个信号意外地有效,尤其是对无障碍访问场景,额外开销极低。
第三类是历史点击模式。我看了一下站点的访问日志,发现超过七成的用户路径是有规律可循的。比如从文章列表页进入详情页后,他们大概率会点击上一篇或下一篇,而不是回到列表重新选择。因此,在详情页底部主动识别上一篇/下一篇链接,把它们加入预取队列,命中率非常高。
当然,移动端的情况有一点点不同。触屏设备没有鼠标悬停事件,只有touchstart和touchend,这时候我采用的方法是"触摸起始位置预测"。当手指触碰到屏幕的一瞬间,去计算触点位置命中了哪个链接,然后立刻发起预取。等手指抬起后的click事件真正触发时,页面其实已经加载得差不多了。这个思路本质上是用"手指按下"作为预测信号,虽然比悬停的提前量小,但依然能省掉大部分网络等待。
2.2 最小可行的预测模型:权重评分
我没有做太复杂的数学模型,只是给每个候选链接维护一个分数,分数由三部分组成:当前会话中是否被悬停过、是否被聚焦过、以及历史上这类页面被点击的统计概率。写出来大概是这个思路:
const score = { hovered: 1, focused: 2, history: 3, touching: 1.5 };每次采集到信号时,就给对应链接的分数加上对应的权重。当总分超过一个阈值(比如2分),就触发预取。这个模型虽然简单,但在实际运行中非常稳,因为它解决的问题是"用户在几秒内最可能要什么",而不是"用户长期喜欢什么"。预取只需要比用户快几步就够了,不需要快十步。
我事后再去看这套打分方案的命中率,预取请求中真正被用户点击的比例大概在40%到60%之间。这个数字看起来不算高,但因为预取是静默进行的,没被点击的请求也只是白白消耗了一部分带宽,不会对用户造成可见影响,所以这个命中率在工程上完全可以接受。
3. 预取机制的选型对比:别一上来就用fetch
3.1 浏览器原生预取能力盘点
在决定手写预取逻辑之前,我仔细研究了一下现代浏览器已经支持的预取能力,大致有这几种:
第一种是最传统的<link rel="prefetch">。这个标签告诉浏览器,当前页面结束后可以提前加载某个URL,浏览器会在空闲时下载并缓存到HTTP缓存中。它的特点是优先级较低,适合加载用户将来可能访问的资源,但对当前页面渲染没有任何帮助。我之前实测过,prefetch的资源存在HTTP缓存里,等用户真正跳转后,浏览器可以直接走缓存,速度确实快不少。
第二种是<link rel="preload">。这个和prefetch经常被搞混,preload是用来加载当前页面即将用到的关键资源,优先级很高,会抢占当前页面的带宽和网络连接。如果拿它来预取用户还没点击的页面,反而可能拖慢当前页面的加载,得不偿失。
第三种是fetch()加keepalive。fetch的好处是可控性强,你可以拿到完整的响应体,也可以把响应数据塞进自定义缓存,不依赖浏览器默认的HTTP缓存策略。坏处是如果预取的是完整HTML页面,且页面里有不少图片和静态资源,直接fetch HTML容易造成资源重复下载,因为HTML里引用的图片、样式表、脚本还没被请求呢。
第四种是Service Worker缓存。这个方案最彻底,它可以在后台帮我们把整个页面及其相关资源都缓存起来,再通过拦截fetch事件直接返回缓存内容。缺点是Service Worker本身有学习成本,而且首次安装和更新策略需要仔细设计。我最后的选择是"混合实现",后面这一节我会仔细说。
3.2 我为什么最终选择了混合架构
纯粹用link rel="prefetch"有一个很大的问题:它只预取单个URL,但一个HTML页面通常还会加载自己的CSS、JS、图片。如果我只把HTML拉进缓存,用户跳转后浏览器还是要逐个请求这些子资源,虽然HTML解析快了,但整体感知提升有限。
纯粹用Service Worker呢?它确实能缓存子资源,但Service Worker的缓存更新是一个大坑。如果站点的某个静态资源发布了新版本,而Service Worker还拿着旧缓存,用户会看到样式错乱甚至功能失效。我不想为了预取引入一个容易出这个毛病的额外层级。
所以我的最终方案是这样的:预取请求走fetch()发起,拿到HTML响应后,用cache.put()把请求和响应存入Cache Storage,然后顺便解析HTML中引用的静态资源URL,再用低优先级的<link rel="prefetch">去启动浏览器原生的资源缓存加载。这样HTML本体由我控制,子资源交给浏览器兜底,两全其美。
这个架构的另一个好处是,所有预取行为都在一个独立的PrefetchManager对象里管理,不用侵入业务代码。无论页面是传统的多页应用还是某个局部用了Vue或React,都可以直接把这段逻辑挂到页面上,互不干扰。
4. 预取系统的核心实现:一步步手写
4.1 采集信号和触发预取的基础框架
我按照"采集信号-评分-预取"三个模块来组织代码。先看基础框架:
class PrefetchManager { constructor(options = {}) { this.threshold = options.threshold || 2; this.stableDelay = options.stableDelay || 120; this.cacheName = 'prefetch-cache-v1'; this.scores = new Map(); this.prefetchedUrls = new Set(); this.init(); } init() { document.addEventListener('mouseover', this.handleMouseOver.bind(this)); document.addEventListener('mouseout', this.handleMouseOut.bind(this)); document.addEventListener('focusin', this.handleFocusIn.bind(this)); document.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: true }); } getScore(link) { const href = link.href; return this.scores.get(href) || { value: 0, timer: null }; } addScore(link, delta) { const href = link.href; const current = this.getScore(link); current.value += delta; this.scores.set(href, current); if (current.value >= this.threshold) { this.prefetch(link); } } }mouseover和mouseout分别负责悬停开始和结束。关键在于,mouseover的时候要设置一个定时器,只有悬停时长超过stableDelay才能加分,这样可以避免鼠标快速扫过链接时产生误判。实现如下:
handleMouseOver(event) { const link = event.target.closest('a[href]'); if (!link || link.target === '_blank') return; const href = link.href; if (this.prefetchedUrls.has(href)) return; const current = this.getScore(link); current.timer = setTimeout(() => { this.addScore(link, 1); }, this.stableDelay); } handleMouseOut(event) { const link = event.target.closest('a[href]'); if (!link) return; const current = this.getScore(link); if (current.timer) { clearTimeout(current.timer); current.timer = null; } }这里的几个细节都很值得注意。link.target === '_blank'意味着新开标签页,预取没有太大意义,因为新标签页会重新走一遍加载流程,旧页面的预取缓存帮不上忙。prefetchedUrls这个集合则是防止同一个链接被重复预取,万一用户反复悬停又移开,总不能反复请求同一个URL吧。
对于聚焦事件,逻辑更简单,因为键盘用户的焦点不会像鼠标那样快速滑动:
handleFocusIn(event) { const link = event.target.closest && event.target.closest('a[href]'); if (!link) return; this.addScore(link, 2); }触屏用户则依赖touchstart事件:
handleTouchStart(event) { const touch = event.touches[0]; if (!touch) return; const element = document.elementFromPoint(touch.clientX, touch.clientY); const link = element && element.closest('a[href]'); if (link) { this.addScore(link, 1.5); } }4.2 预取请求与缓存写入
当链接分数达到阈值后,会进入prefetch()方法。这里我做了两个层面的缓存处理,Cache Storage和浏览器HTTP缓存:
async prefetch(link) { const href = link.href; if (this.prefetchedUrls.has(href)) return; this.prefetchedUrls.add(href); try { const cache = await caches.open(this.cacheName); const response = await fetch(href, { credentials: 'same-origin', mode: 'same-origin' }); if (response.ok) { await cache.put(href, response.clone()); this.warmSubresources(href, response); } } catch (error) { console.warn('[PrefetchManager] 预取失败:', href, error); this.prefetchedUrls.delete(href); } }需要注意的是,response.clone()是必须的。因为Response对象是一次性的,你把它存入Cache之后,再想去读取它的body用于解析子资源就会报错,所以必须克隆一份。预取失败时把prefetchedUrls里的记录删掉,这样下次悬停还能重新尝试,不会因为一次网络抖动就永远放弃这个链接。
warmSubresources我这边做的是解析HTML字符串里的CSS和JS引用:
async warmSubresources(href, response) { const html = await response.clone().text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const urls = []; doc.querySelectorAll('link[rel="stylesheet"]').forEach(link => { urls.push(new URL(link.href, href).href); }); doc.querySelectorAll('script[src]').forEach(script => { urls.push(new URL(script.src, href).href); }); urls.forEach(url => { const prefetchLink = document.createElement('link'); prefetchLink.rel = 'prefetch'; prefetchLink.href = url; document.head.appendChild(prefetchLink); }); }这个方案有一个现实限制:如果HTML里的子资源URL是由JS动态拼接出来的,比如某些懒加载的图片,那么静态解析是拿不到完整URL的。不过对于CSS和常规script,解析基本够用。图片资源我没有主动预取,因为图片数量多、体积大,全量预取可能让带宽飙得很高,反而是个负担。
4.3 用户跳转时的缓存命中逻辑
预取只是前半场,真正让用户体感变快的是跳转后能命中缓存。这里我利用了Cache Storage的拦截能力,在页面初始化时注册一个fetch拦截器:
if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js'); }这个sw.js的思路是:优先查Service Worker控制的缓存,命中就直接返回,没有命中就走网络。我会用下面这段核心代码:
self.addEventListener('fetch', event => { if (event.request.method !== 'GET') return; event.respondWith( caches.match(event.request).then(cached => { if (cached) return cached; return fetch(event.request).then(response => { if (response.ok && new URL(event.request.url).origin === location.origin) { const clone = response.clone(); caches.open('prefetch-cache-v1').then(cache => { cache.put(event.request, clone); }); } return response; }); }) ); });这个拦截器看起来简单,其实我踩过几次坑。最典型的是:如果预取缓存里存的是一个旧版本HTML,而服务器其实已经发布了新版本,用户打开缓存页面会看到过期内容。对内容站来说,这个问题很严重。所以我在后台做了一个"先比较后更新"的逻辑,每隔几分钟重新验证一次缓存页面的版本,如果服务端返回的ETag或Last-Modified变了,就主动用新页面替换缓存。当然,没有服务端接口的情况下这个逻辑需要后端配合。
5. 实操收益与排查心得
5.1 我用真实站点验证的效果
整个系统上线第一个版本后,我找了一个访问量相对平稳的频道做了A/B测试,A组正常页面跳转,B组接入预取系统。测试周期是一周,统计维度是页面跳转时的LCP时间(最大内容绘制)和用户跳出率。
从数据上看,B组和A组相比,详情页LCP的中位数从2200毫秒降到了900毫秒左右,降幅达到59%。用户从列表页点击进入详情页后,能够更快看到完整内容,跳出率也随之降低了约6个百分点。对内容站来说,这个提升已经是肉眼可见的改善。
我后来又做了一个更细的统计,发现不同的预取触发信号命中率差异很大。Tab键聚焦的命中率最高,接近75%,因为焦点顺序意味着用户已经明确选择了下一个目标。touchstart触发排在第二,命中率约65%。鼠标悬停加稳定时间排在第三,命中率约45%。这个排序符合直觉,也说明你没必要对所有信号一视同仁,可以根据命中率给不同信号赋不同的权重。
5.2 实际运行中遇到的最大坑点
第一个坑是带宽浪费。预取的命中率虽然有一半左右,但另一半确实浪费了。在移动网络环境下,这个浪费可能会造成用户流量消耗增加。我当时对预取链接做了一层白名单控制,只有列表页的前N个链接和详情页的上一篇/下一篇允许预取,其他链接一律不进入预取队列。白名单控制下来,总预取请求数减少了三分之一,命中率反而还上升了一点。
第二个坑是动态页面缓存不一致。有些详情页会有一段随机推荐内容,每次刷新都不一样,如果我把这种页面缓存到Service Worker里,用户看到的就是第一次预取时的快照。后来我对这类接口请求做了标记,它们的URL带有?cache=no这样的参数,预取系统会对它们特殊处理,不写入缓存,直接走正常网络请求。
第三个坑是缓存版本升级滞后。站点上线预取后,有一次运营更新了首页文案,但很多用户打开的还是预取缓存里的旧内容。这个教训让我意识到,预取系统一定要配合版本管理。我的做法是在缓存名称里加上版本号,比如prefetch-cache-v1,每次发布前端代码时,手动把版本号改成v2,Service Worker在激活阶段检测到缓存名变化,就把旧缓存一次性清掉。
第四个坑,可能也是最有必要提醒大家的,就是预取不能无脑"全文拉取"。页面如果是服务端渲染出来的完整HTML,体积通常不小,如果用户实际不点击,白白消耗了网络带宽,反而拖慢了当前页面的网络传输。我后来加了优先级控制,在网络空闲时才发起预取请求,具体实现是借用requestIdleCallback,它只在浏览器空下来的时候执行回调:
if ('requestIdleCallback' in window) { requestIdleCallback(() => this.prefetch(link)); } else { setTimeout(() => this.prefetch(link), 500); }这个改动虽然小,但效果很明显。预取不再抢占当前页面的关键请求,用户首屏加载速度变得稳定了。
5.3 和构建工具/框架协同的问题
如果你现在用的是Vite、Webpack这类构建工具,预取系统的接入还要注意一个问题:打包后的文件通常带着哈希后缀,比如app.a1b2c3.js。如果你在页面HTML静态写死了预取的资源地址,每次发版后哈希一变,预取配置就失效了。更好的做法是让构建工具自动生成一份"预取清单",列出当前版本的HTML、CSS和JS地址,然后预取系统在运行时动态读取这份清单。
我自己的做法是写了一个小插件,在构建完成后把产物的HTML文件名、CSS文件名、JS文件名输出成prefetch-manifest.json,页面初始化时用fetch拉取这个清单,再按需预取里面的资源。这样发版后无需手动改任何代码,预取配置天然跟着构建走,省心很多。
小插件逻辑大概是这样:
// build-prefetch-manifest.js const fs = require('fs'); const path = require('path'); class PrefetchManifestPlugin { apply(compiler) { compiler.hooks.emit.tapAsync('PrefetchManifestPlugin', (compilation, callback) => { const assets = compilation.getAssets(); const manifest = {}; assets.forEach(asset => { const name = asset.name; if (name.endsWith('.html')) manifest.html = '/' + name; if (name.endsWith('.js')) manifest.js = '/' + name; if (name.endsWith('.css')) manifest.css = '/' + name; }); const content = JSON.stringify(manifest, null, 2); compilation.assets['prefetch-manifest.json'] = { source: () => content, size: () => content.length }; callback(); }); } } module.exports = PrefetchManifestPlugin;接入之后,前端代码只需要在初始化预取管理器时读取一个JSON文件,不用写死任何资源路径。这套思路同样适用于任何以静态产物为主的前端项目。
6. 总结与下一步扩展建议
我做了这么一套预取系统之后,最大的心得体会是:性能优化并不一定都需要引入重型方案,很多问题用最原生的能力反而能解决得最优雅。预取的本质是"把用户的下一步提前做了",这件事的难度不在技术实现,而在你怎么判断用户的意图。你的行为信号采集得越准,预取命中率越高,整个系统的价值就越大。
如果你也想在自己的站点里复刻这套方案,我建议你从最简单的mouseover监听开始,先给链接加分,再触发fetch写入Cache Storage,加上Service Worker拦截回放,就跑通了一个最基础版本。先把这条路走通,再逐步加入聚焦事件、touchstart事件、历史点击统计和构建清单,一步步把系统打磨完整。
我还打算把这个方案继续扩展成"预取+预渲染"的模式。现在的实现只是把HTML和静态资源提前下载好,用户跳转后还是会经历页面的JS执行和渲染过程。如果后续能再加一个隐藏Iframe,把目标页面完整渲染出来,用户点击跳转时直接切换显示隐藏Iframe,那整个页面切换几乎就是即时的。这个方向我还在验证,涉及的内存和兼容性细节比较多,等稳定下来再单独写一篇分享。
最后再提醒一句:任何预取方案都要设置退路,不能让预取影响了正常的网络请求和缓存策略,不然优化体验反而变成了体验事故。预取是个好工具,但温和地使用它,它才会成为你的伙伴。