不能只靠 isIntersecting 判断,必须用 rootMargin 提前触发、滚动趋势动态调参、WeakMap 去重缓存;因 threshold=0 仅表示露出1px即回调,无实际提前量,无法覆盖 fetch+decode 平均180–320ms耗时与移动端800px/s滚动速度的时间缺口。

直接说结论:不能只靠 isIntersecting 做判断,必须用 rootMargin 提前触发 + 滚动趋势动态调参 + WeakMap 去重缓存,否则所谓“感知式”只是伪命题。
为什么 threshold=0 无法支撑预加载
很多人以为把 threshold 设为 0 就能“提前一点”,其实不是。它只表示“哪怕露出 1px 就回调”,但此时元素离视口真实距离可能是 0px —— 完全没有预留网络、解码、渲染的时间窗口。实测中,图片从 fetch 到 img.decode() 完成平均耗时 180–320ms,而用户滚动速度在移动端常达 800px/s。这意味着:等 isIntersecting 变 true 才开始 fetch,大概率已经卡在视口边缘了。
真正可控的提前量只有 rootMargin。它本质是把检测边界往外推,比如设 rootMargin: "300px",等于告诉浏览器:“别等它进视口,离顶部还有 300px 时就通知我”。
- 桌面端可固定用
"300px" - 中速滚动手机建议
"150px"(适配常见 DPR=2/3 设备) - 三折叠设备(如 Mate XT G 态)需动态计算:
${window.innerHeight * 0.3}px,否则展开态下预取失效
如何避免重复请求和资源竞争
一个组件可能被多次 observe,一张图可能被多个模块共用 data-src,不加控制就会并发拉同一张图 5 次,触发 CDN 限流或浪费带宽。
必须分两层去重:
- 单个 DOM 元素维度:用
WeakMap缓存其预取状态,key 是 element,value 是{ url, promise, timestamp };再次 observe 同一元素时,直接返回已有的promise - 资源 URL 维度:用全局
Map存url → Promise,所有模块对同一dataset.src的 fetch 都复用同一个 promise - 额外加一层并发限制:内部用
Promise.allSettled(queue.slice(0, 3)),确保最多同时预取 3 个资源
怎么让预取真正“感知”用户意图
纯像素级触发仍是机械响应。真正的“感知”要结合滚动行为预测 —— 不是看“在哪”,而是看“往哪去、多快”。
做法很简单:单独节流监听 scroll(仅用于趋势判断,不参与加载逻辑),每 100ms 记一次 window.scrollY,算最近两次的 deltaY 符号和幅度:
- 连续两次
deltaY > 0(向下滚动)→ 动态加大rootMargin到"400px",扩大预取范围 - 连续两次
deltaY (向上滚动)→ 收窄为 <code>"100px",避免误触发下方资源 - 监听到
mouseenter(如轮播图、放大镜区域)→ 立即触发该区域所有data-pre元素的预取,跳过等待
这个逻辑必须和 IntersectionObserver 的回调完全解耦,否则会污染核心观察链路。
img.src 赋值前必须等 decode() 完成
很多实现直接在 fetch 完就赋 img.src,结果出现“闪白”或“先占位后渲染”。这是因为浏览器还没完成图像解码,赋值后立即渲染会 fallback 到空帧。
正确顺序是:
- fetch 图片 blob
- 用
URL.createObjectURL(blob)创建临时 URL - 新建
Image()实例,src设为此 URL - 调用
img.decode(),等 promise resolve 后再把该 URL 赋给真实 DOM 的img.src
这一步不能省,尤其在低端 Android 设备上,decode() 耗时可能超 200ms,跳过它,“预取”就只剩心理安慰。
最易被忽略的点是:三折叠设备屏幕高度突变时,rootMargin 若没做动态重算,预取窗口会错位;还有 WeakMap 缓存未在元素 unobserve 时清理,长期运行会导致内存缓慢增长。

















