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

总结

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

相关推荐
千寻girling2 小时前
面试官 : “ 说一下 ES6 模块与 CommonJS 模块的差异 ? ”
前端·javascript·面试
贝格前端工场2 小时前
困在像素里:我的可视化大屏项目与前端价值觉醒
前端·three.js
float_六七2 小时前
用 `<section>` 而不是 `<div>的原因
前端
ChinaLzw2 小时前
解决uniapp web-view 跳转到mui开发的h5项目 返回被拦截报错的问题
前端·javascript·uni-app
用户12039112947262 小时前
从零起步,用TypeScript写一个Todo App:踩坑与收获分享
前端·react.js·typescript
悟能不能悟2 小时前
Postman Pre-request Script 详细讲解与高级技巧
java·开发语言·前端
henujolly2 小时前
useeffect和uselayouteffect
前端·javascript·react.js
Ulyanov2 小时前
Python射击游戏开发实战:从系统架构到高级编程技巧
开发语言·前端·python·系统架构·tkinter·gui开发
华仔啊2 小时前
这 10 个 Vue3 性能优化技巧很实用,但很多项目都没用上
前端·vue.js