sggmico / fe-happy-interview

面试不迷茫
Apache License 2.0
5 stars 0 forks source link

【Vue开发实践】:讲真,Vue图片懒加载怎么做? #15

Open sggmico opened 3 years ago

sggmico commented 3 years ago

前言

图片懒加载大家都很熟悉了,优点如下:

  1. 提升页面渲染效率,避免大量图片加载造成页面卡顿。
  2. 按需加载,减少无效的图片加载,节约网络资源。

本文将使用vue自定义指令的方式,实现图片懒加载的功能。要点如下:

先来围观下,不使用图片懒加载功能,图片列表交互效果,这里

嗯,因为页面会同时请求全部的图片。如果不处理, 一是页面会比较卡,二是滚动到后边会出现白屏,用户体验非常差。接下来,主角登场 —— 图片懒加载指令他来了。

mock

首先使用 mock.js,准备一些图片数据。

目录地址: src/mock/index.js

import Mock from 'mockjs'

Mock.mock('/images', 'get', {
  code: 200,
  msg: 'ok',
  'list|20': [
    {
      id: '@id',
      'cover|1': [
        "@image('300x200', @color, #fff, @title)"
      ]
    }
  ]
})

当请求数据时,返回的数据格式大概是下面的样子:

此时图片数据他来了。

eventBus

图片交互中,我们需要监听图片容器的滚动,这里利用Vue内置的事件通知机制,封装了一个eventBus。该模块保证 eventBus既可以在所有组件内使用,也可以在普通的JS模块里使用。代码如下:

目录地址:src/eventBus.js

import Vue from 'vue'
const bus = new Vue()
Vue.prototype.$bus = bus
export default bus

列表组件目录地址: src/components/List.vue

<template>
  <div class="container" ref="container">
     <ul>
        <li v-for="(image, idx) in images" :key="image.id">
          <img :src="image.cover"  alt=""  :data-id="idx"/>
          </li>
     </ul>
  </div>
</template>
<script>
import { getImages } from "@/api/images";
export default {
    data() {
        return {
            images: []
        };
    },
    async created() {
        const { data } = await getImages();
        this.images = Object.freeze(data.list);
    },
    mounted() {
        this.$refs.container.addEventListener("scroll", () => {
            this.$bus.$emit("iscroll", this.$refs.container);
        });
    },
};
</script>

上面,我们在created阶段,请求到了mock的图片数据,并且在模板里循环遍历了图片列表。同时,在mounted阶段,我们通过监听图片容器的滚动,当滚动触发时,通过封装好的事件总线(eventBus)发布 “iscroll”的事件通知。滚动监听他来了。

eventBus的更多了解,这里

debounce

图片懒加载的机制是:当图片列表滚动时,动态的将可视区内的图片加载并渲染出来。但是,当滚动发生时,对图片进行展示的逻辑并非实时执行,而是等到滚动停止后的某个时间点执行,这样就可以避免在滑动过程中触发非必要的图片加载和渲染。所以,debounce防抖处理他来了。

/**
 * 防抖函数
 *
 * @export
 * @param {*} fn
 * @param {*} delay
 * @returns
 */
export function debounce(fn, delay) {
  let timer = null
  return function (...arg) {
    clearTimeout(timer)
    timer = setTimeout(() => {
      fn.apply(this, arg)
    }, delay);
  }
}

关于防抖和节流,感兴趣的朋友,这里

v-lazy

对Vue自定义指令不熟悉的朋友,这里

到目前位置,List组件内的渲染时,会将所有的图片原地址添加到src属性上 —— :src="image.cover" 。这样,在组件渲染时,全部的图片都会加载和渲染。为了实现图片懒加载的效果,我们需要对src的赋值通过自定义指令做一层代理,并且指令的逻辑单独维护。

最新List组件实现,如下:

<template>
  <div class="container" ref="container">
     <ul>
        <li v-for="(image, idx) in images" :key="image.id">
          <img v-lazy:[container]="image.cover"  alt=""  :data-id="idx"/>
          </li>
     </ul>
  </div>
