怎样手写图片预加载和懒加载?
预加载是提前创建 Image 对象并设置 src,浏览器下载后会放进缓存,真正显示时就不用再等;用 onload、onerror 把它包成 Promise,就能等一组图片都加载完再展示。懒加载反过来,先把真实地址放在 data-src 里,用 IntersectionObserver 监听图片进入视口后再把地址赋给 src,加载后取消监听。现在可以优先用 img 的 loading="lazy",首屏图片不要懒加载。
简答
两个需求方向相反。预加载是“现在还用不到,先下载好”,比如轮播图的下一张、游戏开始前的素材;懒加载是“现在用不到,先别下载”,比如长列表里滚动到很下面才看得到的图片。
预加载用
new Image()创建一个图片对象,给它设置src,浏览器就会开始下载。它不需要插入页面。用onload和onerror把加载过程包成 Promise,再配合Promise.all,就能等所有素材都准备好再开始。懒加载先把真实地址写在
data-src上,src先不填。用IntersectionObserver监听这些图片,进入视口时把data-src赋给src,然后调用unobserve不再监听。现在浏览器原生支持<img loading="lazy">,没有特殊需求时优先用它。要注意,首屏图片不能懒加载,否则请求会被推迟,最大内容绘制变慢;懒加载的图片要写好宽高,避免加载后撑开布局。
预加载:把 Image 包成 Promise
下面的 preloadImage 把创建图片的方式作为参数传进来,浏览器里用默认的 new Image()。为了在 Node 里运行,示例传入一个用定时器模拟加载的假图片类,地址里带 bad 的模拟加载失败。
function preloadImage(src, createImage = () => new Image()) {
return new Promise((resolve, reject) => {
const image = createImage();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`图片加载失败:${src}`));
// 先绑定事件再设置 src,加载完成时处理函数一定已经就位。
image.src = src;
});
}
class FakeImage {
set src(value) {
this._src = value;
setTimeout(() => (value.includes('bad') ? this.onerror() : this.onload()), 10);
}
get src() {
return this._src;
}
}
const createFake = () => new FakeImage();
Promise.all(['/a.png', '/b.png'].map((src) => preloadImage(src, createFake))).then((images) => {
console.log(images.map((image) => image.src).join(',')); // /a.png,/b.png
});
Promise.allSettled(['/a.png', '/bad.png'].map((src) => preloadImage(src, createFake))).then(
(results) => {
console.log(results.map((result) => result.status).join(',')); // fulfilled,rejected
},
);
为什么第二组用 Promise.allSettled?Promise.all 只要有一张失败,整体就失败了。如果一张素材失败不影响开始,比如只是背景图,就用 allSettled 拿到每一张的结果,再决定要不要用占位图。几个方法的区别见《Promise.all、allSettled、race 和 any 如何选择?》。
只想让浏览器提前下载某张图,也可以在 HTML 里写 <link rel="preload" as="image" href="...">,见《dns-prefetch、preconnect 和 preload 应该怎样选择?》。
懒加载:用 IntersectionObserver
IntersectionObserver 的作用是:告诉它要看哪些元素,元素和视口的相交情况变化时,它会调用回调。以前的做法是监听 scroll 事件,在回调里对每张图调用 getBoundingClientRect 算位置,这些计算都在主线程上反复执行;用 IntersectionObserver 就不用自己算了。下面是浏览器里的写法:
function lazyLoadImages(selector = 'img[data-src]') {
const images = document.querySelectorAll(selector);
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const image = entry.target;
image.src = image.dataset.src;
image.removeAttribute('data-src');
// 已经开始加载的图片不再需要监听。
observer.unobserve(image);
}
},
// 提前 200px 开始加载,用户滚到时图片多半已经准备好。
{ rootMargin: '200px 0px' },
);
images.forEach((image) => observer.observe(image));
return () => observer.disconnect();
}
对应的 HTML 是 <img data-src="/photo.jpg" width="400" height="300" alt="...">。entry.isIntersecting 为 true 表示元素进入了观察区域,rootMargin 把观察区域往外扩了 200 像素,所以图片在离视口还有一段距离时就开始加载。返回的函数调用 disconnect,页面切走时可以停止所有监听。
原生 loading="lazy" 能代替手写吗
大多数情况下可以。<img loading="lazy"> 的含义是,推迟加载,直到图片离视口达到浏览器计算出的距离。浏览器自己决定提前多少开始加载,不需要写 JavaScript。MDN 还提到,禁用 JavaScript 时浏览器不会推迟加载,这是为了防止网站用图片请求追踪用户的滚动位置。
需要自己控制时才手写,比如要精确设置提前量、加载完要加淡入效果,或者懒加载的不是 <img> 而是背景图。
不管哪种方式,MDN 都建议给懒加载的图片写明宽高。没加载的图片宽高是 0,加载以后突然撑开,正在看的内容会被挤下去。首屏图片为什么不能懒加载,见《懒加载为什么有时反而让首屏和首次交互变慢?》。
常见追问
预加载的图片没有插入页面,下载了有用吗?
有用。浏览器把下载好的图片放进缓存,之后页面上用到同一个地址,就会直接从缓存里拿,不用重新下载。前提是响应头允许缓存。
图片下载好了,插入页面时还会卡一下吗?
大图可能会,因为图片还要解码。可以在插入前调用
image.decode(),它返回一个 Promise,图片解码完成、可以安全插入页面时才完成。