Vue3-Lazyload源码解析

Intersection Observer API

一种异步检测目标元素与祖先元素或 viewport 相交情况变化的方法。

Intersection Observer API 会注册一个回调函数,每当被监视的元素进入或者退出另外一个元素时(或者 viewport ),或者两个元素的相交部分大小发生变化时,该回调方法会被触发执行,浏览器会自行优化元素相交管理。
Vue3-Lazyload源码解析_第1张图片

适用场景

  • 图片懒加载——当图片滚动到可见时才进行加载
  • 内容无限滚动——也就是用户滚动到接近内容底部时直接加载更多,而无需用户操作翻页,给用户一种网页可以无限滚动的错觉
  • 检测广告的曝光情况——为了计算广告收益,需要知道广告元素的曝光情况
  • 在用户看见某个区域时执行任务或播放动画

替代方法

过去,相交检测通常要用到事件监听,并且需要频繁调用 Element.getBoundingClientRect() 方法以获取相关元素的边界信息。事件监听和调用 Element.getBoundingClientRect() 都是在主线程上运行,因此频繁触发、调用可能会造成性能问题。这种检测方法极其怪异且不优雅。

如果为了使用不同业务引用多个第三方库,里面可能都各自实现一套相同的流程,这种情况下的性能是糟糕并且无法优化的

概念和用法

Intersection Observer API 允许你配置一个回调函数,当以下情况发生时会被调用

  • 每当目标(target)元素与设备视窗或者其他指定元素发生交集的时候执行。设备视窗或者其他元素我们称它为根元素或根(root)。
  • Observer 第一次监听目标元素的时候

通常,您需要关注文档最接近的可滚动祖先元素的交集更改,如果元素不是可滚动元素的后代,则默认为设备视窗。如果要观察相对于根(root)元素的交集,请指定根(root)元素为null

目标(target)元素与根(root)元素之间的交叉度是交叉比(intersection ratio)。这是目标(target)元素相对于根(root)的交集百分比的表示,它的取值在0.0和1.0之间。

创建一个 intersection observer

创建一个 IntersectionObserver 对象,并传入相应参数和回调函数,该回调函数将会在目标(target)元素和根(root)元素的交集大小超过阈值(threshold)规定的大小时候被执行。

options

参数 描述
root 指定根(root)元素,用于检查目标的可见性。必须是目标元素的父级元素。如果未指定或者为null,则默认为浏览器视窗
rootMargin 根(root)元素的外边距。如果有指定 root 参数,则 rootMargin 也可以使用百分比来取值。该属性值是用作 root 元素和 target 发生交集时候的计算交集的区域范围,使用该属性可以控制 root 元素每一边的收缩或者扩张。默认值为0
threshold 可以是单一的 number 也可以是 number 数组,target 元素和 root 元素相交程度达到该值的时候 IntersectionObserver 注册的回调函数将会被执行。如果你只是想要探测当 target 元素的在 root 元素中的可见性超过50%的时候,你可以指定该属性值为0.5。如果你想要 target 元素在 root 元素的可见程度每多25%就执行一次回调,那么你可以指定一个数组 [0, 0.25, 0.5, 0.75, 1]。默认值是0 (意味着只要有一个 target 像素出现在 root 元素中,回调函数将会被执行)。该值为1.0含义是当 target 完全出现在 root 元素中时候回调才会被执行。
const options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 1.0
}

const callback =(entries, observer) => {
  entries.forEach(entry => {
    // Each entry describes an intersection change for one observed target element:
    // entry.boundingClientRect
    // entry.intersectionRatio
    // entry.intersectionRect
    // entry.isIntersecting
    // entry.rootBounds
    // entry.target
    // entry.time
  });
};

const observer = new IntersectionObserver(callback, options);

请留意,你注册的回调函数将会在主线程中被执行。所以该函数执行速度要尽可能的快。如果有一些耗时的操作需要执行,建议使用 Window.requestIdleCallback() 方法。

创建一个 observer 后需要给定一个目标元素进行观察。

const target = document.querySelector('#listItem');
observer.observe(target);

Vue3-Lazyload源码解析_第2张图片

交集的计算

容器元素和偏移值

所有区域均被 Intersection Observer API 当做一个矩形看待。如果元素是不规则的图形也将会被看成一个包含元素所有区域的最小矩形,相似的,如果元素发生的交集部分不是一个矩形,那么也会被看作是一个包含他所有交集区域的最小矩形。

容器 (root) 元素既可以是 target 元素祖先元素也可以是指定 null 则使用浏览器视口做为容器(root)。来对目标元素进行相交检测的矩形,它的大小有以下几种情况:

  • 如果隐含 root (值为null) , 就是视窗的矩形大小。
  • 如果有溢出部分, 则是 root 元素的内容 (content) 区域.
  • 否则就是容器元素的矩形边界 (getBoundingClientRect() 方法获取).

rootMargin 的属性值将会做为 margin 偏移值添加到容器 (root) 元素的对应的 margin 位置,并最终形成 root 元素的矩形边界

阈值

IntersectionObserver API 并不会每次在元素的交集发生变化的时候都会执行回调。相反它使用了 thresholds 参数。当你创建一个 observer 的时候,你可以提供一个或者多个 number 类型的数值用来表示 target 元素在 root 元素的可见程序的百分比,然后,API的回调函数只会在元素达到 thresholds 规定的阈值时才会执行。

  • 第一个盒子的 thresholds 包含每个可视百分比
  • 第二个盒子只有唯一的值 [0.5]。
  • 第三个盒子的 thresholds 按10%从0递增(0%, 10%, 20%, etc.)。
  • 最后一个盒子为 [0, 0.25, 0.5, 0.75, 1.0]。

Vue3-Lazyload源码解析_第3张图片

requestIdleCallback

window.requestIdleCallback()方法插入一个函数,这个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout,则有可能为了在超时前执行函数而打乱执行顺序。

你可以在空闲回调函数中调用**requestIdleCallback()**,以便在下一次通过事件循环之前调度另一个回调。

Vue3-Lazyload源码解析_第4张图片

注意:

  • requestAnimationFrame 会请求浏览器在下一次重新渲染之前执行回调函数
  • requestIdleCallback在浏览器空闲时期被调用

自定义事件

CustomEvent

创建一个新的 CustomEvent 对象。

