评论

收藏

[JavaScript] 手把手带你写一个 Vue3 的自定义指令

开发技术 开发技术 发布于:2021-12-04 12:25 | 阅读数:601 | 评论:0

背景
众所周知,Vue.js 的核心思想是数据驱动 + 组件化,通常我们开发页面的过程就是在编写一些组件,并且通过修改数据的方式来驱动组件的重新渲染。在这个过程中,我们不需要去手动操作 DOM。
然而在有些场景下,我们还是避免不了要操作 DOM。由于 Vue.js 框架接管了 DOM 元素的创建和更新的过程,因此它可以在 DOM 元素的生命周期内注入用户的代码,于是 Vue.js 设计并提供了自定义指令,允许用户进行一些底层的 DOM 操作。
举个实际的例子——图片懒加载。图片懒加载是一种常见性能优化的方式,由于它只去加载可视区域图片,能减少很多不必要的请求,极大的提升用户体验。
而图片懒加载的实现原理也非常简单,在图片没进入可视区域的时候,我们只需要让 ​​img​​ 标签的 ​​src​​ 属性指向一张默认图片,在它进入可视区后,再替换它的 ​​src​​ 指向真实图片地址即可。
如果我们想在 Vue.js 的项目中实现图片懒加载,那么用自定义指令就再合适不过了,那么接下来就让我手把手带你用 Vue3 去实现一个图片懒加载的自定义指令 ​​v-lazy​​。

