【源码学习】第23期 | 原来还可以这样用vue3实现一个懒加载组件!

lxf2023-03-18 07:25:01

背景

    在实际的项目实践中,相信不少人都会遇到过需要处理大量图片的场景,一下子请求并渲染完所有图片肯定会造成页面卡顿,造成不好的用户体验!聪明的小伙伴肯定第一时间想到懒加载了,那么懒加载具体是怎么实现的?下面就跟着vant4源码来一起探讨一下,长文预警ing~

收获列表

  • 图片懒加载的实现原理
  • vant的图片懒加载组件及其原理
  • 如何vue3ts实现图片懒加载组件

实现原理

    图片懒加载的原理相信大家都不会陌生,就是进入可视区域再加载图片,实现的关键就是判断是否进入可视区域,常见的有以下三种实现方式

  • 滚动监听+scrollTop+offsetTop+innerHeight
    当scrollTop+innerHeight > offsetTop,即图片在视口内,否则图片在可视区域外,三者关系示意图如下

【源码学习】第23期 | 原来还可以这样用vue3实现一个懒加载组件!

  • 滚动监听+getBoundingClientRect()
    具体的用法可以参考MDN,返回DomRect的值示意图如下,当top值处于0至视口高度之间时即为该元素进入可视区域

【源码学习】第23期 | 原来还可以这样用vue3实现一个懒加载组件!

  • intersectionObserve()传送门
    其中前两种方法可以说事件模式,第三种方法则是intersectionObserveapi模式,那么vantlazyload又是怎么实现的呢,下面根据源码来分析一下:

下载vant源码


git clone https://github.com/youzan/vant.git

cd vant

pnpm install

pnpm run dev

调试源码

  • 利用 Vue.js devtools 定位 lazyloadd的demo所在源码文件

【源码学习】第23期 | 原来还可以这样用vue3实现一个懒加载组件!

  • 就是这里了

【源码学习】第23期 | 原来还可以这样用vue3实现一个懒加载组件!

  • vue-lazyloadindex.jsinstall函数打断点

【源码学习】第23期 | 原来还可以这样用vue3实现一个懒加载组件!


源码分析

vue-lazyload入口文件

    这里主要导出一个具有install函数的对象lazy,而install函数主要做了以下几件事:

  • 将lazy实例挂载到全局变量上
const LazyClass = Lazy();
const lazy = new LazyClass(options);
const lazyContainer = new LazyContainer({ lazy });
app.config.globalProperties.$Lazyload = lazy;
  • 注册全局懒加载组件LazyComponent
if (options.lazyComponent) {

  app.component('LazyComponent', LazyComponent(lazy));

}
  • 注册全局懒加载图片组件LazyImage
if (options.lazyImage) {

  app.component('LazyImage', LazyImage(lazy));

}
  • 注册全局指令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),
});

lazy类

    lazy 类有400多行,缩减一下主体结构如下,其实就是构造器跟一堆方法,接着来细看一下

export class Lazy {
  constructor() {
  }
  config(options = {}) {

  }
  performance() {

  }
  addLazyBox(vm) {

  }
  add(el, binding, vnode) {

  }
  update(el, binding, vnode) {

  }
  remove(el) {

  }
  removeComponent(vm) {

  }

  setMode(mode) {

  }

  addListenerTarget(el) {

  }
  removeListenerTarget(el) {

  }

  initListen(el, start) {

  }

  initEvent() {

  }
  lazyLoadHandler() {
  }
  initIntersectionObserver() {
  }

  observerHandler(entries) {
  }
  elRenderer(listener, state, cache) {
  }

  valueFormatter(value) {
  }
};
  • 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);
}

    这里主要是给lazy实例增加属性,其中lazyLoadHandlerthrottle节流函数包裹,同时setMode判断是事件模式还是IntersectionObserverapi模式,下面根据属性的顺序学习一下所用到的方法

getDPR方法

    获取像素比

// var inBrowser = typeof window !== "undefined";
// window.devicePixelRatio 获取设备像素比
export const getDPR = (scale = 1) =>
  inBrowser ? window.devicePixelRatio || scale : scale;

supportWebp方法

    使用 canvas 的 toDataURL 进行判断浏览器是否支持webp图片格式

export function supportWebp() {
  if (!inBrowser) return false;

  let support = true;

  try {
    const elem = document.createElement('canvas');

    if (elem.getContext && elem.getContext('2d')) {
      support = elem.toDataURL('image/webp').indexOf('data:image/webp') === 0;
    }
  } catch (err) {
    support = false;
  }

  return support;
}

