Vant4图片懒加载源码解析(一)

组件简介

Lazyload懒加载,当页面需要加载大量内容时,使用懒加载可以实现延迟加载页面可视区域外的内容,从而使页面加载更流程。

如何使用

Lazyload是Vue指令,使用前需要对指令进行注册

1. 注册指令

javascript 复制代码
import { createApp } from 'vue'; 
import { Lazyload } from 'vant'; 
const app = createApp(); 
app.use(Lazyload); // 注册时可以配置额外的选项 app.use(Lazyload, { lazyComponent: true, });

2. 将v-lazy指令的值设置为需要懒加载的图片

ini 复制代码
<img v-for="img in imageList" v-lazy="img" />

3. 背景图片懒加载,要使用v-lazy:background-image

arduino 复制代码
<div v-for="img in imageList" v-lazy:background-image="img" />

4. 组件懒加载,将需要懒加载的组件放在lazy-component标签中

javascript 复制代码
// 注意注册时设置"lazyComponent"选项
app.use(Lazyload, { lazyComponent: true, });

<lazy-component>
  <img v-for="img in imageList" v-lazy="img" />
</lazy-component>

源码解析

1. vue-lazyload/index.js

index.js是lazyload组件的入口文件,该js导出一个Lazyload对象,Lazyload对象会声明一个install方法, install函数是组件注册的必用函数,这里不再赘述。

php 复制代码
import Lazy from './lazy';
import LazyComponent from './lazy-component';
import LazyContainer from './lazy-container';
import LazyImage from './lazy-image';

export const Lazyload = {
  /*
   * install function
   * @param  {App} app
   * @param  {object} options lazyload options
   */
  install(app, options = {}) {
    const LazyClass = Lazy();
    // 创建了一个laze实例
    const lazy = new LazyClass(options);
    // 创建了一个lazeContainer实例
    const lazyContainer = new LazyContainer({ lazy });
    
    // 将laze实例放到vue实例的global属性上
    app.config.globalProperties.$Lazyload = lazy;
    
    // 如果options中存在lazyComponent, 注册LazyComponent组件,这里是我们上文"如何使用第4点"强调使用组件的话需要增加传参lazyComponent: true了
    if (options.lazyComponent) {
      app.component('LazyComponent', LazyComponent(lazy));
    }
    // 根据传参lazeImage注册LazyImage组件
    if (options.lazyImage) {
      app.component('LazyImage', LazyImage(lazy));
    }
    
    // 注册指令lazy, 我们就知道为啥指令是v-lazy了啦
    app.directive('lazy', {
      beforeMount: lazy.add.bind(lazy),
      updated: lazy.update.bind(lazy),
      unmounted: lazy.remove.bind(lazy),
    });
    // 注册指令lazy-container
    app.directive('lazy-container', {
      beforeMount: lazyContainer.bind.bind(lazyContainer),
      updated: lazyContainer.update.bind(lazyContainer),
      unmounted: lazyContainer.unbind.bind(lazyContainer),
    });
  },
};

2. vue-lazyload/lazy.js

lazy.js导入了很多工具函数,导出一个匿名函数,外部执行这个匿名函数,会return一个类class Lazy。

函数可以将内部逻辑和变量放到当前函数做作用域中,避免外包访问和修改。

2.1 constructor构造函数

constuctor函数初始化一些配置和函数

kotlin 复制代码
import { nextTick } from 'vue';
import { inBrowser, getScrollParent } from '@vant/use';
import {
  remove,
  on,
  off,
  throttle,
  supportWebp,
  getDPR,
  getBestSelectionFromSrcset,
  hasIntersectionObserver,
  modeType,
  ImageCache,
} from './util';
import { isObject } from '../../utils';
import ReactiveListener from './listener';

const DEFAULT_URL =
  'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const DEFAULT_EVENTS = [
  'scroll',
  'wheel',
  'mousewheel',
  'resize',
  'animationend',
  'transitionend',
  'touchmove',
];
const DEFAULT_OBSERVER_OPTIONS = {
  rootMargin: '0px',
  threshold: 0,
};