插件
为了让这个指令方便地给多个项目使用,我们把它做成一个插件:
const lazyPlugin = {  install (app, options) {  app.directive('lazy', {    // 指令对象  })  }}export default lazyPlugin复制代码
然后在项目中引用它:
import { createApp } from 'vue'import App from './App.vue'import lazyPlugin from 'vue3-lazy'createApp(App).use(lazyPlugin, {  // 添加一些配置参数})复制代码
通常一个 Vue3 的插件会暴露 ​​install​​ 函数,当 ​​app​​ 实例 ​​use​​ 该插件时,就会执行该函数。在 ​​install​​ 函数内部,通过 ​​app.directive​​ 去注册一个全局指令,这样就可以在组件中使用它们了。

指令的实现
接下来我们要做的就是实现该指令对象,一个指令定义对象可以提供多个钩子函数,比如 ​​mounted​​、​​updated​​、​​unmounted​​ 等,我们可以在合适的钩子函数中编写相应的代码来实现需求。
在编写代码前,我们不妨思考一下实现图片懒加载的几个关键步骤。

  • 图片的管理
管理图片的 DOM、真实的 ​​src​​、预加载的 ​​url​​、加载的状态以及图片的加载。

  • 可视区的判断
判断图片是否进入可视区域。
关于图片的管理,我们设计了 ​​ImageManager​​ 类:
const State = {  loading: 0,  loaded: 1,  error: 2}export class ImageManager {  constructor(options) {  this.el = options.el  this.src = options.src  this.state = State.loading  this.loading = options.loading  this.error = options.error    this.render(this.loading)  }  render() {  this.el.setAttribute('src', src)  }  load(next) {  if (this.state > State.loading) {    return  }  this.renderSrc(next)  }  renderSrc(next) {  loadImage(this.src).then(() => {    this.state = State.loaded    this.render(this.src)    next && next()  }).catch((e) => {    this.state = State.error    this.render(this.error)    console.warn(`load failed with src image(${this.src}) and the error msg is ${e.message}`)    next && next()  })  }}export default function loadImage (src) {  return new Promise((resolve, reject) => {  const image = new Image()  image.onload = function () {    resolve()    dispose()  }  image.onerror = function (e) {    reject(e)    dispose()  }  image.src = src  function dispose () {    image.onload = image.onerror = null  }  })}复制代码
首先,对于图片而言,它有三种状态,加载中、加载完成和加载失败。
当 ​​ImageManager​​ 实例化的时候,除了初始化一些数据,还会把它对应的 ​​img​​ 标签的 ​​src​​ 执行加载中的图片 ​​loading​​,这就相当于默认加载的图片。
当执行 ​​ImageManager​​ 对象的 ​​load​​ 方法时,就会判断图片的状态,如果仍然在加载中,则去加载它的真实 ​​src​​,这里用到了 ​​loadImage​​ 图片预加载技术实现去请求 ​​src​​ 图片,成功后再替换 ​​img​​ 标签的 ​​src​​,并修改状态,这样就完成了图片真实地址的加载。
有了图片管理器,接下来我们就需要实现可视区的判断以及对多个图片的管理器的管理,设计 ​​Lazy​​ 类:
const DEFAULT_URL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'export default class Lazy {  constructor(options) {  this.managerQueue = []  this.initIntersectionObserver()    this.loading = options.loading || DEFAULT_URL  this.error = options.error || DEFAULT_URL  }  add(el, binding) {  const src = binding.value    const manager = new ImageManager({    el,    src,    loading: this.loading,    error: this.error  })    this.managerQueue.push(manager)    this.observer.observe(el)  }  initIntersectionObserver() {  this.observer = new IntersectionObserver((entries) => {    entries.forEach((entry) => {    if (entry.isIntersecting) {      const manager = this.managerQueue.find((manager) => {      return manager.el === entry.target      })      if (manager) {      if (manager.state === State.loaded) {        this.removeManager(manager)        return      }      manager.load()      }    }    })  }, {    rootMargin: '0px',    threshold: 0  })  }  removeManager(manager) {  const index = this.managerQueue.indexOf(manager)  if (index > -1) {    this.managerQueue.splice(index, 1)  }  if (this.observer) {    this.observer.unobserve(manager.el)  }  }}const lazyPlugin = {  install (app, options) {  const lazy = new Lazy(options)  app.directive('lazy', {    mounted: lazy.add.bind(lazy)  })  }}复制代码
这样每当图片元素绑定 ​​v-lazy​​ 指令,且在 ​​mounted​​ 钩子函数执行的时候,就会执行 ​​Lazy​​ 对象的 ​​add​​ 方法,其中第一个参数 ​​el​​ 对应的就是图片对应的 DOM 元素对象,第二个参数 ​​binding​​ 就是指令对象绑定的值,比如:
<img class="avatar" v-lazy="item.pic">复制代码
其中 ​​item.pic​​ 对应的就是指令绑定的值,因此通过 ​​binding.value​​ 就可以获取到图片的真实地址。
有了图片的 DOM 元素对象以及真实图片地址后,就可以根据它们创建一个图片管理器对象,并添加到 ​​managerQueue​​ 中,同时对该图片 DOM 元素进行可视区的观察。
而对于图片进入可视区的判断,主要利用了 ​​​IntersectionObserver​​​ API,它对应的回调函数的参数 ​​entries​​,是 ​​IntersectionObserverEntry​​ 对象数组。 当观测的元素可见比例超过指定阈值时,就会执行该回调函数,对 ​​entries​​ 进行遍历,拿到每一个 ​​entry​​,然后判断 ​​entry.isIntersecting​​ 是否为 ​​true​​,如果是则说明 ​​entry​​ 对象对应的 DOM 元素进入了可视区。
然后就根据 DOM 元素的比对从 ​​managerQueue​​ 中找到对应的 ​​manager​​,并且判断它对应图片的加载状态。
如果图片是加载中的状态,则此时执行 ​​manager.load​​ 函数去完成真实图片的加载;如果是已加载状态,则直接从 ​​managerQueue​​ 中移除其对应的管理器,并且停止对图片 DOM 元素的观察。
目前,我们实现了图片元素挂载到页面后,延时加载的一系列处理。不过,当元素从页面卸载后,也需要执行一些清理的操作:
export default class Lazy {  remove(el) {  const manager = this.managerQueue.find((manager) => {    return manager.el === el  })  if (manager) {    this.removeManager(manager)  }  }}const lazyPlugin = {  install (app, options) {  const lazy = new Lazy(options)  app.directive('lazy', {    mounted: lazy.add.bind(lazy),    remove: lazy.remove.bind(lazy)  })  }}复制代码
当元素被卸载后,其对应的图片管理器也会从 ​​managerQueue​​ 中被移除,并且停止对图片 DOM 元素的观察。
此外,如果动态修改了 ​​v-lazy​​ 指令绑定的值,也就是真实图片的请求地址,那么指令内部也应该做对应的修改:
export default class ImageManager {  update (src) {  const currentSrc = this.src  if (src !== currentSrc) {    this.src = src    this.state = State.loading  }  }  }export default class Lazy {  update (el, binding) {  const src = binding.value  const manager = this.managerQueue.find((manager) => {    return manager.el === el  })  if (manager) {    manager.update(src)  }  }  }const lazyPlugin = {  install (app, options) {  const lazy = new Lazy(options)  app.directive('lazy', {    mounted: lazy.add.bind(lazy),    remove: lazy.remove.bind(lazy),    update: lazy.update.bind(lazy)  })  }}复制代码
至此,我们已经实现了一个简单的图片懒加载指令,在这个基础上,还能做一些优化吗?

指令的优化
在实现图片的真实 ​​url​​ 的加载过程中,我们使用了 ​​loadImage​​ 做图片预加载,那么显然对于相同 ​​url​​ 的多张图片,预加载只需要做一次即可。
为了实现上述需求,我们可以在 ​​Lazy​​ 模块内部创建一个缓存 ​​cache​​:
export default class Lazy {  constructor(options) {  // ...  this.cache = new Set()  }}复制代码
然后在创建 ​​ImageManager​​ 实例的时候,把该缓存传入:
const manager = new ImageManager({  el,  src,  loading: this.loading,  error: this.error,  cache: this.cache})复制代码
然后对 ​​ImageManager​​ 做如下修改:
export default class ImageManager {  load(next) {  if (this.state > State.loading) {    return  }  if (this.cache.has(this.src)) {    this.state = State.loaded    this.render(this.src)    return  }  this.renderSrc(next)  }  renderSrc(next) {  loadImage(this.src).then(() => {    this.state = State.loaded    this.render(this.src)    next && next()  }).catch((e) => {    this.state = State.error    this.cache.add(this.src)    this.render(this.error)    console.warn(`load failed with src image(${this.src}) and the error msg is ${e.message}`)    next && next()  })  }}复制代码
在每次执行 ​​load​​ 前从缓存中判断是否已存在,然后在执行 ​​loadImage​​ 预加载图片成功后更新缓存。
通过这种空间换时间的手段,就避免了一些重复的 url 请求,达到了优化性能的目的。

最后
如果你觉得此文对你有一丁点帮助,点个赞。或者可以加入我的开发交流群:1025263163相互学习,我们会有专业的技术答疑解惑
如果你觉得这篇文章对你有点用的话,麻烦请给我们的开源项目点点star: ​​https://gitee.com/ZhongBangKeJi/CRMEB​​不胜感激 !


关注下面的标签,发现更多相似文章