Vue3-Lazyload源码解析

Intersection Observer API

一种异步检测目标元素与祖先元素或 viewport 相交情况变化的方法。
Intersection Observer API 会注册一个回调函数,每当被监视的元素进入或者退出另外一个元素时(或者 viewport ),或者两个元素的相交部分大小发生变化时,该回调方法会被触发执行,浏览器会自行优化元素相交管理。
Vue3-Lazyload源码解析
文章图片

适用场景
  • 图片懒加载——当图片滚动到可见时才进行加载
  • 内容无限滚动——也就是用户滚动到接近内容底部时直接加载更多,而无需用户操作翻页,给用户一种网页可以无限滚动的错觉
  • 检测广告的曝光情况——为了计算广告收益,需要知道广告元素的曝光情况
  • 在用户看见某个区域时执行任务或播放动画
替代方法 过去,相交检测通常要用到事件监听,并且需要频繁调用 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源码解析
文章图片

交集的计算 容器元素和偏移值
所有区域均被 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源码解析
文章图片

requestIdleCallback window.requestIdleCallback()方法插入一个函数,这个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout,则有可能为了在超时前执行函数而打乱执行顺序。
你可以在空闲回调函数中调用**requestIdleCallback()**,以便在下一次通过事件循环之前调度另一个回调。
Vue3-Lazyload源码解析
文章图片

注意:
  • requestAnimationFrame 会请求浏览器在下一次重新渲染之前执行回调函数
  • requestIdleCallback在浏览器空闲时期被调用
?
自定义事件 CustomEvent 创建一个新的 CustomEvent 对象。
$$ event = new CustomEvent(typeArg, customEventInit); $$
  • typeArg:一个表示 event 名字的字符串
  • customEventInit:
    | 参数 | 描述 |
    | ---------- | ----------------------------------------------------------- |
    | detail | 可选的默认值是 null 的任意类型数据,是一个与 event 相关的值 |
    | bubbles | 一个布尔值,表示该事件能否冒泡 |
    | cancelable | 一个布尔值,表示该事件是否可以取消 |
Vue3-Lazyload源码解析
文章图片

dispatchEvent 向一个指定的事件目标派发一个事件, 并以合适的顺序同步调用目标元素相关的事件处理函数。标准事件处理规则(包括事件捕获和可选的冒泡过程)同样适用于通过手动的使用dispatchEvent()方法派发的事件。
$$ cancelled = !target.dispatchEvent(event) $$
参数:
  • event 是要被派发的事件对象。
  • target 被用来初始化 事件 和 决定将会触发 目标.
返回值:
  • 当该事件是可取消的(cancelable为true)并且至少一个该事件的 事件处理方法 调用了Event.preventDefault(),则返回值为false;否则返回true。
Vue3-Lazyload源码解析
文章图片

如果该被派发的事件的事件类型(event's type)在方法调用之前没有被经过初始化被指定,就会抛出一个 UNSPECIFIED_EVENT_TYPE_ERR 异常,或者如果事件类型是null或一个空字符串. event handler 就会抛出未捕获的异常; 这些 event handlers 运行在一个嵌套的调用栈中: 他们会阻塞调用直到他们处理完毕,但是异常不会冒泡。
注意
与浏览器原生事件不同,原生事件是由DOM派发的,并通过event loop异步调用事件处理程序,而dispatchEvent()则是同步调用事件处理程序。在调用dispatchEvent()后,所有监听该事件的事件处理程序将在代码继续前执行并返回。
dispatchEvent()是create-init-dispatch过程的最后一步,用于将事件调度到实现的事件模型中。可以使用Event构造函数来创建事件。
懒加载流程 Vue3-Lazyload源码解析
文章图片

这是比较常规的实现方式
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:
  • Vue3-Lazyload源码解析
    文章图片
    Vue3-Lazyload源码解析
    文章图片
    Vue3-Lazyload源码解析
    文章图片

custom error and loading placeholder image
Vue3-Lazyload源码解析
文章图片
Vue3-Lazyload源码解析
文章图片
Vue3-Lazyload源码解析
文章图片
Vue3-Lazyload源码解析
文章图片
Vue3-Lazyload源码解析
文章图片
Vue3-Lazyload源码解析
文章图片

默认配置 在初始化的时候我们可以传入一些配置参数
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 !== nullexport 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) returnthis._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源码解析
文章图片

图片地址规范函数(_valueFormatter)
/** * generate loading loaded error image url * @param {string} image's src * @return {object} image's loading, loaded, error url */ _valueFormatter(value) { let src = https://www.it610.com/article/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 = https://www.it610.com/article/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 = [] // 父元素 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 = https://www.it610.com/article/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 bestSelectedSrchttps://www.it610.com/article/= ''; let tmpOption; for (let i = 0; i < result.length; i++) { tmpOption = result[i]; bestSelectedSrc = https://www.it610.com/article/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; }

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