手把手帶你寫一個 Vue3 的自定義指令

背景

衆所周知,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 去註冊一個全局指令,這樣就可以在組件中使用它們了。

指令的實現

接下來我們要做的就是實現該指令對象,一個指令定義對象可以提供多個鉤子函數,比如 mountedupdatedunmounted 等,我們可以在合適的鉤子函數中編寫相應的代碼來實現需求。

在編寫代碼前,我們不妨思考一下實現圖片懶加載的幾個關鍵步驟。

管理圖片的 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 請求,達到了優化性能的目的。

總結

懶加載圖片指令完整的指令實現,可以在 vue3-lazy 中查看, 在我的課程《Vue3 開發高質量音樂 Web app》中也有應用。

懶加載圖片指令的核心是應用了 IntersectionObserver API 來判斷圖片是否進入可視區,該特性在現代瀏覽器中都支持,但 IE 瀏覽器不支持,此時可以通過監聽圖片可滾動父元素的一些事件如 scrollresize 等,然後通過一些 DOM 計算來判斷圖片元素是否進入可視區。不過 Vue3 已經明確不再支持 IE,那麼僅僅使用 IntersectionObserver API 就足夠了。

除了懶加載圖片自定義指令中用到的鉤子函數,Vue3 的自定義指令還提供了一些其它的鉤子函數,你未來在開發自定義指令時,可以去查閱它的文檔,在適合的鉤子函數去編寫相應的代碼邏輯。

相關鏈接

[1] IntersectionObserver: https://developer.mozilla.org/zh-CN/docs/Web/API/IntersectionObserver

[2] vue3-lazy: https://github.com/ustbhuangyi/vue3-lazy

[3] 《Vue3 開發高質量音樂 Web app》:https://coding.imooc.com/class/503.html

[4] Vue3 自定義指令文檔: https://v3.cn.vuejs.org/guide/custom-directive.html

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/huyuJFhopwupX3yskGFb9g