说到图片懒加载,不少人的第一反应还是监听scroll事件配合getBoundingClientRect来判断元素位置。这种方案能用,但存在明显短板:每次滚动都会触发回调,getBoundingClientRect又会强制浏览器重排,图片一多页面就会明显卡顿。IntersectionObserver正是为了解决这类交叉观察需求而生的浏览器原生API,它把监听工作交给了浏览器内部的任务调度,性能开销几乎可以忽略。这篇文章就围绕它来实现一个通用的图片懒加载能力。

一、为什么scroll监听方案会被淘汰
传统的scroll监听方案大致是这样的:给window绑定scroll事件,在回调里遍历所有待加载的图片,逐个调用getBoundingClientRect获取位置,判断其是否进入视口范围。逻辑本身不复杂,问题出在执行成本上。scroll事件在移动端滚动时每秒可能触发几十次,如果不做节流,回调函数会疯狂执行。
更麻烦的是getBoundingClientRect的调用会触发强制同步布局。浏览器本来会把DOM计算和绘制合并批处理,你频繁读取元素位置,它就被迫中断流水线立刻计算,这正是滚动掉帧的常见元凶。图片数量少的时候感知不明显,一旦列表页有几百张图,滚动流畅度会急剧下降。
IntersectionObserver把这部分工作下沉到浏览器内核。观察者只在元素与根容器的交叉状态发生真实变化时才执行回调,且执行时机由浏览器统一调度,通常在渲染帧的空闲阶段,天然避开了布局抖动问题。开发者只需要关心业务逻辑,性能问题交给底层处理。
二、IntersectionObserver核心机制解析
先看构造函数的基本形态:
// 创建一个交叉观察器实例
const observer = new IntersectionObserver(callback, options);
// callback 会在被观察元素的交叉状态变化时触发
// entries 是一个数组,包含所有发生变化的观察条目
const callback = (entries, observer) => {
entries.forEach(entry => {
// entry.isIntersecting 表示元素当前是否与根交叉
// entry.intersectionRatio 表示交叉区域占元素总面积的比例
// entry.target 是被观察的DOM元素本身
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img); // 加载完成后停止观察,释放资源
}
});
};options里有三个关键参数需要理解透。root指定根元素,默认是浏览器视口,如果你要做的是某个内部滚动容器的懒加载,就把这个容器的DOM传进去。rootMargin允许你在根元素外围扩展一圈虚拟边距,格式和CSS的margin一致,比如rootMargin: '200px 0px'表示元素距离视口上下边缘还有200像素时就提前触发加载,这是提升体验的关键参数,用户滚动到图片时图片往往已经加载完毕。
threshold控制回调触发的时机,取值范围0到1,也可以是数组。设为0表示元素只要有一个像素进入视口就触发,设为0.5则要求一半面积可见。做图片懒加载时通常用默认值0就够了,如果要实现滚动进度条之类的功能,就需要传一个细分的数组,比如[0, 0.25, 0.5, 0.75, 1]。
三、实现IntersectionObserver2Image懒加载组件
下面封装一个开箱即用的懒加载函数,命名为intersectionObserver2Image,它接受一个图片选择器和配置项,内部处理了占位、加载失败重试和观察器回收。
/**
* 基于 IntersectionObserver 的图片懒加载
* @param {string} selector - 图片元素选择器
* @param {object} config - 配置项
*/
function intersectionObserver2Image(selector, config = {}) {
const defaultConfig = {
root: null, // 根元素,null表示视口
rootMargin: '300px 0px', // 提前300px预加载
threshold: 0,
attr: 'data-src', // 真实图片地址存放的属性
retry: 2 // 加载失败重试次数
};
const opts = Object.assign({}, defaultConfig, config);
const images = document.querySelectorAll(selector);
if (!('IntersectionObserver' in window)) {
// 降级处理:不支持时直接加载全部图片
images.forEach(img => { img.src = img.dataset.src; });
return null;
}
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const img = entry.target;
obs.unobserve(img); // 先停止观察,避免重复触发
let retryCount = 0;
const load = () => {
img.src = img.dataset.src;
img.onerror = () => {
if (retryCount < opts.retry) {
retryCount++;
setTimeout(load, 500 * retryCount); // 指数退避重试
} else {
img.src = 'https://ipipp.com/fallback.png';
}
};
};
load();
});
}, {
root: opts.root,
rootMargin: opts.rootMargin,
threshold: opts.threshold
});
images.forEach(img => observer.observe(img));
return observer;
}
// 页面中使用
document.addEventListener('DOMContentLoaded', () => {
intersectionObserver2Image('img.lazy');
});对应的HTML结构需要注意一点:真实的图片地址不能写在src属性里,否则浏览器会立刻发起请求,懒加载就失去意义了。正确做法是放在自定义属性中,src只放一张极小的占位图或干脆留空配合CSS背景色。
<div class="list">
<img class="lazy" data-src="https://ipipp.com/photo/1.jpg"
src="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
alt="示例图片" width="400" height="300"/>
<img class="lazy" data-src="https://ipipp.com/photo/2.jpg"
src="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
alt="示例图片" width="400" height="300"/>
</div>这里有个容易忽略的细节值得强调:img标签务必写上width和height属性。图片在加载完成前浏览器不知道它占多大空间,如果没预留尺寸,加载完成后会引起布局塌陷和跳动,影响累积布局偏移指标,对页面性能评分有直接影响。占位用的1像素透明GIF以Base64形式内联,不产生额外请求,是目前比较通用的做法。
四、用Node.js搭建本地验证环境
写完前端逻辑,需要一个能跑起来的环境验证效果。用Node.js起一个静态文件服务非常简单,把懒加载页面和模拟图片数据都放到本地服务上测试,比直接双击打开HTML文件更接近真实场景,因为file协议下部分浏览器行为会与http环境不一致。
const http = require('http');
const fs = require('fs');
const path = require('path');
const server = http.createServer((req, res) => {
let filePath = path.join(__dirname, req.url === '/' ? 'index.html' : req.url);
const ext = path.extname(filePath);
const types = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript',
'.css': 'text/css',
'.jpg': 'image/jpeg',
'.png': 'image/png'
};
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not Found');
return;
}
res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream' });
res.end(data);
});
});
server.listen(3000, () => {
console.log('服务已启动: http://127.0.0.1:3000');
});启动后在浏览器打开地址,打开开发者工具的Network面板,把页面滚动到不同位置,可以清楚看到图片请求只在元素接近视口时才发出,rootMargin设置的300像素提前量也能直观观察到。验证懒加载效果时建议勾选网络面板的Disable cache,否则图片命中缓存后不好判断加载时机。
五、兼容性与生产环境的补充建议
IntersectionObserver在现代浏览器中支持度已经相当好,主流的Chrome、Firefox、Safari、Edge都原生支持,但在一些老旧的移动端Webview里仍有缺失。生产环境建议做能力检测,不支持时降级为直接加载,上文代码中已经处理了这种情况。如果对旧设备覆盖率有硬性要求,也可以引入官方提供的polyfill,通过npm安装后在入口处引入即可,不过polyfill内部依然是基于scroll监听模拟的,性能优势会打折扣。
另一个实践建议是处理好动态内容。如果你的页面是无限滚动列表,新插入的图片节点不会自动被观察,需要在插入DOM后手动调用observer.observe(newImg)。反过来,列表项被移除时要调用unobserve,SPA应用切换路由时如果观察器不再使用,记得调用observer.disconnect()彻底断开,避免内存泄漏。
整体来看,IntersectionObserver方案相比scroll监听在性能、代码简洁度、可维护性三方面都有明显优势,核心逻辑十来行就能完成,剩下的都是体验层面的细节打磨。把预加载边距、失败重试、尺寸预留这几个点处理好,一个生产可用的图片懒加载能力就成型了,同样的思路还可以迁移到视频预加载、埋点曝光统计、无限滚动分页等场景。
IntersectionObserver图片懒加载Node.js修改时间:2026-09-16 19:36:51