export default function () {
  return class Lazy {
    constructor({
      preLoad,
      error,
      throttleWait,
      preLoadTop,
      dispatchEvent,
      loading,
      attempt,
      silent = true,
      scale,
      listenEvents,
      filter,
      adapter,
      observer,
      observerOptions,
    }) {
      this.mode = modeType.event;
      this.listeners = [];
      this.targetIndex = 0;
      this.targets = [];
      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 || {},
        observer: !!observer,
        observerOptions: observerOptions || DEFAULT_OBSERVER_OPTIONS,
      };
      this.initEvent();
      this.imageCache = new ImageCache({ max: 200 });
      this.lazyLoadHandler = throttle(
        this.lazyLoadHandler.bind(this),
        this.options.throttleWait,
      );

      this.setMode(this.options.observer ? modeType.observer : modeType.event);
    }
    ...
    }
 }

2.2 remove & on & off

  • remove是移除数组中的元素

  • on和off是通过addEventListener绑定和解绑函数

  • 为什么addEventListener要设置capture和passive呢?
    capture: false

    表示监听器在 事件冒泡阶段 触发(这是最常用的模式)

    passive: true

    明确告诉浏览器:此监听器绝不会调用 event.preventDefault()

    浏览器因此可以立即执行默认行为(比如滚动、缩放),无需等待 JS 执行完毕,从而提升流畅度。

typescript 复制代码
// 移除数组中的元素
export function remove(arr, item) {
  if (!arr.length) return;
  const index = arr.indexOf(item);
  if (index > -1) return arr.splice(index, 1);
}
// 绑定事件
export function on(el, type, func) {
  el.addEventListener(type, func, {
    capture: false, // 默认在**冒泡阶段**触发事件
    passive: true, // 明确告诉浏览器:**此监听器绝不会调用 `event.preventDefault()`**
  });
}
// 解绑事件
export function off(el, type, func) {
  // 第三个参数是一个布尔值,指定需要移除的事件监听器是否为捕获监听器,默认false
  el.removeEventListener(type, func, false);
}

2.3 throttle节流函数

  • 第一次立即执行, lastRun = 0, Date.now() - lastRun是一个很大的数,肯定比delay大,所以第一次会立即执行
  • 第二次注入一个定时器,等待delay秒后执行,此时timeout有值且是一个数字,
  • 等待delay秒后执行runCallback函数,timeout被清空,lastRun被重新赋值为当前时间
  • 事件在时间间隔为delay秒内触发,就会继续注入setTimeout,等待执行。。。如此一直循环
  • 只要当没有setTimeout回调事件之后,最后会在elapsed >= delay条件下再执行一次runCallback
ini 复制代码
export function throttle(action, delay) {
  let timeout = null;
  let lastRun = 0;
  return function (...args) {
    if (timeout) {
      return;
    }
    const elapsed = Date.now() - lastRun;
    const runCallback = () => {
      lastRun = Date.now();
      timeout = false;
      action.apply(this, args);
    };
        // 第一次立即执行
    if (elapsed >= delay) {
      runCallback();
    } else {
    // 第二次注入一个定时器
      timeout = setTimeout(runCallback, delay);
    }
  };
}

2.4 supportWebp & getDPR

函数 作用 典型用途
supportWebp() 检测是否支持 WebP 图片格式 优先加载 WebP,节省带宽、提升加载速度
getDPR(scale) 获取设备像素比(DPR) ,获取当前设备的物理像素与 CSS 像素的比率(DPR) 为高清屏加载高分辨率图片,避免模糊
  • inBrowser是判断条件是typeof window是否存在值
    const inBrowser = 'undefined' != typeof window;
  • canvas.toDataURL('image/webp') 如果浏览器支持WebP格式的图片,就会返回'data:image/webp'...图片base64格式的字符串
    如果不支持,就会降级为PNG, 返回"data:image/png..."
    哇哦,知识面又拓宽了~
