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 图片格式在性能优化中的重要意义。
  • 我们梳理了源码中的关键知识点,下篇再见~
相关推荐
passerby60615 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了5 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅5 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅6 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment6 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
Cobyte6 小时前
AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用
前端·后端·aigc