跳过正文
  1. 前端工程体验优化实战/

万物皆可懒加载:通用资源懒加载工具库

hujiacheng
作者
hujiacheng
Front-end Developer / Strive To Become Better
目录
图片与懒加载 - 这篇文章属于一个选集。
§ 4: 本文

## 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.jsexport导出的方法、属性,就可以通过window['LazyLoadAll']方便地开发调试:

2. 实现初始化逻辑和class LazyLoadAll
#

编写代码逻辑的第一步,当然是工具库的初始化逻辑。

我们基于用户的需求和使用方式来确定初始化逻辑,从用户角度出发,设计工具库的API。

用户使用懒加载库,核心的需求是:简单易用地实现资源懒加载。

具体到使用方式,预计主要会有2种:

  1. 为已经存在的DOM元素,例如SSR直接响应的DOM元素,增加懒加载逻辑。
  2. 为新增的元素,例如组件动态渲染生成的DOM元素,增加懒加载逻辑。

所以我们分别提供2个API,满足用户的以上用法:

  1. initLazyloadAll(options):初始化通用懒加载库,同时为已经存在的DOM元素,增加懒加载逻辑。
  2. 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个方法:

  1. init(options):初始化方法,用于监听scroll事件、初始化Intersection Observer API等逻辑。
  2. addTarget(ele):新增懒加载监视目标元素方法,用于对目标元素开始懒加载监视。
  3. 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个方法,逻辑分别是:

  1. init(options):初始化Intersection Observer API实例。同时指定回调函数,当目标元素,满足懒加载条件时(entry.isIntersecting === true),调用onIntersectCb()方法,执行真正的加载资源逻辑。
  2. addTarget(ele):对懒加载监视目标元素调用observe(ele)方法,开始监视。
  3. 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个方法,逻辑分别是:

  1. init(options):开始监听页面scroll事件,同时指定回调函数,当目标元素,满足懒加载条件时(topPos < window.innerHeight),调用onIntersectCb()方法,执行真正的加载资源逻辑。并添加节流处理throttle(),节省运行时开销。
  2. addTarget(ele):将懒加载监视目标元素,添加到类实例的this.targetEles属性,开始监视。
  3. removeTarget(ele):对懒加载监视目标元素,移除出类实例的this.targetEles属性,停止监视。

有了IntersectionLazyLoaderScrollLazyLoader2个类,帮我们处理监视懒加载目标、判断是否执行加载资源。

我们接下来就要实现核心执行加载资源逻辑的onIntersectCb()方法了。

5. 分情况处理各类元素
#

执行加载资源逻辑的onIntersectCb()方法主要功能是目标:

  • 获取提前在懒加载目标元素上声明的data-src属性值。
  • data-src属性值设为src属性值,从而真正加载目标资源。

我们实现的是通用资源懒加载工具库,所以计划兼容以下5类资源的懒加载逻辑:

元素加载资源逻辑注意事项
<img>src属性设置为图片URL
<video>及其附带的<source>子元素<video>触发加载资源有2种用法:
1. <video>src属性设置为视频URL
1. <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类加载逻辑:

  1. loadSrc:加载<img><iframe>资源
  2. loadVideo:加载<video>及其附带的<source>子元素资源
  3. loadPicture:加载<picture>及其附带的<source>子元素资源
  4. 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更新,自动应用懒加载的逻辑。

示例代码请参考:

lazyload-all 在线运行DEMO

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指标的目标元素可以通过以下方式获取:

  1. Devtool的Performance Insight检测获取。
  2. 或用第1节《数据驱动、指标先行》介绍的web-vitalsonLCPAPI,从其返回数据中的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指标,加快页面初始化加载。

图片与懒加载 - 这篇文章属于一个选集。
§ 4: 本文

相关文章