</template>
<script>
//...
export default {
    data() {
        return {
            images: [],
            container: 'container' //图片容器的className
        };
    },
    //...
};
</script>

上面,我们通过 v-lazy:[container] = ”image.cover“ 来接管每个图片的原始地址,其中 container 为父容器的类名,在指令里判断图片是否在可视区会用到父容器,并且可以在data中动态指定。接下来,我们只需要关注 v-lazy的实现即可。

另外,需要提前准备一张占位图片,每个图片默认开始时使用的是占位图。

目录地址:src/directives/lazy/img/cover-def.gif

v-lazy指令实现细节如下:

imgs

首先,我们需要声明一个变量 imgs,用来存放当前待被懒加载的图片信息集合。当图片滚动发生时,遍历imgs,并找出在可视区停留的图片进行加载和渲染。

// 用来收集需要懒加载的图片信息
let imgs = []

指令配置

根据指令的写法,我们需要用到两个钩子函数:

  1. inserted:在绑定了该指令的图片被插入后,获取当前图片元素—— el 和 绑定的信息 —— binding。需要处理逻辑包括:
    1. 将占位图赋值给当前图片
    2. 通过 binding.arg 获取到指令参数 —— 图片容器的className,然后找到父容器
    3. 构建当前图片对应的图片信息对象img,包括:el(图片元素),src(图片原地址),containerClientHeight(图片容器的clientHeight值),并将img添加到imgs中。
    4. 立即处理当前图片是否需要加载和渲染 —— loadImg(这部分逻辑在单独函数里维护,后边详细讲)
  2. unbind:在图片指令解除绑定后,将图片从带加载的图片集合imgs里清除。
import defImage from './img/cover-def.gif'

// 用来收集需要懒加载的图片信息
let imgs = []

// 指令配置对象
const lazyDirective = {
  inserted(el, binding) {
    const { value, arg } = binding
    let container = el.parentNode
    // 设置默认图片
    el.src = defImage;
    // 找到容器
    while (container && container.className.indexOf(arg) == -1) {
      container = container.parentNode
    }
    const img = {
      el: el,
      src: value,
      container: {
        top: container && container.getBoundingClientRect().top || 0,
        clientHeight: container && container.clientHeight || 0
      },
      height: el.height || 0
    }
    // 添加到当前需要被懒加载的图片集合内,当滚动时,通过加载真实图片
    imgs.push(img)
    // 立即处理:是否加载真实图片
    loadImg(img)
  },
  unbind(el) {
    // 当前图片的指令解除绑定时,需要从imgs中删除(因为此时当前图片已经销毁)
    deleteImg(el)
  }
}

iscroll 事件订阅

通过事件总线,注册 ”iscroll“事件,当 List 组件中的图片容器发生滚动时,会触发图片列表查看及加载判断。

import { debounce } from '@/utils'
import eventBus from '@/eventBus'
/**
 * 查看所有待加载的图片
 *
 */
function loadImages() {
  for (const img of imgs) {
    loadImg(img)
  }
}

// 监听滚动
eventBus.$on('iscroll', debounce(loadImages, 300))

loadImg

通过 loadImg 函数处理每个带加载图片,是否加载的条件:图片是否在图片容器可视区内

是否在可视区,分两种情况判断:

  1. 超出顶部可视区
  2. 超出底部可视区

来围观下示意图:

上图中的,disTop 大于 负的img.heightdisTop 小于 container.clientHeight时,当前图片就在container的可视区域内。反之,就不在可视区。细品~ 。实现如下:

/**
 * 加载真实图片
 *
 * @param {*} img
 */
function loadImg(img) {
  const { el, src, height, container } = img
  img.top = el.getBoundingClientRect().top || 0
  const disTop = img.top - container.top
  // 不在可视区
  if (disTop < -height || disTop > container.clientHeight) return
  // 在可视区
  const image = new Image()
  image.onload = () => {
    el.src = src
  }
  image.src = src
  console.log('加载了', el.dataset.id)
  // 加载过的图片,需要将其从imgs中清除
  deleteImg(el)
}

