实现vue图片懒加载插件

212 阅读2分钟

概述

网页中涉及到大量图片的时候,往往可以考虑图片的懒加载,所谓的图片懒加载就是初始的时候,不渲染图片,而是默认给一张本地静态图片,当用户滚动条滚动到可视区范围位置的时候,再加载图片。这也是页面性能优化的一种方案,网站中使用到这种方式的网站有京东,淘宝等多数的电商网站,如果我们自己项目中也涉及到大量图片加载,其实可以考虑使用图片懒加载,这里借助vue中的自定义指令实现这个插件。

最终效果(效果图较大,等会就出来了)

window滚动

动画.gif

带滚动父元素的

动画.gif

图片懒加载原理

分两种情况

  1. 全局来说,我们只考虑在window上面绑定滚动事件,当图片进入到浏览器窗口可视区范围,我们就加载图片,默认没进入可视区的图片,我们默认使用一张尺寸较小本地静态图(可以自己定义)。
  2. 局部来说,我们给某个大盒子设置overflow属性,让其超出隐藏,如果内部又很多图片,那么我们可以通过滚动父级元素,当图片进入到父级元素的可视区范围,则加载图片,反之和1相同。

元素进入可视区的计算方法

  • 全局

对于全局的这种情况比较简单,通过getBoundingClientRect这个方法可以获取元素到可视区顶部的距离,只要顶部的距离小于html文档的可视区高度,则进入可视区

export const checkEnterView = (imgInstance) => {
   const { top, left } = el.getBoundingClientRect();
   const htmlClientHeight = document.documentElement.clientHeight;
  const htmlClientWidth = document.documentElement.clientWidth;
  if (top < htmlClientHeight && left < htmlClientWidth) {
    return true;
   }
 return false;
};
  • 局部

对于第二种情况,我们要判断元素是否进入到父元素(带overflow),还是有点不好想,思路和全局的也一样

export const checkEnterView = (imgInstance, scrollParent) => {
  let parentHeight, parentWidth, x, y;
  if (imgInstance.scroll) {
    // 存在滚动父级,需要元素到滚动父级可视区顶部的估计:计算公式为offsetTop-scrollParent.scrollTop
    parentHeight = scrollParent.clientHeight;
    parentWidth = scrollParent.clientWidth;
    y = imgInstance.el.offsetTop - scrollParent.scrollTop;
    x = imgInstance.el.offsetLeft - scrollParent.scrollLeft;
  } 
  if (y < parentHeight && x < parentWidth) {
    return true;
  }
  return false;
};