javascript 复制代码
export function supportWebp() {
// 不是browser, 直接return
  if (!inBrowser) return false;

  let support = true;

  try {
    const elem = document.createElement('canvas');
    // 检测canvas是否可用
    if (elem.getContext && elem.getContext('2d')) {
      // 尝试将canvas导出为WebP格式的数据URL
      support = elem.toDataURL('image/webp').indexOf('data:image/webp') === 0;
    }
  } catch (err) {
    support = false;
  }

  return support;
}

export const getDPR = (scale = 1) =>
  inBrowser ? window.devicePixelRatio || scale : scale;

2.5 getBestSelectionFromSrcset

这个函数 getBestSelectionFromSrcset 的作用是:

根据图片容器的缩放后宽度(考虑设备像素比),从 <img> 元素的 data-srcset 属性中智能选择最合适的一张图片源(URL) ,优先选择 WebP 格式,并适配高清屏。

ini 复制代码
export function getBestSelectionFromSrcset(el, scale) {
  // 判断是img标签 且 存在data-srcset属性
  if (el.tagName !== 'IMG' || !el.getAttribute('data-srcset')) return;
  
  // 注意不是标准 `srcset`,是自定义属性data-srcset
  let options = el.getAttribute('data-srcset');
  const container = el.parentNode;
  // 计算父容器的宽度,考虑缩放比例
  const containerWidth = container.offsetWidth * scale;

  let spaceIndex;
  let tmpSrc;
  let tmpWidth;
   
  // 标准的srcset格式image-400.jpg 400w, image-800.webp 800w, image-1200.jpg 1200w
  options = options.trim().split(',');

  const result = options.map((item) => {
    item = item.trim();
    spaceIndex = item.lastIndexOf(' ');
    if (spaceIndex === -1) {
      tmpSrc = item;
      tmpWidth = 999998;// 无宽度描述,视为"兜底图"
    } else {
      tmpSrc = item.substr(0, spaceIndex);
    // 提取 "800w" → 去掉末尾 'w'(通过 -2 截断)
      tmpWidth = parseInt(
        item.substr(spaceIndex + 1, item.length - spaceIndex - 2),
        10,
      );
    }
    return [tmpWidth, tmpSrc];
  });
   // 从大到小排序
  result.sort((a, b) => {
    if (a[0] < b[0]) { // a宽度小,放后面
      return 1;
    }
    if (a[0] > b[0]) { // a宽度大,放前面
      return -1;
    }
    if (a[0] === b[0]) {
   // 宽度相同:WebP 优先
      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;
  });
  let bestSelectedSrc = '';
  let tmpOption;
/** 排序完成后的数据大概如下,目的是找到最契合容器宽度的图片src后返回
[
  [1200, 'img-1200.webp'],
  [800,  'img-800.jpg'],
  [400,  'img-400.jpg']
] **/

  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;
}

2.6 hasIntersectionObserver & ImageCache

  • hasIntersectionObserver 是判断浏览器是否支持IntersectionObserver方法
    IntersectionObserver 提供了一种异步观察目标元素与其祖先元素或顶级文档[视口]交叉状态的方法。其祖先元素或视口被称为根。 通俗的讲就是某个目标元素出现在屏幕范围内,就能通过ntersectionObserver捕获到这个元素,从而对这个元素做一些操作。我们这里使用的目的是图片element元素出现在屏幕可视范围才加载图片的资源。

  • 如何封装一个Cache类?

    this.imageCache = new ImageCache({ max: 200 });

javascript 复制代码
判断浏览器是否支持IntersectionObserver 方法
export const hasIntersectionObserver =
  inBrowser &&
  'IntersectionObserver' in window &&
  'IntersectionObserverEntry' in window &&
  'intersectionRatio' in window.IntersectionObserverEntry.prototype;

// 图片cache类
export class ImageCache {
  constructor({ max }) {
    this.options = {
      max: max || 100,
    };
    this.caches = [];
  }

  has(key) {
    return this.caches.indexOf(key) > -1;
  }

  add(key) {
    if (this.has(key)) return;
    this.caches.push(key);
    if (this.caches.length > this.options.max) {
      this.free();
    }
  }

  free() {
    this.caches.shift();
  }
}

2.7 initEvent

这个 initEvent() 函数的作用是:在当前类实例(如 Lazy)上初始化一个简易的自定义事件系统(发布-订阅模式) ,提供 $on$once$off$emit 等方法,用于组件内部或外部监听和触发特定事件(如图片加载中、加载完成、加载失败等

kotlin 复制代码
    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.$once = (event, func) => {
        const on = (...args) => {
          // 先调用$off解绑事件
          this.$off(event, on);
          // 执行回调方法
          func.apply(this, args);
        };
        this.$on(event, on);
      };
      // 解绑事件
      this.$off = (event, func) => {
        // 如果不存在func
        if (!func) {
          // 如果event类名在Event没有注册过,直接return
          if (!this.Event.listeners[event]) return;
          // 注册过就直接清空数组中数据
          this.Event.listeners[event].length = 0;
          return;
        }
        // 存在func回调函数则从数组中移除
        remove(this.Event.listeners[event], func);
      };
      // 触发注册过的event事件
      this.$emit = (event, context, inCache) => {
        if (!this.Event.listeners[event]) return;
        this.Event.listeners[event].forEach((func) => func(context, inCache));
      };
    }

2.7 lazyLoadHandler

遍历所有被监听的元素(如图片),检查它们是否进入视口(可见区域),如果是,则触发加载;同时清理已从 DOM 中移除的无效监听器,防止内存泄漏。具体的listener是数据结构是什么样的,我们下篇讲。

scss 复制代码
    lazyLoadHandler() {
      const freeList = [];
      this.listeners.forEach((listener) => {
        // 没有目标元素的监听器,放入待释放数组中
        if (!listener.el || !listener.el.parentNode) {
          freeList.push(listener);
        }
        // 检查图片是否已经进入了视口区域,如果是,则加载图片
        const catIn = listener.checkInView();
        if (!catIn) return;
        listener.load();
      });
      // 释放无用的监听器,防止内存泄漏
      freeList.forEach((item) => {
        remove(this.listeners, item);
        item.$destroy();
      });
    }

总结

  • 本文深入解析了 LazyLoad 中常用的一些工具函数,涵盖了如何基于发布-订阅模式实现一个轻量级的事件收集与触发机制,回顾了节流(throttle)、addEventListener 的高级参数(如 passivecapture)等核心概念,并探讨了 WebP 图片格式在性能优化中的重要意义。
  • 我们梳理了源码中的关键知识点,下篇再见~
相关推荐
老华带你飞2 小时前
婚纱摄影网站|基于java + vue婚纱摄影网站系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot
Rysxt_2 小时前
UniApp App.vue 文件完整教程
开发语言·前端·javascript
qiyue772 小时前
AI浪潮下,前端的路在何方,附前端转KMP实践
前端·ai编程
Moment2 小时前
历史性突破!LCP 和 INP 终于覆盖所有主流浏览器,iOS 性能盲点彻底消失
前端·javascript·面试
小小荧2 小时前
Hono与Honox一次尝试
前端·后端
菩提小狗2 小时前
第2天:基础入门-Web应用&架构搭建&漏洞&HTTP数据包&代理服务器|小迪安全笔记|网络安全|
前端·安全·架构
咖啡の猫3 小时前
Python集合生成式
前端·javascript·python
QT 小鲜肉3 小时前
【Linux命令大全】001.文件管理之mtoolstest命令(实操篇)
linux·运维·前端·笔记·microsoft
holeer3 小时前
React UI组件封装实战——以经典项目「个人博客」与「仿手机QQ」为例
前端·javascript·react.js·ui·前端框架·软件工程