另外,观察上面代码最后行,需要注意的是:加载过的图片,需要将其从imgs中清除

deleteImg

从imgs中清除已经加载完成的img,实现如下:

/**
 * 删除imgs中已加载的真实图片
 *
 * @param {*} el
 */
function deleteImg(el) {
  imgs = imgs.filter(img => img.el != el)
}

全局指令注册

为了保证改指令可以在全局内使用,需要将其注册为Vue的全局指令。实现如下:

import Vue from 'vue'
// 注册全局指令
Vue.directive('lazy', lazyDirective)
// 导出指令配置
export default lazyDirective

完整实现

目录地址:src/directives/lazy/index.js

import Vue from 'vue'
import { debounce } from '@/utils'
import defImage from './img/cover-def.gif'
import eventBus from '@/eventBus'
// 用来收集需要懒加载的图片信息
let imgs = []
// 指令配置对象
const lazyDirective = {
  inserted(el, binding) {
    const { value, arg } = binding
    let container = el.parentNode
    // 设置默认图片
    el.src = defImage;
    // 找到容器
    while (container && container.className.indexOf(arg) == -1) {
      container = container.parentNode
    }
    const img = {
      el: el,
      src: value,
      container: {
        top: container && container.getBoundingClientRect().top || 0,
        clientHeight: container && container.clientHeight || 0
      },
      height: el.height || 0
    }
    // 添加到当前需要被懒加载的图片集合内,当滚动时,通过加载真实图片
    imgs.push(img)
    // 立即处理:是否加载真实图片
    loadImg(img)
  },
  unbind(el) {
    // 当前图片的指令解除绑定时,需要从imgs中删除(因为此时当前图片已经销毁)
    deleteImg(el)
  }
}

/**
 * 查看所有待加载的图片
 *
 */
function loadImages() {
  for (const img of imgs) {
    loadImg(img)
  }
}

/**
 * 加载真实图片
 *
 * @param {*} img
 */
function loadImg(img) {
  const { el, src, height, container } = img
  img.top = el.getBoundingClientRect().top || 0
  const disTop = img.top - container.top
  // 不在可视区
  if (disTop < -height || disTop > container.clientHeight) return
  // 在可视区
  const image = new Image()
  image.onload = () => {
    el.src = src
  }
  image.src = src
  console.log('加载了', el.dataset.id)
  // 加载过的图片,需要将其从imgs中清除
  deleteImg(el)
}

/**
 * 删除imgs中已加载的真实图片
 *
 * @param {*} el
 */
function deleteImg(el) {
  imgs = imgs.filter(img => img.el != el)
}

// 监听滚动
eventBus.$on('iscroll', debounce(loadImages, 300))

// 注册全局指令
Vue.directive('lazy', lazyDirective)

export default lazyDirective

这样,我们就完成了图片懒加载Vue自定义指令的封装。下面来看下效果吧, 这里

根据控制台输出,滚动结束后,只加载可视区内的图片。效果是不是好些了。

小结

本文比较详细讲述了如何一步步封装一个图片懒加载的Vue自定义指令。内容涉及比较多,防抖、事件总线、如果判断图片在可视区等。希望感兴趣的小伙伴可以自己试着操作一遍,相信一定会有更深入的体会和理解。

交流

如果觉得本文对你有帮助,点赞关注不失联,你的支持是对笔者最大的鼓励!

微信关注 「 乘风破浪大前端 」公众号,发现更多有趣好玩的前端知识和实战。

干货系列文章汇总如下,欢迎 startfollow 交流学习👏🏻。

https://github.com/szjxxy/fe-happy-interview

关于本文如有任何意见或建议,欢迎评论区讨论、指正。

也许你还想看:

  1. 【Vue2.0源码系列】:响应式原理
  2. 【Vue2.0源码系列】:DOM-Diff
  3. 【Vue2.0源码系列】:computed vs methods
  4. 【专题实战】:带你彻底搞懂BFC及其应用