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

前言

这篇文章我们详细解析v-lazy源码

承接上篇文章Vant4图片懒加载源码解析(一)后续

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

源码解析

1. 注册v-lazy指令

swift 复制代码
  app.directive('lazy', {
      beforeMount: lazy.add.bind(lazy),
      updated: lazy.update.bind(lazy),
      unmounted: lazy.remove.bind(lazy),
    });

2. lazy.add函数

该函数是图片懒加载指令的核心方法

  • 将元素添加到IntersectionObserver观察队列
  • 初始化后立即触发一次加载检查
  • DOM更新后再次触发加载检查,确保正确处理初始可见元素

首先要了解一个知识点,add函数中的observer和ReactiveListener是什么关系?

  • observer负责何时加载(检测时机)
  • ReactiveListener负责如何加载(执行细节)
kotlin 复制代码
    /*
     * 添加监听器实例到队列中
     * @param  {DOM} el html文档中挂载的节点
     * @param  {object} binding vue directive binding // vue指令
     * @param  {vnode} vnode vue directive vnode 虚拟节点
     * @return
     */
    add(el, binding, vnode) {
      
      // 如果listeners数组中存在当前el, 则直接update, 我们直接看下面的update函数
      if (this.listeners.some((item) => item.el === el)) {
        this.update(el, binding);
        return nextTick(this.lazyLoadHandler);
      }
      // 格式化value值
      const value = this.valueFormatter(binding.value);
      // 获取src
      let { src } = value;
      
      // 在dom更新之后执行回调函数
      nextTick(() => {
        // 从el的srcset属性中获取当前最优的src属性
        src = getBestSelectionFromSrcset(el, this.options.scale) || src;
        // observer具体怎么执行的,我们具体看1.3 
        // 将当前元素添加到观察者队列中去
        this.observer && this.observer.observe(el);

        const container = Object.keys(binding.modifiers)[0];
        let $parent;

        if (container) {
          $parent = vnode.context.$refs[container];
          // 先从$refs中获取container, 如果获取不到,则document.getElementById 获取
          $parent = $parent
            ? $parent.$el || $parent
            : document.getElementById(container);
        }
        
        // 如果$parent还是为null, 则向上查找具有滚动属性的第一个父级元素
        if (!$parent) {
          $parent = getScrollParent(el);
        }
        // 创建了一个ReactiveListener实例,详情看1.4
        const newListener = new ReactiveListener({
          bindType: binding.arg,
          $parent,
          el,
          src,
          loading: value.loading,
          error: value.error,
          cors: value.cors,
          elRenderer: this.elRenderer.bind(this),
          options: this.options,
          imageCache: this.imageCache,
        });
        
        this.listeners.push(newListener);
        // 给windos和$parent都绑定监听器
        if (inBrowser) {
          this.addListenerTarget(window);
          this.addListenerTarget($parent);
        }
        // 立刻执行一次lazyLoadHandler
        this.lazyLoadHandler();
        // 当前dom更新之后再执行一次lazyLoadHandler
        nextTick(() => this.lazyLoadHandler());
      });
    }

3. this.observer && this.observer.observe(el);

this.observer是IntersectionObserver类
1.3.1 IntersectionObserver的作用?

IntersectionObserver 提供了一种异步观察目标元素与其祖先元素或顶级文档视口(viewport)交叉状态的方法。其祖先元素或视口被称为根(root)

kotlin 复制代码
  // 初始化交互换观察者对象
  initIntersectionObserver() {
      if (!hasIntersectionObserver) {
        return;
      }
      // 创建IntersectionObserver类
      this.observer = new IntersectionObserver(
        this.observerHandler.bind(this),
        this.options.observerOptions,
      );

      if (this.listeners.length) {
        // 遍历listeners中的元素
        this.listeners.forEach((listener) => {
          // 使 `IntersectionObserver` 开始监听一个目标元素。
          this.observer.observe(listener.el);
        });
      }
    }
    
     /**
     * 观察者对象处理函数
     * @return
     */
    observerHandler(entries) {
      entries.forEach((entry) => {
        // 如果有被观察元素出现在视口内
        if (entry.isIntersecting) {
          this.listeners.forEach((listener) => {
            // 查找该元素是listener中的哪个元素
            if (listener.el === entry.target) {
              // 如果该listener的状态已加载,则unobserve该元素
              if (listener.state.loaded)
                return this.observer.unobserve(listener.el);
              // 否则执行load函数
              listener.load();
            }
          });
        }
      });
    }

