起因是我给图库页换懒加载方案,随手搜了下教程。结果每篇都信誓旦旦说自己的写法最好,还互相瞧不上。我也不懂这有什么好吵的,索性把能搜到的 9 种写法全部跑了一遍。
先说结果,免得你浪费五分钟:有一种写法,120 张图滚到底只加载出来 4 张,剩下 116 张从头到尾就没发起过请求。 更气人的是,这写法在教程里出镜率还不低。
我是怎么较这个劲的
120 张图,每张 67KB,本地服务,禁缓存。每张图从「进入视口」到「加载完成」的延迟逐张记录,服务端同时记请求时间戳。
滚法统一:每帧滚 24 像素,一口气滚到底,模拟你刷长列表「不看了,划走」的速度。每种写法跑三轮,三轮数据差不到 1 毫秒,稳得我一度怀疑自己忘了改代码。
九种写法全列出来:不懒加载、原生 loading="lazy"、原生加 fetchpriority=low、IntersectionObserver 三档预载(0px / 200px / 1000px)、裸 scroll 监听、scroll+rAF 合帧、scroll+防抖 250ms。
| 写法 | 首屏请求 | 提前就绪 | 图片就位延迟中位 |
|---|---|---|---|
| 不懒加载 | 120 | 提前 10 秒 | 0 |
| 原生 lazy | 7 | 119 张提前 851ms | 15ms |
| 原生+low 优先级 | 7 | 提前 852ms | 15ms |
| IO 0px | 2 | 0 | 15ms |
| IO 200px | 3 | 提前 119ms | --- |
| IO 1000px | 6 | 提前 685ms | --- |
| scroll 裸监听 | 2 | 0 | 32ms |
| scroll+rAF | 2 | 0 | 14ms |
| scroll+防抖 250ms | 1 | --- | 615ms |
防抖懒加载,白屏神器
看最后一行。615 毫秒中位延迟还不是最惨的,最惨的是那 4 张。
防抖的机制是每次触发就重置定时器。用户只要还在滚,scroll 事件就一个接一个来,那个 250ms 定时器被无限期推倒重来,加载检查一次都没执行。等用户滚到底停下来了,检查终于跑了------但视口里只剩最后那几张图。
对,防抖。就是搜索框「停止输入 300ms 再请求」那个防抖。防抖本身没错,错的是把它贴在滚动事件上。这相当于把外卖小哥防抖了:你越催,他越不动,等你饿死了他才拎着饭出门。
对照组 scroll+rAF 合帧,就位延迟 14ms。44 倍差距,同一台机器,同一批图片,唯一区别就是那个 250ms 的定时器。
要是你代码库里现在还有 scroll + debounce 的懒加载,别修了,直接删。这玩意唯一的优点是省流量------图片压根不加载,能不省吗。
手写党输给了浏览器自带的一行
不写防抖的手写方案,成绩都正常,问题在另一件事上。
原生 loading="lazy" 的预载距离,Chrome 官方口径约 1250px。实测数据也对得上:原生写法的图片平均提前 851ms 就绪。
而手写 IntersectionObserver 的教程,十个里有九个教你写 rootMargin: 200px。实测这配置只提前 119ms。想追平原生,得把预载拉到 1000px,实测提前 685ms。
翻译一下:你吭哧吭哧写二十行 JS 维护一个 IntersectionObserver,用户反而比浏览器自带的一行属性多等半秒。手写党委屈吗?委屈。但数据不撒谎。
顺手辟两个谣
「fetchpriority=low 能优化懒加载」------我加了跑一轮,数据跟没加逐项一样。这属性是给首屏大图抢带宽用的,不是给懒加载列的。别惦记了。
「裸 scroll 监听会卡」 ------120 张图这个规模下,九种写法帧率中位全部 17ms,长任务全部 0 次。裸监听全量扫 getBoundingClientRect 都不卡,只是就位延迟比 IO 慢一倍。真要吐槽它,槽点是慢,不是卡。
所以懒加载到底优化了什么?看首屏那列:不懒加载的页面打开瞬间抢光 120 张图,懒加载系只拿 1 到 7 张。它省的是流量,是你服务器的带宽账单,不是帧率。
有人会说
「headless Chrome 的数据能算数吗?」帧率那条在真机上未必复现,这个我认,不争。但防抖翻车是机制问题,预载距离是浏览器行为,这两条跟跑在哪台机器上没关系。
「防抖是你故意拿来凑数的吧?」我倒是想凑数,可惜它翻得最狠。而且这个场景一点也不罕见:长列表快刷、锚点跳转、按一下 End 键,全是一口气滚到底。防抖懒加载慢速浏览时能凑合,一快就死,测试用例稍微一慢它就隐身------这才是它阴的地方。
正确写法,直接抄
html
<!-- 首屏图:eager + 高优先级,LCP 别懒加载 -->
<img src="hero.jpg" loading="eager" fetchpriority="high" width="800" height="600">
<!-- 其余全部:一行原生,预载 1250px,比多数手写都激进 -->
<img src="a.jpg" loading="lazy" width="300" height="225">
非要手写 IO 的话(要配骨架屏、淡入之类的),把 rootMargin 至少给到 200px,想跟原生打平就 1000px:
js
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.src = e.target.dataset.src;
io.unobserve(e.target);
}
}
}, { rootMargin: '1000px 0px' });
document.querySelectorAll('img[data-src]').forEach(img => io.observe(img));
最后统计一下:你们项目里现在还有 scroll+debounce 的懒加载吗?评论区举个手,我看看有多少人跟我一样是从这个坑里爬出来的。