initEvent方法

    初始化事件,$on添加监听事件,$once只监听一次事件,$off清除事件监听,$emit提交实例、是否从缓存加载等信息

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) => {
      this.$off(event, on);
      func.apply(this, args);
    };
    this.$on(event, on);
  };

  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.$emit = (event, context, inCache) => {
    if (!this.Event.listeners[event]) return;
    this.Event.listeners[event].forEach((func) => func(context, inCache));
  };
}

ImageCache类

    主要属性是options对象、缓存数组,其中max定义缓存数组的最大长度;has方法判断缓存数组中是否有该图片,add方法:当缓存中没有图片并且缓存数组的长度小于限制把指定值push进缓存数组,否则释放掉第一个缓存;free方法移除掉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();
  }
}

throttle节流方法

    在一定时间内只执行一次函数,减少事件短时间内重复执行的频率

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

setMode方法

    设置模式,判断浏览器是否支持IntersectionObserver api,若支持则使用IntersectionObserver模式否则用事件模式

setMode(mode) {
  if (!hasIntersectionObserver && mode === modeType.observer) {
    mode = modeType.event;
  }

  this.mode = mode; // event or observer

  if (mode === modeType.event) {
    if (this.observer) {
      this.listeners.forEach((listener) => {
        this.observer.unobserve(listener.el);
      });
      this.observer = null;
    }

    this.targets.forEach((target) => {
      this.initListen(target.el, true);
    });
  } else {
    this.targets.forEach((target) => {
      this.initListen(target.el, false);
    });
    this.initIntersectionObserver();
  }
}

initListen方法

    添加或清除事件监听器

/*
    * add or remove eventlistener
    * @param  {DOM} el DOM or Window
    * @param  {boolean} start flag
    * @return
    */
initListen(el, start) {
  this.options.ListenEvents.forEach((evt) =>
    (start ? on : off)(el, evt, this.lazyLoadHandler)
  );
}

initIntersectionObserver方法

    初始化IntersectionObserverobserve使 IntersectionObserver 开始监听一个目标元素

/**
     * init IntersectionObserver
     * set mode to observer
     * @return
     */
initIntersectionObserver() {
  if (!hasIntersectionObserver) {
    return;
  }

  this.observer = new IntersectionObserver(
    this.observerHandler.bind(this),
    this.options.observerOptions
  );

  if (this.listeners.length) {
    this.listeners.forEach((listener) => {
      this.observer.observe(listener.el);
    });
  }
}

lazyLoadHandler方法

    遍历listeners判断元素是否在可视范围内,若是则加载,否则push进fleeList数组

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

checkInView方法

    主要是利用useRect来判断元素是否在可视区域内

/*
 *  check el is in view
 * @return {Boolean} el is in view
 */
checkInView() {
  const rect = useRect(this.el);
  return (
    rect.top < window.innerHeight * this.options.preLoad &&
    rect.bottom > this.options.preLoadTop &&
    rect.left < window.innerWidth * this.options.preLoad &&
    rect.right > 0
  );
}

config方法

    config将传入的options的值复制到lazy类的options

/**
    * update config
    * @param  {Object} config params
    * @return
    */
config(options = {}) {
  Object.assign(this.options, options);
}

performance方法

    输出监听事件的加载performance

/**
     * output listener's load performance
     * @return {Array}
     */
performance() {
  return this.listeners.map((item) => item.performance());
}

addLazyBox方法

    将懒加载组件添加至队列

/*
    * add lazy component to queue
    * @param  {Vue} vm lazy component instance
    * @return
    */
addLazyBox(vm) {

  this.listeners.push(vm);
  if (inBrowser) {
    this.addListenerTarget(window);
    this.observer && this.observer.observe(vm.el);
    if (vm.$el && vm.$el.parentNode) {
      this.addListenerTarget(vm.$el.parentNode);
    }
  }
}

valueFormatter方法

    生成loadingerrorurl的对象

/**
    * generate loading loaded error image url
    * @param {string} image's src
    * @return {object} image's loading, loaded, error url
    */
valueFormatter(value) {
  let src = value;
  let { loading, error } = this.options;

  // value is object
  if (isObject(value)) {
    if (
      process.env.NODE_ENV !== 'production' &&
      !value.src &&
      !this.options.silent
    ) {
      console.error('[@vant/lazyload] miss src with ' + value);
    }

    ({ src } = value);
    loading = value.loading || this.options.loading;
    error = value.error || this.options.error;
  }
  return {
    src,
    loading,
    error,
  };
}

removeComponent方法

    将指定懒加载组件从监听列表中清除,停止监听目标元素

/*
 * remove lazy components form list
 * @param  {Vue} vm Vue instance
 * @return
 */