4. ReactiveListener类

ReactiveListener主要实现图片加载

  • 先了解一下loadImageAsync函数
    loadImageAsync封装了图片的加载功能,如果图片加载成功,就调用外部resolve方法,否则reject错误。

  • crossOrigin 是 Web 开发中用于处理跨域资源共享(CORS, Cross-Origin Resource Sharing)的重要属性,常见值如下:

    • anonymous:发起跨域请求但不携带用户凭证(默认行为)

    • use-credentials:发起跨域请求并携带用户凭证

    我们看下有无crossOrigin对图片加载有什么作用!

场景 无 crossOrigin 有 crossOrigin="anonymous"
显示图片 ✅ 正常显示 ✅ 正常显示(服务器需支持 CORS)
读取像素数据 ❌ 抛出安全错误 ✅ 允许读取(服务器需支持 CORS)
发送凭据 不适用 ❌ 不发送 cookies 等凭据

总结:

  1. 在仅需于浏览器中展示跨域图片而不进行额外处理(如Canvas操作、像素数据读取等)的场景下,crossOrigin属性的设置与否并不影响图片的正常加载与显示。

  2. 跨域图片且没加crossOrigin属性,尝试读取像素数据将触发安全错误!

arduino 复制代码
// 尝试读取像素数据将触发安全错误! 
try { 
    ctx.getImageData(0, 0, canvas.width, canvas.height); 
    } 
catch (e) { 
    console.error('无法读取跨域图片的像素数据', e); 
    }
ini 复制代码
// 加载图片资源函数
export const loadImageAsync = (item, resolve, reject) => {
  const image = new Image();
    
  if (!item || !item.src) {
    return reject(new Error('image src is required'));
  }

  image.src = item.src;
  // 根据cors设置了crossOrigin属性值
  if (item.cors) {
    image.crossOrigin = item.cors;
  }

  image.onload = () =>
    // image加载成功回调函数,就resolve数据回去
    resolve({
      naturalHeight: image.naturalHeight,
      naturalWidth: image.naturalWidth,
      src: image.src,
    });
  // image加载失败,则reject error回去
  image.onerror = (e) => reject(e);
};
  • ReactiveListener类
    Listener 类不仅是"加载图片"的简单封装,而是一个完整的资源生命周期管理器
    判断元素位置,图片的加载实现
kotlin 复制代码
import { useRect } from '@vant/use';
// 加载图片异步函数loadImageAsync
import { loadImageAsync } from './util';

export default class ReactiveListener {
    constructor(){...}
   /*
   * 检查元素是否在预定位置
   * @return {Boolean} el is in view
   */
  checkInView() {
    // 获取图片的位置信息,useRect原理就是getBoundingClientRect()
    const rect = useRect(this.el);
    //如果元素垂直方向满足,视口顶部的值 < 视口高度 * 预设值 且底部要大于预设值
    // 水平方向满足:元素左边距离视口的值 < 浏览器可视宽度 * 预设值且右边>0
    return (
      rect.top < window.innerHeight * this.options.preLoad &&
      rect.bottom > this.options.preLoadTop &&
      rect.left < window.innerWidth * this.options.preLoad &&
      rect.right > 0
    );
  }
  /*
   * 加载图片资源
   * @params cb:Function
   * @return
   */
  renderLoading(cb) {
    this.state.loading = true;
    // 加载图片资源
    loadImageAsync(
      {
        src: this.loading,
        cors: this.cors,
      },
      () => {
      // 成功回调函数
        this.render('loading', false);
        this.state.loading = false;
        cb();
      },
      () => {
        // 失败回调函数
        cb();
        this.state.loading = false;

        if (process.env.NODE_ENV !== 'production' && !this.options.silent)
          console.warn(
            `[@vant/lazyload] load failed with loading image(${this.loading})`,
          );
      },
    );
  }

