Web 平台 API / 响应式图片 · 让浏览器挑对那一张 / loading:懒加载的提前量 待审核 7 / 10
loading="lazy" · scroll margin

loading:懒加载的提前量

一篇长文里的几十张配图,读者多半只看得到前几张。浏览器默认把它们全部下载,首屏还没画完,带宽已经分给了屏幕下方三千像素处的图。懒加载把这些请求推迟到「用得上」的时候。在 loading 属性出现之前,这件事由脚本完成:把 URL 藏进 data-src,用 IntersectionObserver 监视,进入视口附近再写回 src。

1 · 属性语义

loading 只有两个值。eager 是缺省值,也是无效值的回退,图片照常立即请求;lazy 让浏览器推迟请求,直到图片进入视口附近。Chrome 77(2019-09)、Firefox 75(2020-04)、Safari 15.4(2022-03)先后支持(兼容性据 MDN BCD,核对于 2026-09),不支持的浏览器忽略属性,图片按 eager 加载,不会因此缺图。

规范里「推迟到何时」的机制就是一个 IntersectionObserver:每个文档有一个 lazy load intersection observer,以一个 lazy load scroll margin 作为 scrollMargin,图片与扩展后的视口相交即恢复加载。这个边距是 implementation-defined,规范只给建议:设一个在正常滚动下多数图片能提前到达的最小值,快速滚动的设备、较慢的网络应当加大。

还有一条隐私规定:页面禁用脚本时,loading="lazy" 不生效,图片按 eager 加载。否则站点可以在 HTML 里等距排布图片,从服务器日志里「哪些图在何时被请求」推算出用户的滚动位置,而禁用脚本的用户本来以为自己没有被这样观察。

2 · 提前量的实测

用一列等距排布的懒加载图片测 Chrome 154(桌面、DPR 1、网络无限制)。文档级滚动下,视口高 900px 时,顶边位于 2140px 的图立即加载、位于 2150px 的不加载,纵向提前量正好是 1250px。横向溢出的文档里,视口宽 1280px,左边位于 1900px 的图加载、1905px 的不加载,横向提前量是 625px,恰好一半(核对于 2026-09)。

嵌套滚动容器沿用同一组边距。规范用的是 scrollMargin 而非 rootMargin,前者作用于路径上每一个滚动容器的裁剪矩形,所以一个只有 200px 高的 overflow: auto 容器里,图片同样在距容器可视边缘约 1250px 处开始加载;横向轮播也是 625px。

图 2-1 · 嵌套滚动容器中 40 张等距懒加载图的加载时刻与加载时距可视边缘的距离。可切换滚动方向观察纵横两个提前量,切到「不写尺寸」观察全部图片在第一轮同时加载。

在图 2-1 里慢速滚动,纵向读数稳定在 1200px 出头、横向在 600px 出头,差额来自每次滚动的步长:图片在越过边界的那一步被发现,此时已经进入边界若干像素。

Chrome 的公开文档称网络较慢时提前量增加到 2500px。作者尝试用 DevTools 的 Slow 3G 限速复现:页面内 navigator.connection.effectiveType 读到 2g,但提前量仍是 1250px;刷新页面后 effectiveType 又读回 4g,提前量不变。另一个意外来自给本系列写 e2e 时:Playwright 自带的 headless shell(Chrome 153)里,同一页的提前量是纵向 3000px、横向 1500px,而页面读到的 effectiveType 同样是 4g。横纵之比两边都是 2:1,绝对值却差了一倍多。作者的推断是提前量取自 Chrome 内部的网络质量估计,这个估计既不等于页面可读的 effectiveType,也不受 DevTools 限速影响,headless shell 里它可能处于「未知」一档。本页无法从页面内部验证这个推断,只能如实记下两组读数(核对于 2026-09)。

3 · 懒加载图必须有尺寸

懒加载按图片盒子的位置判断是否进入提前量,盒子在哪取决于图片下载前的尺寸。不写 width / height 的图下载前高度为 0(或者只有 alt 文本的一行),一整列图片叠在同一个位置,第一轮检查就全部落在视口附近。图 2-1 的「不写尺寸」一档里,40 张图在初始化时全部开始下载,懒加载等于没写。

写了尺寸之后,每张图按比例占好位置,只有真正接近视口的那些会被请求。这是 尺寸与占位 一页的结论在懒加载上的又一个理由:尺寸不仅防止 CLS,也是懒加载能生效的前提。

4 · 属性写入的时机

用脚本创建图片时,loading 必须和 src 在同一个同步任务里写完。规范的 update the image data 算法在属性变化时先把后续步骤排进一个 microtask,是否懒加载在那个 microtask 里才判断。实测先写 src、紧接着同步写 loading = 'lazy',放在 15000px 处的图没有加载;中间隔一个 await Promise.resolve() 再写 loading,图片立即加载,懒加载失效。

另一个坑来自预加载的老写法 new Image()。给未插入文档的 Image 对象设 loading = 'lazy',它永远不会加载——不在文档里的元素永远不会与视口相交。new Image().src = url 这种预热缓存的写法只能配 eager。

警示 · 首屏图片不要写 loading="lazy"。懒加载图的请求要等布局完成、observer 回调执行之后才发出,即便它就在第一屏:preload scanner 在解析 HTML 时就能发现 eager 图并提前请求,懒加载图则要等到布局之后,首屏主图的请求因此排在样式表、字体与脚本之后,LCP 相应推迟。常见的做法是只给前 N 张以外的图加 lazy,N 按首屏能容纳的图片数估计,宁可少懒一张。

5 · 与脚本方案的对照

data-src 加 IntersectionObserver 的脚本方案并非一无是处:它可以自定义提前量、可以在弱网下彻底不加载、可以配合占位模糊图做渐显。代价是脚本执行前图片完全不可见,禁用脚本或脚本出错时整页没有图,搜索引擎的爬虫也要执行脚本才能发现图片 URL。

原生 loading="lazy" 把这些代价都消除了:URL 始终写在 src / srcset 里,脚本失效时退化为 eager。对大多数页面,原生属性是默认选择;需要精细控制提前量或渐显效果时再考虑脚本,而且脚本方案应当以原生属性为基础,只在其上叠加表现层的效果。