ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

手写HTML预取系统:基于用户行为预测的前端性能优化实战 做前端的人应该都有过这种体验页面一旦多起来用户点下一页总会卡顿那么一下尤其在移动端弱网下白屏时间能让人焦虑到怀疑服务器挂了。我一开始也尝试过各种优化压缩资源、上CDN、加缓存头但总感觉少了点什么。后来想明白了一个道理既然用户大概率马上会点下一个页面为什么不提前把那个页面拉到浏览器里缓存起来这个思路就是预取Prefetch而配合用户行为预测之后它就不再是简单的“提前请求”而是一套能自动判断用户意图、后台悄悄加载资源的HTML预取系统。这篇内容我会完整记录我从零手写这套系统的过程如何监听鼠标悬停、如何用IntersectionObserver感知可见区域、如何给链接打分排序、如何控制弱网下的预取策略以及最后我在实际项目中踩过的坑。如果你也在维护博客、文档站、多页应用或者任何“链接跳转占大头”的站点这套方案可以直接抄走。1. 预取系统整体设计先想清楚要解决什么问题1.1 什么是预取哪些场景真正需要预取预取通俗讲就是“提前把资源下载好等要用的时候直接取”。生活里最像的例子是去饭店吃饭大部分人落座前已经想好要点什么后厨在你坐下的瞬间就开始备菜等你真正下单菜几分钟就上齐了。网页预取就是这套逻辑浏览器在后台提前请求用户“很可能访问”的URL把响应缓存下来用户真正点击时直接走本地缓存网络往返时间直接省掉。但预取不是放之四海而皆准的方案用错场景反而会浪费流量。以我自己的实践来看下面这几类网站才是预取的主场文档站和博客用户通常在列表页扫一眼然后点进某篇正文或者从文章底部点“下一页”跳转意图非常明确。数据列表页表格每页数据加载较慢用户翻页概率极高提前预取下一页能明显改善体感。商品推荐位详情页里的“相关推荐”卡片点击率不错提前加载推荐目标页用户点击时几乎无感。内容型站点的分类导航用户会从一个栏目跳到另一个栏目导航区域的链接都可以做预取候选。反过来如果你的用户行为极其随机比如门户首页每个链接点到的概率都差不多预取的命中率就会很低或者你的页面包含敏感信息、需要严格登录态校验那也不建议预取因为拉下来的可能就是一张未登录的空壳页面缓存了反而有害。判断标准就一条预取命中带来的速度提升能不能覆盖住浪费掉的流量和无用请求。1.2 方案选型原生标签、Service Worker还是手搓脚本我当时摆在我面前的有三条路用浏览器原生标签、上Service Worker、或者自己手写一套脚本。先说原生标签。HTML里其实已经提供了link relprefetch浏览器会在空闲时以最低优先级请求指定URL。优点是零成本、不用写JS缺点是控制粒度太粗无法在“用户悬停链接”时动态决定预取哪个URL也无法根据网络状况调整预取数量。它更适合做静态配置比如预先声明下一页固定的URL。再说Service Worker。它能做完整的离线缓存、请求拦截、动态缓存策略是PWA时代的核心利器。但引入Service Worker的代价也不小需要维护一份独立的缓存清理逻辑还得处理版本更新对一个小项目来说重了点。最后就是我自己走的路手写JS预取脚本。核心思路是监听用户的交互事件动态地在head里插入link relprefetch标签把预测到的URL交给浏览器去请求。这么做的好处有三个预取哪些链接、什么时候预取完全由我的行为预测逻辑决定。可以精确控制并发数弱网下甚至直接关闭预取。不依赖框架纯HTMLCSSJS的项目也能用。手写脚本还有一个隐性收益它把“用户行为预测”这件事从“玄学”变成了“可观察、可调试、可优化”的代码。我可以随时往评分函数里加规则比如某类URL权重更高、某类链接直接过滤这是原生标签给不了的灵活度。1.3 整体架构拆解监听、预测、加载、缓存四步整套系统我拆成了四个模块对应预取的完整生命周期监听层负责捕捉用户与候选链接的交互信号包括鼠标悬停、鼠标按下、元素进入视口等。预测层把监听层收集到的信号汇总成“这个链接疑似会被点击”的置信度评分高分的排到预取队列前面。加载层根据当前网络状态和并发上限决定是否真的发起预取请求。请求方式可以是用link标签交给浏览器也可以是fetch()。缓存层浏览器HTTP缓存接管预取回来的资源同时脚本内部用一个Map做“已请求”标记避免同一URL被重复预取。四个模块之间用事件解耦监听层只负责上报信号预测层只负责打分抛队列加载层只看队头和网络状态。这样改起来很舒服比如以后想加“点击热图统计”模块只要在监听层加一个事件监听就能把数据喂给预测层加载层完全不用动。2. 预测用户行为的几种策略与实现原理2.1 悬停意图鼠标位置就是最强信号用户马上就要点击一个链接之前鼠标一定已经停在那上面了。这个信号非常强是整套系统里优先级最高的触发源。我实际用的监听方式是全局事件委托在document上挂一个pointerover事件再用event.target.closest(a[href])找到用户悬停的链接。不过直接一悬停就预取会有一个问题用户鼠标扫过一个链接不代表想点它。我加了一个悬停阈值只有鼠标悬停在同一链接上超过100毫秒才进入预取候选队列。100毫秒这个值是反复试出来的太短容易误判太长又来不及在用户点击前完成加载。桌面端普遍在200到300毫秒内能完成一次预取请求所以100毫秒的判定窗口既够灵敏也不会让预取太频繁。这背后有一个细节移动端没有真正意义上的“悬停”pointerover在触摸设备上经常不触发。我做了降级处理监听pointerdown事件——用户手指按下去的瞬间距离点击大概还有100到150毫秒对预取来说虽然有点紧但按下去的同时如果有空闲时间还是能捞回一部分资源。这也是为什么移动端建议配合后面的“空闲加载”策略来用。2.2 可见性与空闲时机IntersectionObserver加requestIdleCallback光靠悬停还不够有些用户会用键盘Tab键导航或者直接用手指快速滑动页面找链接此时鼠标悬停信号就消失了。为了覆盖这些场景我用IntersectionObserver观察所有候选链接的可见性只要一个合法链接进入视口附近就先把它放进一个“待预取池子”里。进入视口不马上预取因为还没确认用户一定会点。此时就轮到requestIdleCallback登场了这个API能在浏览器处理完高优先级任务后空闲时执行回调非常适合做“后台低优先级预取”。我的策略是IntersectionObserver负责把可见链接推进池子requestIdleCallback负责在浏览器空闲时从池子里捞链接发起预取。实际效果很明显用户页面向下滚动时下一页、侧边栏推荐链接基本都在屏幕上露过头浏览器一旦空闲这些资源就已经在后台下载了。等用户真正点击几乎零等待。2.3 基于URL模式与历史行为的评分模型悬停和可见性能覆盖大多数场景但还不够“智能”。我理想中的预取系统应该能“猜”出用户接下来会点哪而不只是“看到鼠标停在哪”。于是我在预测层加了一个评分模型给每个候选链接算一个综合分。评分公式大概是这样的score hoverScore visibleScore urlPatternScore historyScore - networkPenaltyhoverScore当前鼠标悬停的链接直接加高分比如2分。visibleScore在视口内且面积占比高的链接加1分。urlPatternScore符合“下一页”特征URL的链接加1分。比如链接URL里包含page2、/articles/、next这类关键词说明它很大概率是用户接下来的目标。historyScore如果同一会话内用户之前频繁访问某些路径给同路径下的其他页面加0.5分。networkPenalty资源体积大、当前网络慢直接扣分。评分模型的价值不是算得多复杂而是让预取有优先级的差异。带宽足够时我可以把池子里所有得分大于1的链接都预取弱网时只预取得分最高的那一个。没有评分模型就只能在“全预取”和“不预取”之间二选一体验很尴尬。2.4 预测策略动态权衡控制网络开销预取本质上是用流量换速度所以必须对“网络开销”有敬畏。我在脚本里读取了navigator.connection信息根据effectiveType把网络分成三档4g/ethernet正常预取池子里得分大于等于1的链接都可以加载。3g只预取得分大于等于2的链接通常是悬停中或者明确命中“下一页”模式的链接。slow-2g/2g直接关闭预取因为弱网下任何多余请求都可能让当前页面的加载雪上加霜。同时还要注意navigator.connection.saveData如果用户开启了“省流量模式”预取必须无条件关闭。这个细节很关键省流量的用户对流量消耗非常敏感一个悄悄下载了一堆页面的脚本会让他们很生气甚至直接卸载站点。加这个判断并不难但它体现的是“预取系统要尊重用户意愿”的基本原则。3. 手写HTML预取系统的核心实现3.1 工具准备与目录结构因为整套系统不依赖任何框架准备工作非常轻。我本地直接用一个静态服务器测试目录结构长这样prefetch-demo/ ├── index.html # 首页作为测试入口 ├── page-b.html # 预取目标页1 ├── page-c.html # 预取目标页2 ├── prefetch.js # 预取核心脚本 └── style.css # 测试样式不关键启动方式随便最简单的是在目录里跑python3 -m http.server 8080然后浏览器访问http://localhost:8080。为什么强调要用HTTP服务而不是直接双击HTML文件因为浏览器的预取、缓存机制依赖HTTP响应头file://协议下缓存行为不正常测出来的结果不真实。3.2 PrefetchManager类核心代码整套系统的核心是一个PrefetchManager类我把监听、评分、加载都封装在里面。完整代码可以简化成下面这个版本class PrefetchManager { constructor(options {}) { this.hoverDelay options.hoverDelay ?? 100; this.maxConcurrent options.maxConcurrent ?? 2; this.minScore options.minScore ?? 1; this.enabled false; this.pendingLinks new Map(); // href - link元素 this.prefetchedUrls new Set(); // 已预取的URL防止重复 this.activeCount 0; // 当前进行中的预取请求数 this.idleCallbackTimeout options.idleCallbackTimeout ?? 2000; // 候选链接的评分缓存 this.scoreCache new Map(); // 监听器绑定 this.onPointerOver this.onPointerOver.bind(this); this.onPointerDown this.onPointerDown.bind(this); this.scheduleIdlePrefetch this.scheduleIdlePrefetch.bind(this); } init() { // 如果用户开了省流量模式直接禁用 if (navigator.connection navigator.connection.saveData) { return this; } this.enabled true; document.addEventListener(pointerover, this.onPointerOver, { passive: true }); document.addEventListener(pointerdown, this.onPointerDown, { passive: true }); // 用IntersectionObserver观察页面内的候选链接 this.intersectionObserver new IntersectionObserver( (entries) this.handleIntersect(entries), { rootMargin: 200px } ); // 页面加载完成后扫描所有合法链接进入观察范围 if (document.readyState loading) { document.addEventListener(DOMContentLoaded, () this.scanLinks(), { once: true }); } else { this.scanLinks(); } return this; } scanLinks() { const links document.querySelectorAll(a[href]); links.forEach((link) { if (this.isEligibleUrl(link.href)) { this.intersectionObserver.observe(link); } }); } isEligibleUrl(url) { try { const parsed new URL(url, window.location.href); // 只处理同源链接避免跨域预取引发的CORS问题 if (parsed.origin ! window.location.origin) { return false; } // 跳过带hash的链接因为hash变化不会触发页面导航 if (parsed.hash) { return false; } // 跳过纯静态资源文件的链接比如图片、js等 if (/\.(png|jpe?g|gif|svg|css|js|pdf|zip|mp4|webm)(\?.*)?$/i.test(parsed.pathname)) { return false; } return true; } catch (e) { return false; } } handleIntersect(entries) { if (!this.enabled) return; entries.forEach((entry) { const link entry.target; const href link.href; if (entry.isIntersecting !this.prefetchedUrls.has(href)) { this.scoreCache.set(href, (this.scoreCache.get(href) ?? 0) 1); this.scheduleIdlePrefetch(href); } }); } onPointerOver(e) { if (!this.enabled) return; const link e.target.closest e.target.closest(a[href]); if (!link || !this.isEligibleUrl(link.href)) return; // 悬停结束后再延迟判断是否预取 clearTimeout(link._hoverTimer); link._hoverTimer setTimeout(() { if (!this.enabled) return; const href link.href; this.scoreCache.set(href, (this.scoreCache.get(href) ?? 0) 2); this.scheduleIdlePrefetch(href); }, this.hoverDelay); } onPointerDown(e) { if (!this.enabled) return; const link e.target.closest e.target.closest(a[href]); if (!link || !this.isEligibleUrl(link.href)) return; const href link.href; this.scoreCache.set(href, (this.scoreCache.get(href) ?? 0) 3); // 按下鼠标时立刻提升优先级这里直接预取不等待空闲 this.prefetch(href, high); } scheduleIdlePrefetch(href) { if (this.prefetchedUrls.has(href)) return; if (requestIdleCallback in window) { requestIdleCallback(() this.prefetch(href, low), { timeout: this.idleCallbackTimeout }); } else { setTimeout(() this.prefetch(href, low), 200); } } prefetch(href, priority low) { if (this.prefetchedUrls.has(href)) return; if (this.activeCount this.maxConcurrent priority ! high) return; // 弱网判断 if (!this.isNetworkOk()) return; this.prefetchedUrls.add(href); this.activeCount; const link document.createElement(link); link.rel prefetch; link.href href; link.onload () { this.activeCount--; }; link.onerror () { // 预取失败时把URL从已预取集合中移除允许后续重试 this.prefetchedUrls.delete(href); this.activeCount--; }; document.head.appendChild(link); } isNetworkOk() { if (!navigator.connection) return true; const effectiveType navigator.connection.effectiveType; if (effectiveType slow-2g || effectiveType 2g) { return false; } if (effectiveType 3g this.activeCount 0) { return false; } return true; } } // 页面里这样启动即可 const prefetchManager new PrefetchManager(); prefetchManager.init();这套代码其实不难但有几个设计点值得单独说。pendingLinks在最终版本里我改成了prefetchedUrls加scoreCache的组合前者保证同一URL不重复请求后者记录评分。为什么不用队列处理一堆等待中的低优先级请求因为prefetch请求本身就是低优先级的加队列只会增加复杂度遇到高优先级的pointerdown时直接绕过限制发起请求就够了。onerror回调里把URL从prefetchedUrls删除也很重要依赖的就是“预取失败之后用户真正点击时浏览器会重新请求”不能因为一次预取失败就永久放弃。这个细节是我在调试时发现的一开始失败后不重试导致某些经常失败的链接永远不再被预取命中率低了不少。3.3 关键参数说明与计算几个核心参数的取值我是通过本地测试和性能观察调出来的直接列成表格方便你参考参数默认值说明hoverDelay100ms鼠标悬停判定为“有意图”的最短停留时间maxConcurrent2同一时刻最多发起低优先级预取请求数minScore1低优先级预取所需的最低评分idleCallbackTimeout2000msrequestIdleCallback执行的最长等待时间rootMargin200px链接进入视口外围200px即被认为“可见”maxConcurrent设为2是因为浏览器对同时发起的大量低优先级请求也会做队列和调度设得太多请求之间会互相挤占反而拖慢真正用户请求的速度。实测下来同一时刻2个预取请求能充分利用空闲带宽又不会造成冲突。rootMargin设成200px是让链接在滚入视口之前就提前进入待预取池给预取留出更充分的时间窗口。这些参数不是拍脑袋定的而是经过一个循环先本地测试用浏览器Network面板看预取请求是否有大量排队再模拟弱网观察页面自身加载是否被拖慢最后根据结果调整参数。你在自己的项目里也建议跑一遍这个流程特别是hoverDelay如果你的用户群鼠标移动慢可以适当调大到150ms。3.4 部署接入与效果验证接入很简单在页面的head里引入脚本然后在底部初始化即可!DOCTYPE html html langzh-cn head meta charsetutf-8 title预取测试页/title script srcprefetch.js defer/script /head body h1首页/h1 a hrefpage-b.html进入B页面/a a hrefpage-c.html进入C页面/a /body /html验证效果时打开DevTools的Network面板切换到Slow 3G模拟慢网络然后鼠标悬停在链接上大约几秒。你会看到一条发起prefetch请求的记录它的优先级会显示为Lowest。等请求结束后再点击这个链接观察页面加载时间。实际操作里还应该配合performanceAPI做数据对比。比如在页面里记录navigation条目的domContentLoadedEventEnd对比预取命中前后同一页面的加载耗时。我在本地测试时未预取的B页面在Slow 3G下从点击到DOMContentLoaded大约1.2秒预取命中后几乎瞬间完成因为HTML直接从内存缓存读出来了。这种提升不用多说用户体感就是“秒开”。4. 实测表现与常见问题排查4.1 我用本地服务测出来的效果我专门搭了一个20页的模拟文档站来做测试每页大概50KB的HTML加上几张公共静态资源图。打开Chrome的DevTools网络模拟设置成Slow 3G。未启用预取时从列表页点击进入详情页白屏等待时间大约在1.1到1.3秒之间。启用预取后我让鼠标在列表页的下一项链接上停留300毫秒然后点击进入页面几乎瞬间渲染出来因为HTML已经躺在缓存里了。而在4G网络下由于网络链路本身很快预取的绝对时间缩短可能只有100到200毫秒但少了那一下网络波动、DNS解析、TLS握手的抖动体感依然明显更“跟手”。特别是跨页跳转最容易出现的那一小截白屏被预取彻底消灭了。如果你在本地测不出明显差别不要急这是正常的。本地服务器和浏览器缓存响应都太快预取的优势要在真实网络的延迟里才能真正体现。建议直接部署到测试环境或者用DevTools的“Slow 3G 禁用缓存”组合来模拟效果会明显得多。4.2 容易踩的坑缓存、登录态、跨域第一坑是服务器缓存头。link relprefetch本质上还是发了一个普通GET请求响应是否被缓存取决于服务器返回的Cache-Control。如果服务器对HTML设置了no-cache或者非常短的max-age预取回来的页面在用户点击时可能已经过期浏览器会重新发起请求预取就白做了。解决方法是确认目标页面的HTML响应头里至少包含Cache-Control: max-age60之类允许缓存的配置。第二坑是登录态。带登录态的站点特别容易在这里翻车用户A访问编辑后台脚本预取了一个需要登录才能访问的页面结果拿到的是重定向到登录页的响应而且这个“登录页HTML”还被缓存了下来。等用户真正进入该页面时看到的不是内容列表而是一闪而过的空白登录页。所以涉及权限校验、个性化内容的URL必须加入过滤名单放在isEligibleUrl里直接排除。第三坑是跨域。预取跨域资源时浏览器会带上Sec-Purpose: prefetch头如果你的服务器没做相应配置或没有配CORS预取会失败。但这里要分清预取只是失败不会导致页面报错所以很多问题从一开始就被静默吞掉了。我建议对所有预取请求单独加日志埋点至少能在开发阶段看到哪些URL失败、失败原因是什么。第四坑是动态URL。很多站点的URL包含实时变化的参数比如/list?timestamp123456每次渲染都会变。如果预取系统记录了带时间戳的URL用户点击时拿到的是另一个URL预取就永远命中不了。这种情况下我一般把这类不稳定参数在评分前就剔除掉或者在收到链接时就统一归一化URL格式。4.3 问题速查表把实际排障中容易遇到的问题整理成了一张速查表你可以直接收藏当排查手册用现象可能原因处理办法Network面板看不到prefetch请求脚本未初始化或saveData模式被跳过或网络被判为慢速检查init()是否执行控制台输出当前enabled状态prefetch请求优先级显示High触发了pointerdown高优先级路径属正常现象如果不是预期触发排查是否是移动端误触发的pointerdown预取成功但点击后仍然发起新请求服务端Cache-Control不允许缓存或URL因参数变化不一致调整响应头缓存策略统一URL格式后再预取弱网下页面自身加载变慢预取请求抢占了带宽并发数设置过高调低maxConcurrent到1在isNetworkOk里加强弱网限制跨域预取全部静默失败服务器没有相关CORS配置不做跨域预取或联系服务端配置响应头页面出现登录状态错乱预取了需要鉴权的页面缓存了未登录版本在过滤名单中排除个人中心、设置页等需要登录的路径同一URL多次预取prefetchedUrls被onerror删除连续失败后反复重试增加失败次数限制比如失败超过3次就不再预取4.4 上线前必查清单最后列一份上线前的检查清单我每次部署这套预取逻辑前都会过一遍能省掉不少线上事故[ ] 过滤名单是否覆盖了所有“不能预取”的页面比如登录页、后台、个人中心、购物车[ ] 是否处理了navigator.connection.saveData为真时的静默关闭逻辑[ ]maxConcurrent是否根据线上页面实际情况做了调整而不是盲目用默认值[ ] 预取目标页面的Cache-Control是否允许缓存有没有可能缓存到敏感数据[ ] 是否做了URL归一化避免时间戳、随机参数导致预取命中率骤降[ ] 是否监听了link标签的onerror避免预取失败影响后续重试[ ] 是否在页面脚本加载完成后才初始化PrefetchManager避免与首屏渲染抢资源最后再分享两个小经验这套预取系统上线后我维护了一段时间有一个经验特别想分享初始化时机非常重要。起初我把PrefetchManager放在head里的同步脚本中直接初始化结果发现首屏解析被拖慢了十几毫秒。后来改成放在DOMContentLoaded之后用requestIdleCallback延迟启动首屏完全不受影响而预取本身因为是在用户浏览列表页的中后期才发挥作用启动晚几十毫秒根本看不出来。另一个经验是针对多页面站点的如果控制脚本注入了大量link relprefetch标签切页后旧页面的标签不会自动清理虽然不影响功能但会占用一点点内存。我建议在页面卸载时清理一次或者把脚本做单体复用让每个页面共享同一个PrefetchManager实例。这样既省内存也能让跨页的历史行为数据延续下去评分模型会更准。
返回列表