  /*
   * 加载图片资源
   * @return
   */
  load(onFinish = noop) {
    if (this.attempt > this.options.attempt - 1 && this.state.error) {
      if (process.env.NODE_ENV !== 'production' && !this.options.silent) {
        console.log(
          `[@vant/lazyload] ${this.src} tried too more than ${this.options.attempt} times`,
        );
      }

      onFinish();
      return;
    }
    // 加载过就直接return
    if (this.state.rendered && this.state.loaded) return;
    // 如果this.imageCache集合中存在src,说明加载过,设置加载成功状态
    if (this.imageCache.has(this.src)) {
      this.state.loaded = true;
      this.render('loaded', true);
      this.state.rendered = true;
      return onFinish();
    }

    this.renderLoading(() => {
      // 记录加载次数
      this.attempt++;

      this.options.adapter.beforeLoad?.(this, this.options);
      this.record('loadStart');

      loadImageAsync(
        {
          src: this.src,
          cors: this.cors,
        },
        (data) => {
          // 图片加载成功后的回调函数
          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;
          // imageCache 缓存已经加载过的src
          this.imageCache.add(this.src);
          onFinish();
        },
        (err) => {
          !this.options.silent && console.error(err);
          this.state.error = true;
          this.state.loaded = false;
          this.render('error', false);
        },
      );
    });
  }

  elRenderer(listener, state, cache) {
      // listener中不存在el元素,直接return
      if (!listener.el) return;
      const { el, bindType } = listener;
      
      // 通过state状态获取src值
      let src;
      switch (state) {
        case 'loading':
          src = listener.loading;
          break;
        case 'error':
          src = listener.error;
          break;
        default:
          ({ src } = listener);
          break;
      }
      // `bindType`:指定如何应用图片,可能是:
         1. `background-image`:作为CSS背景
         2. 作为`<img>`标签的`src`属性
      if (bindType) {
        el.style[bindType] = 'url("' + src + '")';
      } else if (el.getAttribute('src') !== src) { // 仅在需要时更新
        el.setAttribute('src', src);
      }
      
      // 设置lazy属性
      el.setAttribute('lazy', state);

      this.$emit(state, listener, cache);
      this.options.adapter[state] &&
        this.options.adapter[state](listener, this.options);

      if (this.options.dispatchEvent) {
        // 创建一个原生自定义事件
        const event = new CustomEvent(state, {
          detail: listener,
        });
        // 触发当前创建的这个事件
        el.dispatchEvent(event);
      }
   }
}

5. lazyLoadHandler

判断node是否在 viewport范围内,如果是,则加载图片资源。如果listener中数据不存在el,则清理掉。

scss 复制代码
    /**
     * find nodes which in viewport and trigger load
     * @return
     */
    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();
      });
    }

6. lazy.update

v-lazy指令所在dom更新el元素src属性时触发

kotlin 复制代码
    update(el, binding, vnode) {
      const value = this.valueFormatter(binding.value);
      
      // 获取src
      let { src } = value;
      src = getBestSelectionFromSrcset(el, this.options.scale) || src;
      
      // 判断当前el是否在listeners集合中存在
      const exist = this.listeners.find((item) => item.el === el);
      if (!exist) {
        // 不存在则add进去
        this.add(el, binding, vnode);
      } else {
        exist.update({
          src,
          error: value.error,
          loading: value.loading,
        });
      }
      // 重新设置observe 元素
      if (this.observer) {
        this.observer.unobserve(el);
        this.observer.observe(el);
      }
      // 加载图片资源
      this.lazyLoadHandler();
      // 等dom更新后再次检查更新
      nextTick(() => this.lazyLoadHandler());
    }

7. lazy.remove

从listener集合中移除el元素

kotlin 复制代码
 /**
     * remove listener form list
     * @param  {DOM} el
     * @return
     */
    remove(el) {
      if (!el) return;
      // 不在观察el元素
      this.observer && this.observer.unobserve(el);
      const existItem = this.listeners.find((item) => item.el === el);
      if (existItem) {
        // 移除
        this.removeListenerTarget(existItem.$parent);
        this.removeListenerTarget(window);
        remove(this.listeners, existItem);
        existItem.$destroy();
      }
    }

总结

声明了好几个类来处理懒加载功能,还挺复杂的~

相关推荐
卸任4 小时前
Vibe coding工具使用心得
前端·ai编程
brien95 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance5 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M5 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写5 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李5 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易5 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马6 小时前
前沿视界
前端
kyriewen6 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易6 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员