可以根据下面这张图脑海里想一想 X$%QD4X%8YMQ2~720{IS93M.png

实现

知道了上面图片懒加载的原理,实现起来就比较简单了,无非是图片元素进入到可视区范围,将其正确的路径设置上,能加载出来就不变,加载不出来就设置出错的默认图(避免页面出现碎掉的标签),还可以进行过渡效果设置。

结构

image.png

utils.js

用到的几个工具函数,判断可视区,防抖,节流

import loadingSrc from "../../assets/img/loading.jpg";
/**
 * @Description 判断dom元素是否到达可视区
 * @Author Galaxy
 * @Date 2022/10/18 18:47:46
 * @param { Boolean }
 * @return { Boolean }
 **/
export const checkEnterView = (imgInstance, scrollParent) => {
  let parentHeight, parentWidth, x, y;
  if (imgInstance.scroll) {
    // 存在滚动父级,需要元素到滚动父级可视区顶部的估计:计算公式为offsetTop-scrollParent.scrollTop
    parentHeight = scrollParent.clientHeight;
    parentWidth = scrollParent.clientWidth;
    y = imgInstance.el.offsetTop - scrollParent.scrollTop;
    x = imgInstance.el.offsetLeft - scrollParent.scrollLeft;
  } else {
    // 不存在滚动父级的情况
    const { top, left } = imgInstance.el.getBoundingClientRect();
    y = top;
    x = left;
    parentHeight = document.documentElement.clientHeight;
    parentWidth = document.documentElement.clientWidth;
  }
  if (y < parentHeight && x < parentWidth) {
    return true;
  }
  return false;
};

// 初始状态将图片的路径设置成加载中的图片
export const initLoadImg = (el) => {
  el.src = loadingSrc;
};

/**
 * @Description 防抖函数
 * @Author Galaxy
 * @param { Function } fn 需要防抖的函数
 * @param { Number } time 时间间隔
 * @return { Fuction } 返回防抖后的新函数
 **/
export const debounce = (fn, time) => {
  let timer = null;
  return function (...arg) {
    if (timer) {
      clearTimeout(timer);
    }
    timer = setTimeout(() => {
      fn.apply(this, ...arg);
    }, time);
  };
};
/**
 * @Description 节流函数
 * @Author Galaxy
 * @param { Function } fn 需要节流的函数
 * @param { Number } time 时间间隔
 * @param { Number } immediate 是否初始的时候立即执行一次
 * @return { Fuction } 返回节流后的新函数
 **/
export const throttle = (fn, time, immediate = true) => {
  let oldTime = new Date();
  return function (...arg) {
    if (immediate) {
      fn.apply(this, ...arg);
      immediate = false;
      return;
    }
    let currentTime = new Date();
    if (currentTime - oldTime >= time) {
      fn.apply(this, ...arg);
      oldTime = currentTime;
    }
  };
};


ImageItemClass.js

指令绑定的img元素的抽象类

// v-lazy绑定的元素加载图片项构造函数
export default class ImageItemClass {
  constructor({ errorImg, src, el, transitionTime }) {
    // img标签
    this.el = el;
    // 加载出错显示的默认图
    this.errorImg = errorImg;
    //绑定的图片路径
    this.src = src;
    // 是否加载过了当前图片
    this.loaded = false;
    // 动画过渡时长
    this.transitionTime = transitionTime;
    // 是否存在加载出错
    this.loadingError = false;
  }
  //   每个图片项的加载函数
  loadImg() {
    // 返回promise,外部调用可以做其他扩展处理
    return new Promise((resolve, reject) => {
      this.el.src = this.src;
      //src为有效图片路径能够加载出来
      this.el.onload = () => {
        resolve();
        // 为了更好的用户体验,这里将图片的透明度进行过渡
        this.el.style.opacity = '0';
        // this.el.style.transition = `opacity ${this.transitionTime}s`;
        this.addTransition();
      };
      //src为无效图片路径不能够加载出来
      this.el.onerror = () => {
        // 设置成默认加载错误时候的图片,避免破碎图片的显示
        this.el.src = this.errorImg;
        this.loadingError = true;
        reject();
        this.addTransition();
      };
      // 标识当前图片已经被加载了,避免滚动重复处理造成的卡顿
      this.loaded = true;
    });
  }
  // 添加过渡
  addTransition() {
    requestAnimationFrame(() => {
      !this.loadingError ? (this.el.style.transition = `opacity ${1.2}s ease-in-out`) : null;
      this.el.style.opacity = '1';
    });
  }
}

Lazy.js

v-lazy指令的构造函数,便于扩展

import { checkEnterView, initLoadImg, debounce, throttle } from "./utils";
import ImageItemClass from "./ImageItemClass";
// 懒加载类
export default class Lazy {
  constructor(options) {
    // 所有v-lazy绑定的图片的集合
    this.imgPoolList = new Map();
    this.noScrollParentimgPoolList = []; //不带滚动父级
    this.scrollParentimgPoolList = []; //带滚动父级
    // 图片集合字段名
    this.pooListField = "noScrollParentimgPoolList";
    // 是否绑定了滚动处理函数到window
    this.isBindScrollWindow = false;
    //  是否绑定了滚动处理函数到带overflow属性的父级元素
    this.isBindScrollScrollParent = false;
    // 图片加载失败的默认图
    this.errorImg = options.errorImg || require("./img/error.jpg");
    // 图片加载中显示的默认图
    this.loadingImg = options.loadingImg || require("./img/loading.jpg");
    // 是否指定特定的带over-flow的父级作为滚动处理函数绑定的对象,默认绑定滚动处理函数在window上面
    this.scrollParent = null;
    // 根据用户指令配置项,确定是否根据父级来绑定滚动函数
    this.isScrollParent = false;
    // 动画过渡时长
    this.transitionTime = options.transitionTime || 0.9;
    // 防抖的阈值
    this.debounceTime = options.debounceTime || 200;
    // 绑定防抖函数,避免滚动过程频繁触发,提高流畅度
    this.debounceHandleScroll = debounce(
      this.handleScroll,
      this.debounceTime
    ).bind(this);
    // 绑定节流函数,避免滚动过程频繁触发,提高流畅度
    this.throttleHandleScroll = throttle(
      this.handleScroll,
      this.debounceTime,
      true
    ).bind(this);
  }
  // 指令绑定的dom元素插入到页面中触发(类似mounted)
  inserted(el, binding, vnode) {
    // 是否需要滚动父级
    this.isScrollParent = binding.modifiers.scroll;
    this.pooListField = this.isScrollParent
      ? "scrollParentimgPoolList"
      : "noScrollParentimgPoolList";
    // 将当前dom元素加入到set集合中,便于统一处理
    this.imgPoolList.set(
      el,
      new ImageItemClass({
        src: binding.value, //图片路径
        errorImg: this.errorImg, //加载出错默认图
        el, //当前dom节点
        transitionTime: this.transitionTime, //动画过渡时长
        scroll: this.isScrollParent, //是否标记滚动父级
      })
    );
    // 初始化渲染图片状态
    initLoadImg(el);
    // 初始的时候,执行一次函数
    this.handleScroll();
    // 记住绑定过了就不需要绑定了,不然会出现给dom元素重复多次绑定滚动处理函数
    // 如果存在滚动滚动父级,那么给滚动父级也绑定滚动处理函数
    if (this.findScrollParent(el)) {
      !this.isBindScrollScrollParent &&
        this.scrollParent.addEventListener("scroll", this.debounceHandleScroll);
      this.isBindScrollScrollParent = true;
    } else {
      !this.isBindScrollWindow &&
        window.addEventListener("scroll", this.debounceHandleScroll);
      this.isBindScrollWindow = true;
    }
  }
  // 元素卸载,初始化
  unbind() {
    this.scrollParentimgPoolList = [];
    this.noScrollParentimgPoolList = [];
  }
  // 指令绑定的值变化
  update(el, binding) {
    this.updateImageInstance(el, binding);
  }
  // 更新保存的图片实例对象
  updateImageInstance(el, binding) {
    // 将更新的图片的路径进行更换,重新加载
    for (let [elment, imgInstance] of this.imgPoolList) {
      if (el == elment && !imgInstance.loaded) {
        imgInstance.src = binding.value;
      }
    }
    this.handleScroll();
  }
  // 滚动的时候,根据dom元素是否进入到可视区动态,再决定是否加载图片
  handleScroll() {
    for (let [el, imgInstance] of this.imgPoolList) {
      if (this.scrollParent) {
        // 当img出现在可视区并且没有被加载的时候,进行加载处理
        if (
          checkEnterView(imgInstance, this.scrollParent) &&
          !imgInstance.loaded
        ) {
          this.resolveImgInstance(imgInstance);
        }
      } else {
        // 当img出现在可视区并且没有被加载的时候,进行加载处理
        if (checkEnterView(imgInstance) && !imgInstance.loaded) {
          this.resolveImgInstance(imgInstance);
        }
      }
    }
  }
  // 加载图片
  resolveImgInstance(imgInstance) {
    imgInstance
      .loadImg()
      .then(() => {
        // 图片加载成功的回调
      })
      .catch(() => {
        // 图片加载失败的回调,将loaded变为false,便于图片更新后重新加载
        imgInstance.loaded = false;
      });
  }
  // 寻找滚动父级元素,带overflow的
  findScrollParent(el) {
    if (!this.isScrollParent) return false;
    let parent = el.parentNode;
    while (parent) {
      if (
        getComputedStyle(parent).getPropertyValue("overflow") == "scroll" ||
        getComputedStyle(parent).getPropertyValue("overflow") == "auto" ||
        getComputedStyle(parent).getPropertyValue("overflow-x") == "scroll" ||
        getComputedStyle(parent).getPropertyValue("overflow-x") == "auto" ||
        getComputedStyle(parent).getPropertyValue("overflow-y") == "scroll" ||
        getComputedStyle(parent).getPropertyValue("overflow-y") == "auto"
      ) {
        // 找到了带overflow样式的父级元素
        this.scrollParent = parent;
        return true;
      }
      parent = parent.parentNode;
    }
    return false;
  }
}


index.js

注册指令集合

import Lazy from "./Lazy";
export default {
  // 插件都要具有install方法,这样外部就可以通过vue.use注册插件了
  install(Vue, options = {}) {
    // 注册v-lazy指令
    const lazyInstance = new Lazy(options);
    Vue.directive("lazy", {
      inserted: lazyInstance.inserted.bind(lazyInstance),
    });
  },
};

使用

main.js

import Vue from "vue";
import App from "./App.vue";
// 导入插件
import vLazy from "./components/v-lazy";
Vue.use(vLazy);
new Vue({
  render: (h) => h(App),
  router,
  store,
}).$mount("#app");

App.vue

注意,当设置了父级元素overflow的时候,一定要设置position定位,不然通过offsetTop获取距离定位元素就会出现误差

<template>
  <div class="app">
    <div class="aline">
      <!-- <div class="item">
        <div class="img-wrap">
          <h2>全局window滚动</h2>
          <ul>
            <li v-for="(item, index) in list" :key="index">
              <img v-lazy="item.src" />
            </li>
          </ul>
        </div>
      </div> -->
      <div class="item">
        <h2>带overflow父级内部滚动</h2>
        <div class="img-wrap scroll-wrap">
          <ul>
            <li v-for="(item, index) in list" :key="index">
              <img v-lazy.scroll="item.src" />
            </li>
          </ul>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  components: {},
  data() {
    return {
      current: -1,
      list: [],
      img: "",
    };
  },
  mounted() {
    this.getList();
    this.init();
  },
  methods: {
    handleCLick() {
      console.log(this.current++);
    },
    getList() {
      let res = [
        {
          id: Math.random(),
          src: require("./assets/img/01.webp"),
        },
        {
          id: Math.random(),
          src: require("./assets/img/02.webp"),
        },
        {
          id: Math.random(),
          src: require("./assets/img/03.webp"),
        },
        {
          id: Math.random(),
          src: 11,
        },
        {
          id: Math.random(),
          src: require("./assets/img/04.webp"),
        },
      ];
      this.list = [...res, ...res, ...res, ...res];
    },
    init() {},
  },
};
</script>

<style lang="less">
* {
  margin: 0;
  padding: 0;
}
.app {
  padding: 20px;
  button {
    padding: 10px;
    background-color: #008c8c;
    color: #fff;
    margin: 20px 0;
  }
  .container {
    .operate {
      text-align: center;
    }
    .aline {
      width: 50%;
    }
    h2 {
      font-weight: bold;
      font-size: 20px;
    }
    .aline {
      &:nth-child(1) {
        margin-right: 20px;
      }
    }
    display: flex;
    justify-content: space-between;
  }
}
.aline {
  display: flex;
  justify-content: center;
}
.demo-split {
  height: 200px;
  border: 1px solid #dcdee2;
  margin: 20px;
}
.item {
  margin: 40px;
  img {
    width: 250px;
    height: 200px;
  }
  ul {
    margin: 0 auto;
    li {
      border: 1px solid red;
      height: 200px;
      width: 250px;
    }
  }
  .scroll-wrap {
    height: 500px;
    overflow: auto;
    position: relative;
  }
}
</style>

总结

图片懒加载在项目中很实用,也算是性能优化的一种方案,这里自己写一个,加深对其原理的理解,插件已上传到npm

npm 插件下载地址

npm i gnip-vue2-lazy-load

插件实战效果链接

数字教材