removeComponent(vm) {
  if (!vm) return;
  remove(this.listeners, vm);
  this.observer && this.observer.unobserve(vm.el);
  if (vm.$parent && vm.$el.parentNode) {
    this.removeListenerTarget(vm.$el.parentNode);
  }
  this.removeListenerTarget(window);
}

分析完lazy类的这些方法,接着看懒加载图片组件lazyImage就容易得多了~

lazyImage

/**
 * This is a fork of [vue-lazyload](https://github.com/hilongjw/vue-lazyload) with Vue 3 support.
 * license at https://github.com/hilongjw/vue-lazyload/blob/master/LICENSE
 */

import { useRect } from '@vant/use';
import { loadImageAsync } from './util';
import { noop } from '../../utils';
import { h } from 'vue';

export default (lazyManager) => ({
  // 传入值
  props: {
    src: [String, Object],
    tag: {
      type: String,
      default: 'img',
    },
  },
  // 渲染函数 tag默认值为img 图片路径为renderSrc 插槽等
  render() {
    return h(
      this.tag,
      {
        src: this.renderSrc,
      },
      this.$slots.default?.()
    );
  },
  // 定义参数
  data() {
    return {
      el: null,
      options: {
        src: '',
        error: '',
        loading: '',
        attempt: lazyManager.options.attempt,
      },
      state: {
        loaded: false,
        error: false,
        attempt: 0,
      },
      renderSrc: '',
    };
  },
  // 监听图片路径,初始化组件
  watch: {
    src() {
      this.init();
      // 将图片组件添加到队列
      lazyManager.addLazyBox(this);
      // 节流监听进入可视区域的图片并加载
      lazyManager.lazyLoadHandler();
    },
  },

  created() {
    this.init();
    this.renderSrc = this.options.loading;
  },
  mounted() {
    this.el = this.$el;
    lazyManager.addLazyBox(this);
    lazyManager.lazyLoadHandler();
  },
  // 组件卸载前停止监听元素
  beforeUnmount() {
    lazyManager.removeComponent(this);
  },
  methods: {
  // 初始化函数
    init() {
      const { src, loading, error } = lazyManager.valueFormatter(this.src);
      this.state.loaded = false;
      this.options.src = src;
      this.options.error = error;
      this.options.loading = loading;
      this.renderSrc = this.options.loading;
    },
  //利用useRect判断元素是否在可视区域内
    checkInView() {
      const rect = useRect(this.$el);
      return (
        rect.top < window.innerHeight * lazyManager.options.preLoad &&
        rect.bottom > 0 &&
        rect.left < window.innerWidth * lazyManager.options.preLoad &&
        rect.right > 0
      );
    },
  // 加载函数
    load(onFinish = noop) {
      if (this.state.attempt > this.options.attempt - 1 && this.state.error) {
        if (
          process.env.NODE_ENV !== 'production' &&
          !lazyManager.options.silent
        ) {
          console.log(
            `[@vant/lazyload] ${this.options.src} tried too more than ${this.options.attempt} times`
          );
        }

        onFinish();
        return;
      }
      const { src } = this.options;
      loadImageAsync(
        { src },
        ({ src }) => {
          this.renderSrc = src;
          // 改变加载状态
          this.state.loaded = true;
        },
        () => {
          this.state.attempt++;
          this.renderSrc = this.options.error;
          this.state.error = true;
        }
      );
    },
  },
});

loadImageAsync方法

export const loadImageAsync = (item, resolve, reject) => {
  // 新建图片
  const image = new Image();
  // 图片路径必须
  if (!item || !item.src) {
    return reject(new Error('image src is required'));
  }
  // 设置图片的src跟crossOrigin
  image.src = item.src;
  if (item.cors) {
    image.crossOrigin = item.cors;
  }
 // 根据实际宽高、src加载图片
  image.onload = () =>
    resolve({
      naturalHeight: image.naturalHeight,
      naturalWidth: image.naturalWidth,
      src: image.src,
    });
  // 图片加载出错
  image.onerror = (e) => reject(e);
};

总结

    到这里懒加载组件的学习就告一段落了,组件源码的学习篇幅较长,比之前的源码学习需要更多的耐心,但与之对应的收获也更多,比如兼容性处理,对事件用数组的方式进行监听,使用节流函数减少调用频率等等,也许正如宫崎骏所说的那样:人生如路,须要耐心。走着走着,说不定就会在凄凉中走出繁华的风景!

参考文章

  • vant4.0 正式发布了,分析其源码学会用 vue3 写一个图片懒加载组件!
  • IntersectionObserver MDN