资讯动态

手写轻量级预取系统:基于用户行为预测的前端性能优化实战

发布时间:2026/9/24 18:27:36 来源:尧图企业网站定制
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. 预取机制的选型对比别一上来就用fetch3.1 浏览器原生预取能力盘点在决定手写预取逻辑之前我仔细研究了一下现代浏览器已经支持的预取能力大致有这几种第一种是最传统的link relprefetch。这个标签告诉浏览器当前页面结束后可以提前加载某个URL浏览器会在空闲时下载并缓存到HTTP缓存中。它的特点是优先级较低适合加载用户将来可能访问的资源但对当前页面渲染没有任何帮助。我之前实测过prefetch的资源存在HTTP缓存里等用户真正跳转后浏览器可以直接走缓存速度确实快不少。第二种是link relpreload。这个和prefetch经常被搞混preload是用来加载当前页面即将用到的关键资源优先级很高会抢占当前页面的带宽和网络连接。如果拿它来预取用户还没点击的页面反而可能拖慢当前页面的加载得不偿失。第三种是fetch()加keepalive。fetch的好处是可控性强你可以拿到完整的响应体也可以把响应数据塞进自定义缓存不依赖浏览器默认的HTTP缓存策略。坏处是如果预取的是完整HTML页面且页面里有不少图片和静态资源直接fetch HTML容易造成资源重复下载因为HTML里引用的图片、样式表、脚本还没被请求呢。第四种是Service Worker缓存。这个方案最彻底它可以在后台帮我们把整个页面及其相关资源都缓存起来再通过拦截fetch事件直接返回缓存内容。缺点是Service Worker本身有学习成本而且首次安装和更新策略需要仔细设计。我最后的选择是混合实现后面这一节我会仔细说。3.2 我为什么最终选择了混合架构纯粹用link relprefetch有一个很大的问题它只预取单个URL但一个HTML页面通常还会加载自己的CSS、JS、图片。如果我只把HTML拉进缓存用户跳转后浏览器还是要逐个请求这些子资源虽然HTML解析快了但整体感知提升有限。纯粹用Service Worker呢它确实能缓存子资源但Service Worker的缓存更新是一个大坑。如果站点的某个静态资源发布了新版本而Service Worker还拿着旧缓存用户会看到样式错乱甚至功能失效。我不想为了预取引入一个容易出这个毛病的额外层级。所以我的最终方案是这样的预取请求走fetch()发起拿到HTML响应后用cache.put()把请求和响应存入Cache Storage然后顺便解析HTML中引用的静态资源URL再用低优先级的link relprefetch去启动浏览器原生的资源缓存加载。这样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[relstylesheet]).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带有?cacheno这样的参数预取系统会对它们特殊处理不写入缓存直接走正常网络请求。第三个坑是缓存版本升级滞后。站点上线预取后有一次运营更新了首页文案但很多用户打开的还是预取缓存里的旧内容。这个教训让我意识到预取系统一定要配合版本管理。我的做法是在缓存名称里加上版本号比如prefetch-cache-v1每次发布前端代码时手动把版本号改成v2Service 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那整个页面切换几乎就是即时的。这个方向我还在验证涉及的内存和兼容性细节比较多等稳定下来再单独写一篇分享。最后再提醒一句任何预取方案都要设置退路不能让预取影响了正常的网络请求和缓存策略不然优化体验反而变成了体验事故。预取是个好工具但温和地使用它它才会成为你的伙伴。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价