## 1. 封装通用资源懒加载库
接下来,我们就将基于上述介绍的基于scroll事件和基于Intersection Observer API,2类懒加载方案,封装一套懒加载库,实现通用的资源懒加载库,降低使用成本,提高开发体验,同时保证生产环境可用的浏览器兼容性。
通用资源懒加载库 LazyloadAll GitHub 仓库链接:https://github.com/JuniorTour/lazyload-all?tab=readme-ov-file#lazyload-all

1. 初始化仓库#
首先我们初始化懒加载库的代码仓库。
要想顺利高效地开发一个工具库,有许多基础设施需要配置,例如:ESLint、Prettier、打包编译工具、开发调试环境、自动化测试和持续集成CI等等,需要耗费大量时间精力。
所以笔者推荐使用 jslib-base 工具库模板,快速生成一套基础设施,让我们能直接投入到源码编写中,为我们节省时间精力。
使用这个模板工具非常简单,只需要运行命令:
npx @js-lib/cli new lib-name选择自己需要的语言、环境配置,即可自动生成所有工具库开发和发布的基础设施。
我们的示例的工具库名称是lazyload-all,运行上述命令时替换lib-name部分,随后的命令行交互中,选择JS语言,最终生成的主要目录结构如下:
.
├── demo 使用DEMO
├── dist 编译产出代码
├── src 源代码目录
├── README.md初始化完成后,我们直接打开src/index.js就可开始编写工具库的代码逻辑了。
其他的语法检查、打包编译甚至GitHub持续集成环境(.github\workflows\ci.yml)都已经为我们配置好了,开箱即用。
另外,为了便于我们在开发环境,改动代码后,自动触发打包,我们再安装一个nodemon库,用于监听文件变化,触发打包命令(npm run build:aio),并在package.json新增对应的调用命令,:
npm install nodemon --save-dev"scripts": {
"dev": "nodemon -e js,ts --watch src --delay 2.5 --exec "npm run build:aio"",
}这样,运行npm run dev后,我们对代码的改动就会自动触发打包命令:npm run build:aio。
同时访问\demo\demo-global.html页面,就可以作为我们的本地开发调试环境,愉快地开始写代码了。
在这个HTML页面中,我们在index.js中export导出的方法、属性,就可以通过window['LazyLoadAll']方便地开发调试:

2. 实现初始化逻辑和class LazyLoadAll#
编写代码逻辑的第一步,当然是工具库的初始化逻辑。
我们基于用户的需求和使用方式来确定初始化逻辑,从用户角度出发,设计工具库的API。
用户使用懒加载库,核心的需求是:简单易用地实现资源懒加载。
具体到使用方式,预计主要会有2种:
- 为已经存在的DOM元素,例如SSR直接响应的DOM元素,增加懒加载逻辑。
- 为新增的元素,例如组件动态渲染生成的DOM元素,增加懒加载逻辑。
所以我们分别提供2个API,满足用户的以上用法:
initLazyloadAll(options):初始化通用懒加载库,同时为已经存在的DOM元素,增加懒加载逻辑。lazyLoadeAllInstance.update():调用已经初始化的懒加载库实例方法update(),为新增的元素,增加懒加载逻辑
用户使用方式示例代码如下:
const lazyLoadeAllInstance = window.LazyLoadAll.initLazyloadAll({
// mode: MODES.scroll,
// mode: MODES.intersectionObserver,
once: true,
});
// 懒加载元素增减后,更新监视目标
lazyLoadeAllInstance.update();接下来,就可以按照实现这2个API的目标,编写代码逻辑了。
首先,在入口文件index.js中,导出initLazyloadAll(options)方法:
// src\index.js
import LazyLoadAll from './LazyLoadAll';
import { onIntersectCb } from './loadHandlers';
import { MODES } from './const';
export function initLazyloadAll(options) {
// debugger;
const lazyLoadeAllInstance = new LazyLoadAll({
onIntersectCb,
once: true,
// mode: MODES.scroll,
mode: MODES.intersectionObserver,
...options,
});
lazyLoadeAllInstance.update();
return lazyLoadeAllInstance;
}新建src\LazyLoadAll.js,以面向对象的模式,创建并导出class LazyLoadAll:
import ScrollLazyLoader from './ScrollLazyloader';
import IntersectionLazyLoader from './IntersectionLazyLoader';
import { MODES, ATTRS } from './const';
export default class LazyLoadAll {
lazyLoader = null;
constructor(options) {
if (options.mode === MODES.scroll) {
this.lazyLoader = new ScrollLazyLoader(options);
} else if (options.mode === MODES.intersectionObserver) {
this.lazyLoader = new IntersectionLazyLoader(options);
}
}
update() {
const lazyloadEles = document.querySelectorAll(`[${ATTRS.dataLaztload}]`);
lazyloadEles.forEach((ele) => {
this.lazyLoader.addTarget(ele);
});
}
}LazyLoadAll类就是我们通用懒加载库的核心逻辑,在这个类中,我们会根据初始化传入的options.mode决定当前使用基于scroll事件,还是基于Intersection Observer API的懒加载实现方案。
它的lazyLoader属性,就是我们基于上述2类懒加载实现方案,编写出的懒加载运行逻辑,主要有3个方法:
init(options):初始化方法,用于监听scroll事件、初始化Intersection Observer API等逻辑。addTarget(ele):新增懒加载监视目标元素方法,用于对目标元素开始懒加载监视。removeTarget(ele):删除懒加载监视目标元素方法,用于在元素懒加载触发后,停止监视。
3. 实现 class IntersectionLazyLoader#
之后,我们先编写基于Intersection Observer API方案的IntersectionLazyLoader类及其3个方法,代码逻辑如下:
// src\IntersectionLazyLoader.js
export default class IntersectionLazyLoader {
observer = null;
constructor(options) {
this.init(options);
}
init({ onIntersectCb, once, ObserverOptions }) {
if (!onIntersectCb) {
console.warn(
`initScrollLazyLoader have falsy onIntersectCb=${onIntersectCb} `,
);
return;
}
if (this.observer) {
return;
}
this.observer = new IntersectionObserver((entries) => {
// 遍历所有观察的元素
entries.forEach((entry) => {
// 如果该元素进入了视口,就执行该元素对应的回调
if (entry.isIntersecting) {
const ele = entry.target;
onIntersectCb(ele);
if (once) {
this.removeTarget(ele);
}
}
});
}, ObserverOptions);
}
addTarget(ele) {
if (!ele) {
return;
}
this.observer.observe(ele);
}
removeTarget(ele) {
if (!ele) {
return;
}
this.observer.unobserve(ele);
}
}IntersectionLazyLoader类的3个方法,逻辑分别是:
init(options):初始化Intersection Observer API实例。同时指定回调函数,当目标元素,满足懒加载条件时(entry.isIntersecting === true),调用onIntersectCb()方法,执行真正的加载资源逻辑。addTarget(ele):对懒加载监视目标元素调用observe(ele)方法,开始监视。removeTarget(ele):对懒加载监视目标元素调用unobserve(ele)方法,停止监视。
4. 实现 class ScrollLazyLoader#
接下来再实现基于监听scroll事件方案的的ScrollLazyLoader类及其3个方法,代码逻辑如下:
// src\ScrollLazyloader.js
function throttle(callback, limit) {
// ... 通用节流方法
}
export default class ScrollLazyLoader {
inited = false;
targetEles = [];
constructor(options) {
this.init(options);
this.inited = true;
}
runLoad({ onIntersectCb, once }) {
this.targetEles.forEach((ele) => {
// 获取图片与视口顶部的相对距离
const topPos = ele.getBoundingClientRect().top;
// 与 视口高度(window.innerHeight)对比,判断是否在视口内
if (topPos < window.innerHeight) {
// debugger;
onIntersectCb(ele);
if (once) {
this.removeTarget(ele);
}
}
});
}
init(options) {
if (!options.onIntersectCb) {
console.warn(
`initScrollLazyLoader have falsy onIntersectCb=${options.onIntersectCb} `,
);
return;
}
if (this.inited) {
return;
}
// 初始化时,运行一次runLoad(),
// 从而实现页面刷新,滚动位置不变时,仍能触发懒加载
this.runLoad(options);
window.addEventListener(
'scroll',
throttle(() => {
this.runLoad(options);
}),
200,
);
}
addTarget(ele) {
if (!ele) {
return;
}
this.targetEles.push(ele);
}
removeTarget(ele) {
if (!ele) {
return;
}
this.targetEles.splice(this.targetEles.indexOf(ele), 1);
}
}ScrollLazyLoader类同样暴露3个方法,逻辑分别是:
init(options):开始监听页面scroll事件,同时指定回调函数,当目标元素,满足懒加载条件时(topPos < window.innerHeight),调用onIntersectCb()方法,执行真正的加载资源逻辑。并添加节流处理throttle(),节省运行时开销。addTarget(ele):将懒加载监视目标元素,添加到类实例的this.targetEles属性,开始监视。removeTarget(ele):对懒加载监视目标元素,移除出类实例的this.targetEles属性,停止监视。
有了IntersectionLazyLoader和ScrollLazyLoader2个类,帮我们处理监视懒加载目标、判断是否执行加载资源。
我们接下来就要实现核心执行加载资源逻辑的onIntersectCb()方法了。
5. 分情况处理各类元素#
执行加载资源逻辑的onIntersectCb()方法主要功能是目标:
- 获取提前在懒加载目标元素上声明的
data-src属性值。 - 将
data-src属性值设为src属性值,从而真正加载目标资源。
我们实现的是通用资源懒加载工具库,所以计划兼容以下5类资源的懒加载逻辑:
| 元素 | 加载资源逻辑 | 注意事项 |
|---|---|---|
<img> | src属性设置为图片URL | – |
<video>及其附带的<source>子元素 | <video>触发加载资源有2种用法:1. <video>的src属性设置为视频URL1. <source>子元素的src属性设置为视频URL | <video>元素<source>子元素的src更新后,必须主动调用<video>元素引用的load()方法,否则,不会触发视频资源加载。 |
<iframe> | src属性设置为页面URL | – |
<picture>及其附带的<source>子元素 | <source>子元素的**srcset**属性设置为图片URL | <source>用的是**srcset**属性,与<video>元素<source>子元素的src属性不同。 |
CSSbackground-image属性 | background-image属性值设置为图片URL | – |
经过梳理分析,这5类元素最终需要4类加载逻辑:
loadSrc:加载<img>和<iframe>资源loadVideo:加载<video>及其附带的<source>子元素资源loadPicture:加载<picture>及其附带的<source>子元素资源loadBgImg:加载CSSbackground-image属性资源
对应的代码逻辑如下:
// src\loadHandlers.js
import { DATA_PROP_PREFIX, ATTRS } from './const';
function loadSources(sourceEles, srcAttrName = ATTRS.dataSrc) {
if (!sourceEles?.length) {
console.warn(`[lazyload-all] loadSource no ele.`);
return;
}
sourceEles.forEach((ele) => {
const srcVal = ele.getAttribute(srcAttrName);
ele.setAttribute(srcAttrName.replace(DATA_PROP_PREFIX, ''), srcVal);
});
}
function loadVideo(videoEle) {
if (!videoEle) {
console.warn(`[lazyload-all] processVideoSource no videoEle.`);
return;
}
const sourceEles = videoEle.querySelectorAll('source');
if (!sourceEles || !sourceEles.length) {
loadSrc(videoEle);
return;
}
loadSources(sourceEles);
// 重要!不然只修改 source && src 不会触发 Video 加载
videoEle.load();
}
function loadBgImg(ele) {
if (!ele) {
return;
}
const bgSrcVal = ele.getAttribute(ATTRS.dataBgSrc);
if (bgSrcVal) {
ele.setAttribute('style', `background-image: url(${bgSrcVal})`);
}
}
function loadSrc(ele) {
const srcAttrName = ATTRS.dataSrc;
const srcVal = ele.getAttribute(srcAttrName);
if (!srcVal) {
console.warn(`[lazyload-all] lazyload ele no src | srcset value.`);
return;
}
ele.setAttribute(srcAttrName.replace(DATA_PROP_PREFIX, ''), srcVal);
ele.setAttribute(ATTRS.dataLaztloaded, true);
}
function loadPicture(ele) {
if (!ele) {
console.warn(`[lazyload-all] loadPicture no ele input.`);
return;
}
const sourceEles = ele.querySelectorAll('source');
if (!sourceEles || !sourceEles.length) {
return;
}
loadSources(sourceEles, ATTRS.dataSrcset);
}
const loadHandlers = {
IMG: loadSrc,
VIDEO: loadVideo,
PICTURE: loadPicture,
IFRAME: loadSrc,
};
export function onIntersectCb(ele) {
if (ele.getAttribute(ATTRS.dataLaztloaded)) {
return;
}
const tag = ele.tagName;
let load = loadHandlers[tag];
// 触发资源加载
if (load) {
load(ele);
} else if (ele.getAttribute(ATTRS.dataBgSrc)) {
loadBgImg(ele);
}
}在src\loadHandlers.js文件中我们会导出执行加载资源逻辑的onIntersectCb(ele),其中会根据目标元素的标签名(ele.tagName)选择上述4类加载逻辑之一执行,触发资源加载。
6. 使用示例#
实际使用时,可以和前端框架结合起来,封装一些组件或hook,便于复用。
例如,和React.js框架结合,利用useEffecthook自动调用lazyLoadeAllInstance.update(),实现DOM更新,自动应用懒加载的逻辑。
示例代码请参考:
function useInitLazyLoadAll(options) {
/*
initLazyloadAll 可选选项:
{
mode: MODES.scroll,
mode: MODES.intersectionObserver,
once: true,
afterLoadCb(ele) {
debugger;
console.log(`After lazyload`, ele);
},
}
*/
const [lazyLoadeAllInstance] = useState(initLazyloadAll(options));
useEffect(() => {
// 懒加载元素增减后,更新监视目标
lazyLoadeAllInstance.update();
}, []);
return lazyLoadeAllInstance;
}
function LazyLoadContainer({ children }) {
useInitLazyLoadAll();
return <>{children}</>;
}2. 验证、量化和评估#
1. 验证#
1. 注意避免懒加载对CLS、LCP的负面影响#
懒加载图片、视频可能对最大内容绘制耗时(LCP)和累计布局变化(CLS)有负面影响。
例如,我们对图片进行懒加载时,如果我们没有给图片设置默认的宽高,那么当图片未加载时,图片元素的宽高就是默认值0px或是alt属性文案的宽高,但是当图片进入视口、图片资源加载后,图片元素的尺寸又会变为图片文件的大小。
这就是典型的意外布局变化(Layout Shift),可能会使得页面中元素的位置突然变化,导致用户操作失误,对用户体验会有显著的负面影响。
再比如,我们懒加载的目标元素,如果是页面中的最大尺寸内容(Largest Content),是LCP指标测量的目标元素,那么对这个元素的应用懒加载,导致其加载时间推迟,就会自然而然地导致LCP指标恶化。
所以,在进行资源懒加载优化时,务必:
- 对懒加载目标元素设置默认的宽高尺寸。
- 避免对LCP指标的目标元素进行懒加载优化。
注:LCP指标的目标元素可以通过以下方式获取:
- Devtool的Performance Insight检测获取。
- 或用第1节《数据驱动、指标先行》介绍的
web-vitals库onLCPAPI,从其返回数据中的entries属性值中获取,示例数据:
2. 复用UA信息指标,确认兼容性目标#
资源懒加载的各类解决方案都和浏览器兼容性有关,所以在实施资源懒加载前,我们可以复用第14节介绍的《各浏览器版本占比》Grafana 图表,确认前端项目当前用户的浏览器版本分布情况,决定在生产环境中使用哪种资源懒加载解决方案。
2. 量化#
1. 新建懒加载触发次数指标#
量化懒加载优化效果的直接手段就是统计懒加载触发了多少次,次数越多,说明懒加载的影响用户量越多、影响范围越大,能直接说明我们的优化收益。
所以我们首先要建立的量化指标就是懒加载触发次数指标。
我们的LazyloadAll 工具库有为我们准备相应的API:afterLoadCb(),通过这个属性指定的函数,将会在懒加载触发后被调用。
我们在afterLoadCb()函数内再次使用我们的数据收集服务接口/counter-metric,上报数据到Grafana,就可以进一步绘制出对应的可视化图表,用来量化懒加载触发次数。
示例代码:
async function report({name, labels, help, sampleRate}) {
// await fetch('http://localhost:4001/counter-metric', ......
}
const lazyLoadeAllInstance = initLazyloadAll({
afterLoadCb(ele) {
report({
name: 'LazyLoadTrigger',
help: 'LazyLoad trigger count',
labels: { type: ele?.tagName },
sampleRate: 0.01, // 采样率应该根据用户总量而定
});
},
});2. 复用加载资源总体积指标#
在前端页面中,并非所有用户都会完整浏览页面的所有内容,所以资源懒加载优化,通常可以让这部分没有被用户浏览的资源不触发加载,进而前端应用加载的资源体积显著减少。
所以量化资源懒加载优化懒加载的另一可用指标就是第5节介绍的加载资源总体积指标,在优化前后观察对比这一指标的变化,就能量化我们的优化效果。
3. 避免LCP和CLS恶化#
资源懒加载也可能导致最大内容绘制耗时(LCP)和累计布局变化(CLS)指标恶化,所以我们在实施资源懒加载时,也应该关注已经建立的LCP和CLS指标状况,避免其出现显著的恶化情况。
3. 评估#
资源懒加载的具体优化效果会因为前端应用中各类资源的数量多少、体积大小有所不同,但是普遍都能对下列2方面产生优化效果:
节省CDN的流量开销:显著减少前端应用加载资源总体积,同时节省用户流量消耗。
用户体验优化:改善首次内容绘制FCP指标,加快页面初始化加载。

