不能只靠isintersecting判断加载时机,必须用rootmargin提前触发+weakmap去重+滚动趋势动态调参,否则预取会变成“卡顿前一秒才开始下载”。

直接说结论:不能只靠 isIntersecting 判断加载时机,必须用 rootMargin 提前触发 + WeakMap 去重 + 滚动趋势动态调参,否则预取就变成“卡顿前一秒才开始下载”。
为什么 threshold=0 和 isIntersecting 不足以支撑预取
很多人以为只要把 threshold 设为 0,再监听 entry.isIntersecting === true 就能“提前加载”,实际完全不是——这仍是被动响应,不是预取。
-
threshold: 0表示元素哪怕露出 1px 就触发回调,但此时图片还没解码、HTTP 请求可能刚发出、CDN 缓存未命中,用户已经看到空白或 placeholder -
isIntersecting是瞬时布尔值,不带时间维度;而预取需要的是“即将进入”的预测窗口(200–400ms) - 移动端滚动快,
isIntersecting可能只维持几帧,来不及完成fetch+img.decode()+ 赋值src
rootMargin 是唯一可控的“时间偏移量”
rootMargin 不是像素缓冲区,它是你对浏览器说:“请把视口逻辑上向下/向右扩展 N px,让我在真实可见前就拿到回调”。它才是预取调度的真正杠杆。
- 桌面端常用
"300px",对应中速滚动下约 300–400ms 预留时间 - 中速滚动手机建议
"150px";三折叠设备(如 Mate XT 的 G 态)需动态计算:${window.innerHeight * 0.3}px - 慎用负值:
"-50px"会显著缩小触发范围,容易漏掉快速滚动场景 - 横向滚动容器要配
rootMargin: "0px 150px",否则只在垂直方向生效
避免并发爆炸和重复请求的三个硬约束
模块化意味着同一张图可能被多个组件引用、同一 URL 可能在不同模块中出现多次。不做控制,fetch 会瞬间打满连接池。
- 用
WeakMap缓存每个 DOM 元素的预取状态:const prefetchState = new WeakMap(),key 是entry.target,value 是{ url, promise, timestamp } - 全局 URL 去重用
Map:const urlToPromise = new Map(),相同dataset.src只发一次fetch - 限制并发数:内部用
Promise.allSettled(queue.slice(0, 3)),最多同时预取 3 个资源,防止带宽抢占关键首屏资源
让预取“感知用户意图”而不是“响应像素”
纯静态 rootMargin 在快速滚动、轮播图交互、放大镜悬停等场景下会严重误判。必须引入轻量行为信号。
- 监听
scroll仅作趋势采样(节流到 60ms),计算最近 300ms 的deltaY符号与幅度;连续两次deltaY > 0→ 动态加大rootMargin到"400px";向上滚动则收窄至"100px" - 对交互密集模块(如轮播图容器),监听
mouseenter,立即触发其下所有[data-prefetch]元素的预取,跳过 observer 状态判断 - 预取后务必调用
img.decode(),等解码完成再赋值src,否则赋值瞬间仍会触发主线程解码阻塞
最常被忽略的一点:预取不是“越早越好”,而是“刚好够用”。过度预取会挤占 LCP 关键资源带宽,反而拖慢首屏。真正有效的模块化预取框架,核心不在 Observer 本身,而在对 rootMargin 的动态校准、对请求生命周期的精细控制、以及对用户滚动意图的轻量建模。