$$ event = new CustomEvent(typeArg, customEventInit); $$

  • typeArg:一个表示 event 名字的字符串
  • customEventInit:
    | 参数 | 描述 |
    | ---------- | ----------------------------------------------------------- |
    | detail | 可选的默认值是 null 的任意类型数据,是一个与 event 相关的值 |
    | bubbles | 一个布尔值,表示该事件能否冒泡 |
    | cancelable | 一个布尔值,表示该事件是否可以取消 |

Vue3-Lazyload源码解析_第5张图片

dispatchEvent

向一个指定的事件目标派发一个事件, 并以合适的顺序同步调用目标元素相关的事件处理函数。标准事件处理规则(包括事件捕获和可选的冒泡过程)同样适用于通过手动的使用dispatchEvent()方法派发的事件。

$$ cancelled = !target.dispatchEvent(event) $$

参数:

  • event 是要被派发的事件对象。
  • target 被用来初始化 事件 和 决定将会触发 目标.

返回值:

  • 当该事件是可取消的(cancelable为true)并且至少一个该事件的 事件处理方法 调用了Event.preventDefault(),则返回值为false;否则返回true。

Vue3-Lazyload源码解析_第6张图片

如果该被派发的事件的事件类型(event's type)在方法调用之前没有被经过初始化被指定,就会抛出一个 UNSPECIFIED_EVENT_TYPE_ERR 异常,或者如果事件类型是null或一个空字符串. event handler 就会抛出未捕获的异常; 这些 event handlers 运行在一个嵌套的调用栈中: 他们会阻塞调用直到他们处理完毕,但是异常不会冒泡。

注意

与浏览器原生事件不同,原生事件是由DOM派发的,并通过event loop异步调用事件处理程序,而dispatchEvent()则是同步调用事件处理程序。在调用dispatchEvent()后,所有监听该事件的事件处理程序将在代码继续前执行并返回。

dispatchEvent()是create-init-dispatch过程的最后一步,用于将事件调度到实现的事件模型中。可以使用Event构造函数来创建事件。

懒加载流程

Vue3-Lazyload源码解析_第7张图片

这是比较常规的实现方式

Vue-lazyload源码解析

入口文件

export const Lazyload = {
  /*
   * install function
   * @param  {App} app
   * @param  {object} options lazyload options
   */
  install(app: App, options: VueLazyloadOptions = {}) {
    const lazy = new Lazy(options)
    const lazyContainer = new LazyContainer(lazy)

    // 暴露给组件实例
    app.config.globalProperties.$Lazyload = lazy;

    // 组件注册
    if (options.lazyComponent) {
      app.component('lazy-component', LazyComponent(lazy));
    }

    if (options.lazyImage) {
      app.component('lazy-image', LazyImage(lazy));
    }

    // 指令注册
    app.directive('lazy', {
      // 保持指向
      beforeMount: lazy.add.bind(lazy),
      beforeUpdate: lazy.update.bind(lazy),
      updated: lazy.lazyLoadHandler.bind(lazy),
      unmounted: lazy.remove.bind(lazy)
    });

    app.directive('lazy-container', {
      beforeMount: lazyContainer.bind.bind(lazyContainer),
      updated: lazyContainer.update.bind(lazyContainer),
      unmounted: lazyContainer.unbind.bind(lazyContainer),
    });
  }
}

lzay就是懒加载的核心实现,需要把他暴露给Vue实例的实例上,这点很重要

app.config.globalProperties.$Lazyload = lazy;

使用方式:两个组件和两种指令

首先注册组件

if (options.lazyComponent) {
  app.component('lazy-component', LazyComponent(lazy));
}

if (options.lazyImage) {
  app.component('lazy-image', LazyImage(lazy));
}

在不同的指令钩子需要调用lazy的方法

app.directive('lazy', {
  // 保持指向
  beforeMount: lazy.add.bind(lazy),
  beforeUpdate: lazy.update.bind(lazy),
  updated: lazy.lazyLoadHandler.bind(lazy),
  unmounted: lazy.remove.bind(lazy)
});

app.directive('lazy-container', {
  beforeMount: lazyContainer.bind.bind(lazyContainer),
  updated: lazyContainer.update.bind(lazyContainer),
  unmounted: lazyContainer.unbind.bind(lazyContainer),
});

使用方式

template:

use v-lazy-container work with raw HTML

custom error and loading placeholder image

默认配置

在初始化的时候我们可以传入一些配置参数

Vue.use(VueLazyload, {
  preLoad: 1.3,
  error: 'dist/error.png',
  loading: 'dist/loading.gif',
  attempt: 1,
  // the default is ['scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend']
  listenEvents: [ 'scroll' ]
})

内部它的配置参数是这么处理的

this.options = {
  // 不打印断点信息
  silent,
  // 触发事件
  dispatchEvent: !!dispatchEvent,
  throttleWait: throttleWait || 200,
  // 预加载屏比
  preLoad: preLoad || 1.3,
  // 预加载像素
  preLoadTop: preLoadTop || 0,
  // 失败展示图
  error: error || DEFAULT_URL,
  // 加载图
  loading: loading || DEFAULT_URL,
  // 失败重试次数
  attempt: attempt || 3,
  scale: scale || getDPR(scale),
  listenEvents: listenEvents || DEFAULT_EVENTS,
  supportWebp: supportWebp(),
  // 过滤器
  filter: filter || {},
  // 动态修改元素属性
  adapter: adapter || {},
  // 是否使用IntersectionObserver
  observer: !!observer || true,
  observerOptions: observerOptions || DEFAULT_OBSERVER_OPTIONS,
};

监听实现方式

只要有两种

// 实现懒加载的两种方案
export const modeType = {
  event: 'event',
  observer: 'observer',
};

其中需要判断是否兼容observer方式

const inBrowser = typeof window !== 'undefined' && window !== null

export const hasIntersectionObserver = checkIntersectionObserver()

function checkIntersectionObserver(): boolean {
  if (inBrowser &&
    'IntersectionObserver' in window &&
    'IntersectionObserverEntry' in window &&
    'intersectionRatio' in window.IntersectionObserverEntry.prototype) {
    // Minimal polyfill for Edge 15's lack of `isIntersecting`
    // See: https://github.com/w3c/IntersectionObserver/issues/211
    if (!('isIntersecting' in window.IntersectionObserverEntry.prototype)) {
      Object.defineProperty(window.IntersectionObserverEntry.prototype,
        'isIntersecting', {
        get: function () {
          return this.intersectionRatio > 0
        }
      })
    }
    return true
  }
  return false
}

Lazy构造函数

class Lazy {
  constructor({
    preLoad,
    error,
    throttleWait,
    preLoadTop,
    dispatchEvent,
    loading,
    attempt,
    silent = true,
    scale,
    listenEvents,
    filter,
    adapter,
    observer,
    observerOptions
  }: VueLazyloadOptions) {
    this.lazyContainerMananger = null;
    this.mode = modeType.event;
    // 监听队列,各个图片实例
    this.ListenerQueue = [];
    // 充当观察实例ID
    this.TargetIndex = 0;
    // 观察队列,window或其他父元素实例
    this.TargetQueue = [];
    this.options = {
         上文省略...
    };
    // 初始化事件
    this._initEvent();
    // 缓存
    this._imageCache = new ImageCache(200);
    // 视图检测
    this.lazyLoadHandler = throttle(
      this._lazyLoadHandler.bind(this),
      this.options.throttleWait!
    );
    // 选择懒加载方式
    this.setMode(this.options.observer ? modeType.observer : modeType.event);
  }
}

我们分步骤解析他们都有什么功能,首先里面主要有两个维护队列

// 监听队列,各个图片实例
this.ListenerQueue = [];
// 充当观察实例ID
this.TargetIndex = 0;
// 观察队列,window或其他父元素实例
this.TargetQueue = [];

ListenerQueue:用于保存懒加载图片的实例

TargetQueue:用于保存懒加载容器的实例

发布订阅事件(_initEvent)

默认提供三个图片加载的事件:

  • loading
  • loaded
  • error
// 提供发布订阅事件
_initEvent() {
  this.Event = {
    listeners: {
      loading: [],
      loaded: [],
      error: [],
    },
  };

  this.$on = (event, func) => {
    if (!this.Event.listeners[event]) this.Event.listeners[event] = [];
    this.Event.listeners[event].push(func);
  };

  this.$off = (event, func) => {
    // 不传方法的情况
    if (!func) {
      // 不含事件直接中断
      if (!this.Event.listeners[event]) return;
      // 否则直接清空事件队列
      this.Event.listeners[event].length = 0;
      return;
    }
    // 只清除指定函数
    remove(this.Event.listeners[event], func);
  };

  this.$once = (event, func) => {
    const on = () => {
      // 一次触发立马移除事件
      this.$off(event, on);
      func.apply(this, arguments);
    };
    this.$on(event, on);
  };

  this.$emit = (event, context, inCache) => {
    if (!this.Event.listeners[event]) return;
    // 遍历事件所有监听方法触发
    this.Event.listeners[event].forEach((func) => func(context, inCache));
  };
}

基本代码都比较简单,其中有一个remove函数,他主要作用就是从队列移除实例

function remove(arr: Array, item: any) {
  if (!arr.length) return;
  const index = arr.indexOf(item);
  if (index > -1) return arr.splice(index, 1);
}

图片缓存

初始化的时候默认做了缓存处理

this._imageCache = new ImageCache(200);

实现也比较简单

class ImageCache {
  max: number;
  _caches: Array;
  constructor(max: number) {
    this.max = max || 100
    this._caches = []
  }

  has(key: string): boolean {
    return this._caches.indexOf(key) > -1;
  }

  // 需要唯一索引值
  add(key: string) {
    // 阻止重复|无效添加
    if (!key || this.has(key)) return;
    this._caches.push(key);
    // 超过限制移除最旧图片
    if (this._caches.length > this.max) {
      this.free();
    }
  }

  // 先进先出
  free() {
    this._caches.shift();
  }
}

视图检测(lazyLoadHandler)

初始化的时候已经自动加了节流降低触发频率,默认200

// 视图检测
this.lazyLoadHandler = throttle(
  this._lazyLoadHandler.bind(this),
  this.options.throttleWait!
);

主要实现功能有

  • 检测是否在视图内
  • 是否触发图片加载逻辑
  • 清理队列无用实例
/**
 * find nodes which in viewport and trigger load
 * @return
 */
_lazyLoadHandler() {
  // 需要被清理的节点
  const freeList: Array = []

  this.ListenerQueue.forEach((listener) => {
    // 不存在DOM节点 || 不存在父节点DOM || 已加载过
    if (!listener.el || !listener.el.parentNode || listener.state.loaded) {
      freeList.push(listener)
    }
    // 检测是否在可视视图范围内
    const catIn = listener.checkInView();
    if (!catIn) return;
    // 如果是在视图内并未加载完
    if (!listener.state.loaded) listener.load()
  });

  // 无用节点实例移除
  freeList.forEach((item) => {
    remove(this.ListenerQueue, item);
    // 手动销毁vm实例与DOM之间的关联
    item.$destroy && item.$destroy()
  });
}

选择懒加载方式(setMode)

初始化调用函数

// 选择懒加载方式
this.setMode(this.options.observer ? modeType.observer : modeType.event);

主要实现功能:

  • 使用observer模式的时候需要有优雅降级处理
  • 如果使用observer就移除事件逻辑并进行实例化
  • 如果使用event就移除观察并进行事件绑定
setMode(mode: string) {
  // 不兼容降级方案
  if (!hasIntersectionObserver && mode === modeType.observer) {
    mode = modeType.event;
  }

  this.mode = mode; // event or observer
  if (mode === modeType.event) {
    if (this._observer) {
      // 移除事件队列所有观察
      this.ListenerQueue.forEach((listener) => {
        this._observer!.unobserve(listener.el);
      });
      // 移除观察对象
      this._observer = null;
    }
    // 添加事件
    this.TargetQueue.forEach((target) => {
      this._initListen(target.el, true);
    });
  } else {
    // 移除事件队列
    this.TargetQueue.forEach((target) => {
      this._initListen(target.el, false);
    });
    // IntersectionObserver实例化
    this._initIntersectionObserver();
  }
}

事件绑定模式(_initListen)

默认的事件有

const DEFAULT_EVENTS = [
  'scroll',
  'wheel',
  'mousewheel',
  'resize',
  'animationend',
  'transitionend',
  'touchmove',
];

下面懂得都懂

/*
  * add or remove eventlistener
  * @param  {DOM} el DOM or Window
  * @param  {boolean} start flag
  * @return
  */
_initListen(el: HTMLElement, start: boolean) {
  this.options.listenEvents!.forEach((evt) => _[start ? 'on' : 'off'](el, evt, this.lazyLoadHandler))
}
const _ = {
  on(el: Element, type: string, func: () => void, capture = false) {
    el.addEventListener(type, func, {
      capture: capture,
      passive: true
    })
  },
  off(el: Element, type: string, func: () => void, capture = false) {
    el.removeEventListener(type, func, capture)
  }
}

其中passive的作用是这么描述的

passive: Boolean,设置为true时,表示 listener 永远不会调用 preventDefault()。如果 listener 仍然调用了这个函数,客户端将会忽略它并抛出一个控制台警告。

根据规范,passive 选项的默认值始终为false。但是,这引入了处理某些触摸事件(以及其他)的事件监听器在尝试处理滚动时阻止浏览器的主线程的可能性,从而导致滚动处理期间性能可能大大降低。

为防止出现此问题,某些浏览器(特别是Chrome和Firefox)已将文档级节点 Window,Document和Document.body的touchstart (en-US)和touchmove (en-US)事件的passive选项的默认值更改为true。这可以防止调用事件监听器,因此在用户滚动时无法阻止页面呈现。

Observer初始化(_initIntersectionObserver)

如果不传参的情况有默认参数

const DEFAULT_OBSERVER_OPTIONS = {
  rootMargin: '0px',
  threshold: 0,
};

下面进行实例化,然后把所有懒加载图片实例加入观察

/**
 * init IntersectionObserver
 * set mode to observer
 * @return
 */
_initIntersectionObserver() {
  if (!hasIntersectionObserver) return

  this._observer = new IntersectionObserver(
    // callback一般会触发两次。一次是目标元素刚刚进入视口(开始可见),另一次是完全离开视口(开始不可见)。
    this._observerHandler.bind(this),
    this.options.observerOptions
  );

  // 加载队列所有数据都放入观察
  if (this.ListenerQueue.length) {
    // 列表所有元素加入观察
    this.ListenerQueue.forEach((listener) => {
      // 开始观察元素
      this._observer!.observe(listener.ell as Element);
    });
  }
}

回调函数做的操作

/**
 * init IntersectionObserver
 * 遍历对比触发元素和监听元素,如果已加载完成移除观察,否则开始加载
 * @return
 */
_observerHandler(entries: Array) {
  entries.forEach((entry) => {
    // target 元素在 root 元素中的可见性是否发生变化
    // 如果 isIntersecting 为真,target 元素的至少已经达到 thresholds 属性值当中规定的其中一个阈值,如果为假,则 target 元素不在给定的阈值范围内可见。
    if (entry.isIntersecting) {
      this.ListenerQueue.forEach((listener) => {
        // 容器元素内触发元素跟队列元素匹配上
        if (listener.el === entry.target) {
          // 已完成加载则移除
          if (listener.state.loaded) return this._observer!.unobserve(listener.el as Element);
          // 进行加载
          listener.load();
        }
      });
    }
  });
}

回调获取到的数据大概如下

Vue3-Lazyload源码解析_第8张图片

图片地址规范函数(_valueFormatter)

/**
 * generate loading loaded error image url
 * @param {string} image's src
 * @return {object} image's loading, loaded, error url
 */
_valueFormatter(value) {
  let src = value;
  // 加载/错误时的图片,没有就取默认
  let { loading, error, cors } = this.options;

  // value is object
  if (isObject(value)) {
    if (!value.src && !this.options.silent) console.error('Vue Lazyload Next warning: miss src with ' + value)
    src = value.src;
    loading = value.loading || this.options.loading;
    error = value.error || this.options.error;
  }

  return {
    src,
    loading,
    error,
    cors
  };
}

搜索对应图片地址(getBestSelectionFromSrcset)

过滤无效实例,进行参数整合

// 筛选最终替换图片地址
function getBestSelectionFromSrcset(el: Element, scale: number): string {
  // 非IMG标签或者不含响应式属性
  if (el.tagName !== 'IMG' || !el.getAttribute('data-srcset')) return '';

  // 例如"img.400px.jpg 400w, img.800px.jpg" => ['img.400px.jpg 400w', ' img.800px.jpg']
  let options = el.getAttribute('data-srcset')!.trim().split(',');
  const result: Array<[tmpWidth: number, tmpSrc: string]> = []
  // 父元素
  const container = el.parentNode as HTMLElement;
  const containerWidth = container.offsetWidth * scale;

  let spaceIndex: number;
  let tmpSrc: string;
  let tmpWidth: number;
  // ......
}

转换出地址和宽度

// 筛选最终替换图片地址
function getBestSelectionFromSrcset(el: Element, scale: number): string {
  // ......
  options.forEach((item) => {
    item = item.trim();
    spaceIndex = item.lastIndexOf(' ');
    // 没指定宽度就给默认99999
    if (spaceIndex === -1) {
      tmpSrc = item;
      tmpWidth = 99999;
    } else {
      tmpSrc = item.substr(0, spaceIndex);
      tmpWidth = parseInt(
        item.substr(spaceIndex + 1, item.length - spaceIndex - 2),
        10
      );
    }
    return [tmpWidth, tmpSrc];
  });
}

得到如下

/*
  得出
  [
    [400, 'img.400px.jpg''],
    [99999, 'img.800px.jpg']
  ]
*/

进行排序

// 筛选最终替换图片地址
function getBestSelectionFromSrcset(el: Element, scale: number): string {
  // ......
  // 宽度优先,webp后者优先
  result.sort(function (a, b) {
    if (a[0] < b[0]) return 1;
    if (a[0] > b[0]) return -1;
    if (a[0] === b[0]) {
      if (b[1].indexOf('.webp', b[1].length - 5) !== -1) {
        return 1;
      }
      if (a[1].indexOf('.webp', a[1].length - 5) !== -1) {
        return -1;
      }
    }
    return 0;
  });
}

得出最终地址

// 筛选最终替换图片地址
function getBestSelectionFromSrcset(el: Element, scale: number): string {
  // ......
  let bestSelectedSrc = '';
  let tmpOption;
  for (let i = 0; i < result.length; i++) {
    tmpOption = result[i];
    bestSelectedSrc = tmpOption[1];
    const next = result[i + 1];
    // 判断懒加载哪张响应式图
    if (next && next[0] < containerWidth) {
      bestSelectedSrc = tmpOption[1];
      break;
    } else if (!next) {
      bestSelectedSrc = tmpOption[1];
      break;
    }
  }

  // 返回最终使用的图片
  return bestSelectedSrc;
}

我们可以直接看官方示例用法

搜索父滚动元素(scrollParent)

// 查找滚动元素的父元素
const scrollParent = (el: HTMLElement) => {
  if (!inBrowser) return
  if (!(el instanceof Element)) {
    return window
  }

  let parent = el

  while (parent) {
    // body, html, 或者没有父元素就中断
    if (parent === document.body || parent === document.documentElement || !parent.parentNode) break
    // 有设置overflow对应属性就返回父元素
    if (/(scroll|auto)/.test(overflow(parent))) return parent
    // 递归让父元素判断
    parent = parent.parentNode as HTMLElement
  }

  return window
}

响应对象(ReactiveListener)

主要用来将懒加载的图片转成一个实例对象

基本属性

export default class ReactiveListener {
  constructor({
    el,
    src,
    error,
    loading,
    bindType,
    $parent,
    options,
    cors,
    elRenderer,
    imageCache
  }) {
    this.el = el;
    this.src = src;
    this.error = error;
    this.loading = loading;
    // 指令传输的样式属性,例如background-image
    this.bindType = bindType;
    // 重连次数
    this.attempt = 0;
    this.cors = cors;

    this.naturalHeight = 0;
    this.naturalWidth = 0;

    this.options = options;

    this.rect = {} as DOMRect;

    this.$parent = $parent;
    // 调用lazy的_elRenderer方法,设置src并且触发对应事件
    this.elRenderer = elRenderer;
    this._imageCache = imageCache;
    // 计算时间
    this.performanceData = {
      loadStart: 0,
      loadEnd: 0
    };

    this.filter();
    this.initState();
    this.render('loading', false);
  }
}

执行过滤器(filter)

/*
  * listener filter
  */
filter() {
  // 执行过滤器操作
  Object.keys(this.options.filter).forEach((key) => {
    this.options.filter[key](this, this.options);
  });
}

初始化图片状态(initState)

/*
  * init listener state
  * @return
  */
initState() {
  // HTMLElement.dataset 属性允许读写在 HTML或 DOM中的元素上设置的所有自定义数据属性(data-*)集。
  if ('dataset' in this.el!) {
    this.el.dataset.src = this.src;
  } else {
    this.el!.setAttribute('data-src', this.src);
  }

  this.state = {
    loading: false,
    error: false,
    loaded: false,
    rendered: false,
  };
}

更新状态(update)

如果传参换了需要重新更新全部流程

/*
  * update image listener data
  * @param  {String} image uri
  * @param  {String} loading image uri
  * @param  {String} error image uri
  * @return
  */
update(option: { src: string, loading: string, error: string }) {
  const oldSrc = this.src;
  // 更新地址
  this.src = option.src;
  this.loading = option.loading;
  this.error = option.error;
  // 重新执行过滤器
  this.filter();
  // 新旧地址不同之后重置状态
  if (oldSrc !== this.src) {
    this.attempt = 0;
    this.initState();
  }
}

检测视图(checkInView)

/*
  * get el node rect
  * @return
  */
getRect() {
  this.rect = this.el!.getBoundingClientRect();
}

/*
  * check el is in view
  * @return {Boolean} el is in view
  */
checkInView() {
  this.getRect();
  return (
    this.rect.top < window.innerHeight * this.options.preLoad! &&
    this.rect.bottom > this.options.preLoadTop! &&
    this.rect.left < window.innerWidth * this.options.preLoad! &&
    this.rect.right > 0
  );
}

渲染函数(render)

/*
  * render image
  * @param  {String} state to render // ['loading', 'src', 'error']
  * @param  {String} is form cache
  * @return
  */
render(state: string, cache: boolean) {
  this.elRenderer(this, state, cache);
}

这里调用的是lazy的_elRenderer方法,设置src并且触发对应事件

渲染Loading

/*
  * render loading first
  * @params cb:Function
  * @return
  */
renderLoading(cb: Function) {
  this.state.loading = true;
  loadImageAsync(
    {
      src: this.loading,
      cors: this.cors,
    },
    () => {
      this.render('loading', false);
      this.state.loading = false;
      cb();
    },
    () => {
      // handler `loading image` load failed
      cb();
      this.state.loading = false;
    }
  );
}

先加载loading图,成功之后再继续往下加载真正图片

执行加载(load)

/*
  * try load image and  render it
  * @return
  */
load(onFinish = noop) {
  // 加载失败
  if (this.attempt > this.options.attempt! - 1 && this.state.error) {
    onFinish();
    return;
  }
  // 已加载中断
  if (this.state.rendered && this.state.loaded) return;

  // 已缓存过中断
  if (this._imageCache.has(this.src as string)) {
    this.state.loaded = true;
    this.render('loaded', true);
    this.state.rendered = true;
    return onFinish();
  }
  // 省略......
}

上面代码主要做了三个判断

  • 连续失败的时候中断
  • 已加载过的时候中断
  • 已缓存过的时候中断
/*
  * try load image and  render it
  * @return
  */
load(onFinish = noop) {
  // 省略......

  this.renderLoading(() => {
    this.attempt
    // 动态修改元素属性
    this.options.adapter.beforeLoad && this.options.adapter.beforeLoad(this, this.options)
    // 记录开始时间
    this.record('loadStart');

    loadImageAsync(
      {
        src: this.src,
        cors: this.cors,
      },
      (data: {
        naturalHeight: number;
        naturalWidth: number
        src: string;
      }) => {
        // 记录尺寸
        this.naturalHeight = data.naturalHeight;
        this.naturalWidth = data.naturalWidth;
        // 修改状态
        this.state.loaded = true;
        this.state.error = false;
        // 记录加载时间
        this.record('loadEnd');
        // 渲染视图
        this.render('loaded', false);
        this.state.rendered = true;
        // 记录缓存
        this._imageCache.add(this.src);
        onFinish();
      },
      (err: Error) => {
        this.state.error = true;
        this.state.loaded = false;
        this.render('error', false);
      }
    );
  });
}

加载Loading成功之后,会修改状态之后记录缓存再执行回调

耗时记录(record,performance)

内部计算的函数

/*
  * record performance
  * @return
  */
record(event: 'loadStart' | 'loadEnd') {
  this.performanceData[event] = Date.now();
}

暴露给外部的查询函数,并且返回规范对象

/*
  * output performance data
  * @return {Object} performance data
  */
performance() {
  let state = 'loading';
  let time = 0;

  if (this.state.loaded) {
    state = 'loaded';
    time = (this.performanceData.loadEnd - this.performanceData.loadStart) / 1000;
  }

  if (this.state.error) state = 'error';

  return {
    src: this.src,
    state,
    time,
  };
}

手动销毁实例($destroy)

/*
  * $destroy
  * @return
  */
$destroy() {
  this.el = null;
  this.src = '';
  this.error = null;
  this.loading = '';
  this.bindType = null;
  this.attempt = 0;
}

打印记录(performance)

/**
 * output listener's load performance
 * @return {Array}
 */
performance() {
  const list: Array = []
  this.ListenerQueue.map(item => list.push(item.performance()))
  return list
}

设置图片地址和状态(_elRenderer)

/**
 * set element attribute with image'url and state
 * @param  {object} lazyload listener object
 * @param  {string} state will be rendered
 * @param  {bool} inCache  is rendered from cache
 * @return
 */
_elRenderer(listener: ReactiveListener, state: TeventType, cache: boolean) {
  if (!listener.el) return;
  const { el, bindType } = listener;
  // 决定渲染状态地址
  let src;
  switch (state) {
    case 'loading':
      src = listener.loading;
      break;
    case 'error':
      src = listener.error;
      break;
    default:
      src = listener.src
      break;
  }

  // 使用指令就直接设置背景
  if (bindType) {
    el.style[bindType] = 'url("' + src + '")';
  } else if (el.getAttribute('src') !== src) { // 使用属性就设置值
    el.setAttribute('src', src);
  }

  // 修改状态属性值
  el.setAttribute('lazy', state);

  // 发布对应事件
  this.$emit(state, listener, cache);

  // 动态修改元素属性(配置传入)
  this.options.adapter[state] &&
    this.options.adapter[state](listener, this.options);

  // 触发元素事件(配置传入true)
  if (this.options.dispatchEvent) {
    // 创建一个自定义事件
    const event = new CustomEvent(state, {
      detail: listener,
    });
    el.dispatchEvent(event);
  }
}

主要流程:

  1. 根据状态决定渲染图
  2. 设置到样式或者属性
  3. 将当前状态记录到元素
  4. 发布对应的事件
  5. 执行动态修改函数(如果配置有)
  6. 是否触发自定义事件(如果配置选true)

可以从官方示例看动态修改函数的使用方法

Vue.use(vueLazy, {
    adapter: {
        loaded ({ bindType, el, naturalHeight, naturalWidth, $parent, src, loading, error, Init }) {
            // do something here
            // example for call LoadedHandler
            LoadedHandler(el)
        },
        loading (listender, Init) {
            console.log('loading')
        },
        error (listender, Init) {
            console.log('error')
        }
    }
})

lazy指令触发函数

我们回顾入口的时候,lazy指令在不同的钩子会执行不同操作

app.directive('lazy', {
  // 保持指向
  beforeMount: lazy.add.bind(lazy),
  beforeUpdate: lazy.update.bind(lazy),
  updated: lazy.lazyLoadHandler.bind(lazy),
  unmounted: lazy.remove.bind(lazy)
});

添加懒加载实例到队列(add)

代码比较多,我们逐步拆解整个函数功能

判断是否已存在队列内和规范参数

/*
  * add image listener to queue
  * @param  {DOM} el
  * @param  {object} binding vue directive binding
  * @param  {vnode} vnode vue directive vnode
  * @return
  */
add(el: HTMLElement, binding: DirectiveBinding, vnode: VNode) {
  // 如果在监听队列中
  if (this.ListenerQueue.some((item) => item.el === el)) {
    // 更新实例
    this.update(el, binding);
    // 下次更新周期执行检测
    return nextTick(this.lazyLoadHandler);
  }

  // 规范格式
  let { src, loading, error, cors } = this._valueFormatter(binding.value)
  
  nextTick(() => {
      // 省略......
  }  
}

添加懒加载实例(addLazyBox)

/*
  * add lazy component to queue
  * @param  {Vue} vm lazy component instance
  * @return
  */
addLazyBox(vm: Tlistener) {
  // 添加到监听队列
  this.ListenerQueue.push(vm);
  if (inBrowser) {
    // 添加观察队列
    this._addListenerTarget(window);
    // 如果有观察对象则加入观察
    this._observer?.observe(vm.el);
    // 存在父元素也添加观察队列
    if (vm.$el?.parentNode) {
      this._addListenerTarget(vm.$el.parentNode);
    }
  }
}

添加的方法在下面

/*
  * add listener target
  * @param  {DOM} el listener target
  * @return
  */
_addListenerTarget(el: HTMLElement | Window) {
  if (!el) return;
  // 查找是否已存在目标
  let target = this.TargetQueue.find((target) => target.el === el);
  if (!target) {
    // 初始化结构
    target = {
      el,
      id: ++this.TargetIndex,
      childrenCount: 1,
      listened: true,
    };
    // 使用事件模式则进行绑定
    this.mode === modeType.event && this._initListen(target.el, true);
    this.TargetQueue.push(target);
  } else {
    // 子元素数量加1
    target.childrenCount++;
  }
  // 返回当前目标元素索引值
  return this.TargetIndex;
}

之所以把每个懒加载实例的父滚动元素加入队列是为了避免多次对同一元素执行初始化操作

记录子元素数量是为了确保移除懒加载实例的时候不会直接删除父元素导致其他实例受影响

查找容器元素和对应地址

我们可以知道指令属性都有哪些

参数 描述
instance 使用指令的组件实例
value 传递给指令的值。例如,在 v-my-directive="1 + 1" 中,该值为 2
oldValue 先前的值,仅在 beforeUpdate 和 updated 中可用。值是否已更改都可用
arg 参数传递给指令 (如果有)。例如在 v-my-directive:foo 中,arg 为 "foo"
modifiers 包含修饰符 (如果有) 的对象。例如在 v-my-directive.foo.bar 中,修饰符对象为 {foo: true,bar: true}
dir 一个对象,在注册指令时作为参数传递。例如,在以下指令中
/*
  * add image listener to queue
  * @param  {DOM} el
  * @param  {object} binding vue directive binding
  * @param  {vnode} vnode vue directive vnode
  * @return
  */
add(el: HTMLElement, binding: DirectiveBinding, vnode: VNode) {
  // 省略......
  nextTick(() => {
    // 得到对应响应式图片地址
    src = getBestSelectionFromSrcset(el, this.options.scale as number) || src;
    // 存在则加入观察
    this._observer?.observe(el);
    // 获取修饰符对象,如果有的话
    const container: string = Object.keys(binding.modifiers)[0];
    // 父滚动元素
    let $parent: any;

    if (container) {
      $parent = binding.instance!.$refs[container]
      // if there is container passed in, try ref first, then fallback to getElementById to support the original usage
      $parent = $parent
        ? $parent.$el || $parent
        : document.getElementById(container);
    }

    // 查找父元素
    if (!$parent) {
      $parent = scrollParent(el);
    }
  }  
}

其中对修饰符对象那一块可能会有疑问,从官方demo可以看到它的用法

在指令后面附带一个dom的ref名或者dom ID,如果有多个默认只拿第一个,然后从使用指令的组件实例开始往上层查找

如果搜索不到就直接从dom元素往上搜索

生成响应对象加入队列并检测视图

/*
  * add image listener to queue
  * @param  {DOM} el
  * @param  {object} binding vue directive binding
  * @param  {vnode} vnode vue directive vnode
  * @return
  */
add(el: HTMLElement, binding: DirectiveBinding, vnode: VNode) {
  // 省略......
  nextTick(() => {
        // 省略......
      const newListener = new ReactiveListener({
        el,
        src,
        error,
        loading,
        bindType: binding.arg!,
        $parent,
        options: this.options,
        cors,
        elRenderer: this._elRenderer.bind(this),
        imageCache: this._imageCache
      });

      // 加入事件队列
      this.ListenerQueue.push(newListener);

      // 加入观察队列
      if (inBrowser) {
        this._addListenerTarget(window);
        this._addListenerTarget($parent);
      }

      nextTick(() => this.lazyLoadHandler());
  }  
}

最后就做了三件事

  1. 生成响应对象加入监听队列
  2. 将window和父滚动元素加入观察队列,里面会过滤重复添加
  3. 在下一次Dom更新之后触发一次视图检测

更新实例地址(update)

/**
 * update image src
 * @param  {DOM} el
 * @param  {object} vue directive binding
 * @return
 */
update(el: HTMLElement, binding: DirectiveBinding, vnode?: VNode) {
  let { src, loading, error } = this._valueFormatter(binding.value)
  // 获取对应的响应式图片地址
  src = getBestSelectionFromSrcset(el, this.options.scale!) || src;

  const exist = this.ListenerQueue.find((item) => item.el === el);
  if (!exist) {
    // 不存在则添加进队列
    this.add(el, binding, vnode!);
  } else {
    // 已存在就更新地址
    exist.update({
      src,
      error,
      loading
    });
  }
  // 重新绑定观察
  if (this._observer) {
    this._observer.unobserve(el);
    this._observer.observe(el);
  }
  // 在下次 DOM 更新循环结束之后执行延迟回调进行检测
  nextTick(() => this.lazyLoadHandler());
}

主要流程

  1. 获取最终渲染地址
  2. 检测是否存在队列中,决定添加或者直接修改
  3. 重新执行观察
  4. 下次 DOM 更新循环结束之后执行延迟回调进行检测

从监听队列移除实例(remove)

/**
 * remove listener form list
 * @param  {DOM} el
 * @return
 */
remove(el: HTMLElement) {
  // 不存在直接中断
  if (!el) return;
  // 移除观察
  this._observer?.unobserve(el);
  const existItem = this.ListenerQueue.find((item) => item.el === el);
  if (existItem) {
    // 减少childrenCount数量,为0则移除对应事件和TargetQueue实例
    this._removeListenerTarget(existItem.$parent);
    this._removeListenerTarget(window);
    // 从队列移除
    remove(this.ListenerQueue, existItem);
    // 手动销毁
    existItem.$destroy && existItem.$destroy()
  }
}

其中_removeListenerTarget下面再解析

从观察队列移除实例(_removeListenerTarget)

/*
  * remove listener target or reduce target childrenCount
  * @param  {DOM} el or window
  * @return
  */
_removeListenerTarget(el: HTMLElement | Window & typeof globalThis) {
  this.TargetQueue.forEach((target, index) => {
    // 如果匹配到队列数据
    if (target!.el === el) {
      // 子数量-1
      target.childrenCount--;
      // 已经为0
      if (!target.childrenCount) {
        // 移除事件
        this._initListen(target.el, false);
        // 将目标从观察队列移除
        this.TargetQueue.splice(index, 1);
        target = null;
      }
    }
  });
}

移除组件(removeComponent)

/*
  * remove lazy components form list
  * @param  {Vue} vm Vue instance
  * @return
  */
removeComponent(vm: Tlistener) {
  if (!vm) return
  // 将目标从队列移除
  remove(this.ListenerQueue, vm)
  // 如果有观察对象则移除观察
  this._observer?.unobserve(vm.el);
  // 存在父元素节点也移除
  if (vm.$parent && vm.$el.parentNode) {
    this._removeListenerTarget(vm.$el.parentNode)
  }
  this._removeListenerTarget(window)
}

懒加载图片组件(lazy-image)

Vue3-Lazyload源码解析_第9张图片

组件基本入参和属性

export default (lazy: Lazy) => {
  return defineComponent({
    props: {
      src: [String, Object],
      tag: {
        type: String,
        default: 'img'
      }
    },

    setup(props, { slots }) {
      const el: Ref = ref(null)
      // 配置
      const options = reactive({
        src: '',
        error: '',
        loading: '',
        attempt: lazy.options.attempt
      })
      // 状态
      const state = reactive({
        loaded: false,
        error: false,
        attempt: 0
      })
      const renderSrc: Ref = ref('')
      const { rect, checkInView } = useCheckInView(el, lazy.options.preLoad!)

      // 生成标准化实例对象
      const vm = computed(() => {
        return {
          el: el.value,
          rect,
          checkInView,
          load,
          state,
        }
      })

      // 初始化各种状态下对应图片地址
      const init = () => {
        const { src, loading, error } = lazy._valueFormatter(props.src)
        state.loaded = false
        options.src = src
        options.error = error!
        options.loading = loading!
        renderSrc.value = options.loading
      }
      init()

      return () => createVNode(
        props.tag,
        {
          src: renderSrc.value,
          ref: el
        },
        [slots.default?.()]
      )
    }
  })
}

加载函数

export default (lazy: Lazy) => {
  return defineComponent({
    setup(props, { slots }) {
      // 省略......
      const load = (onFinish = noop) => {
        // 失败重试次数
        if ((state.attempt > options.attempt! - 1) && state.error) {
          onFinish()
          return
        }
        const src = options.src
        loadImageAsync({ src }, ({ src }: loadImageAsyncOption) => {
          renderSrc.value = src
          state.loaded = true
        }, () => {
          state.attempt++
          renderSrc.value = options.error
          state.error = true
        })
      }
    }
  })
}

触发事件

export default (lazy: Lazy) => {
  return defineComponent({
    setup(props, { slots }) {
      // 省略......
      
      // 地址修改重新执行流程
      watch(
        () => props.src,
        () => {
          init()
          lazy.addLazyBox(vm.value)
          lazy.lazyLoadHandler()
        }
      )
      onMounted(() => {
        // 保存到事件队列
        lazy.addLazyBox(vm.value)
        // 立马执行一次视图检测
        lazy.lazyLoadHandler()
      })
      onUnmounted(() => {
        lazy.removeComponent(vm.value)
      })
    }
  })
}

懒加载组件(lazy-component)

Vue3-Lazyload源码解析_第10张图片

export default (lazy: Lazy) => {
  return defineComponent({
    props: {
      tag: {
        type: String,
        default: 'div'
      }
    },
    emits: ['show'],
    setup(props, { emit, slots }) {
      const el: Ref = ref(null)
      const state = reactive({
        loaded: false,
        error: false,
        attempt: 0
      })
      const show = ref(false)
      const { rect, checkInView } = useCheckInView(el, lazy.options.preLoad!)

      // 通知父组件
      const load = () => {
        show.value = true
        state.loaded = true
        emit('show', show.value)
      }

      // 标准化实例对象
      const vm = computed(() => {
        return {
          el: el.value,
          rect,
          checkInView,
          load,
          state,
        }
      })

      onMounted(() => {
        // 保存到事件队列
        lazy.addLazyBox(vm.value)
        // 立马执行一次视图检测
        lazy.lazyLoadHandler()
      })

      onUnmounted(() => {
        lazy.removeComponent(vm.value)
      })

      return () => createVNode(
        props.tag,
        {
          ref: el
        },
        [show.value && slots.default?.()]
      )
    }
  })
}

跟图片组件的主要区别在于加载函数直接通知到父元素,本身只记录状态

懒加载容器指令(lazy-container)

Vue3-Lazyload源码解析_第11张图片

我们看回入口跟这个有关系的代码

export const Lazyload = {
  /*
   * install function
   * @param  {App} app
   * @param  {object} options lazyload options
   */
  install(app: App, options: VueLazyloadOptions = {}) {
    // 省略...
    const lazyContainer = new LazyContainer(lazy)
    
    app.directive('lazy-container', {
      beforeMount: lazyContainer.bind.bind(lazyContainer),
      updated: lazyContainer.update.bind(lazyContainer),
      unmounted: lazyContainer.unbind.bind(lazyContainer),
    });
  }
}

生成实例之后会在lazy-container指令的钩子函数里调用对应方法

lazy-container实现

// 懒加载容器管理
export default class LazyContainerManager {
  constructor(lazy: Lazy) {
    // 保存lazy指向
    this.lazy = lazy;
    // 保存管理指向
    lazy.lazyContainerMananger = this
    // 维护队列
    this._queue = [];
  }

  bind(el: HTMLElement, binding: DirectiveBinding, vnode: VNode) {
    const container = new LazyContainer(
      el,
      binding,
      vnode,
      this.lazy,
    );
    // 保存懒加载容器实例
    this._queue.push(container);
  }

  update(el: HTMLElement, binding: DirectiveBinding, vnode?: VNode) {
    const container = this._queue.find((item) => item.el === el);
    if (!container) return;
    container.update(el, binding);
  }

  unbind(el: HTMLElement, binding?: DirectiveBinding, vnode?: VNode) {
    const container = this._queue.find((item) => item.el === el);
    if (!container) return;
    // 清空状态
    container.clear();
    // 移除实例
    remove(this._queue, container);
  }
}

这是全局统一的容器实例管理组件,只有三个功能

  • 挂载之前,将容器组件保存为LazyContainer实例,保存在队列维护
  • 更新之后,更新队列实例属性
  • 销毁之前,清空状态移除实例

LazyContainer类

class LazyContainer {
  constructor(el: HTMLElement, binding: DirectiveBinding, vnode: VNode, lazy: Lazy) {
    this.el = null;
    this.vnode = vnode;
    this.binding = binding;
    this.options = {} as DefaultOptions;
    this.lazy = lazy;

    this._queue = [];
    this.update(el, binding);
  }

  update(el: HTMLElement, binding: DirectiveBinding) {
    this.el = el;
    this.options = Object.assign({}, defaultOptions, binding.value);

    // 组件下所有图片添加进懒加载队列
    const imgs = this.getImgs();
    imgs.forEach((el: HTMLElement) => {
      this.lazy!.add(
        el,
        Object.assign({}, this.binding, {
          value: {
            src: el.getAttribute('data-src') || el.dataset.src,
            error: el.getAttribute('data-error') || el.dataset.error || this.options.error,
            loading: el.getAttribute('data-loading') || el.dataset.loading || this.options.loading
          },
        }),
        this.vnode as VNode
      );
    });
  }

  getImgs(): Array {
    return Array.from(this.el!.querySelectorAll(this.options.selector));
  }

  clear() {
    const imgs = this.getImgs();
    imgs.forEach((el) => this.lazy!.remove(el));

    this.vnode = null;
    this.binding = null;
    this.lazy = null;
  }
}

只有两个功能

  • 更新的时候重新获取容器内所有对应标签,整理参数之后传入lzay队列里,里面会判断是新增还是更新操作
  • 组件清理的时候会在lzay懒加载队列里移除容器内所有对应标签的实例

你可能感兴趣的:(Vue3-Lazyload源码解析)