手把手帶你寫(xiě)一個(gè) Vue3 的自定義指令
以下文章來(lái)源于老黃的前端私房菜 褒傅,作者黃軼黃老師
背景
眾所周知屹培,Vue.js 的核心思想是數(shù)據(jù)驅(qū)動(dòng) + 組件化迂苛,通常我們開(kāi)發(fā)頁(yè)面的過(guò)程就是在編寫(xiě)一些組件禁悠,并且通過(guò)修改數(shù)據(jù)的方式來(lái)驅(qū)動(dòng)組件的重新渲染欧啤。在這個(gè)過(guò)程中睛藻,我們不需要去手動(dòng)操作 DOM。
然而在有些場(chǎng)景下邢隧,我們還是避免不了要操作 DOM店印。由于 Vue.js 框架接管了 DOM 元素的創(chuàng)建和更新的過(guò)程,因此它可以在 DOM 元素的生命周期內(nèi)注入用戶(hù)的代碼倒慧,于是 Vue.js 設(shè)計(jì)并提供了自定義指令按摘,允許用戶(hù)進(jìn)行一些底層的 DOM 操作。
舉個(gè)實(shí)際的例子——圖片懶加載纫谅。圖片懶加載是一種常見(jiàn)性能優(yōu)化的方式炫贤,由于它只去加載可視區(qū)域圖片,能減少很多不必要的請(qǐng)求付秕,極大的提升用戶(hù)體驗(yàn)兰珍。
而圖片懶加載的實(shí)現(xiàn)原理也非常簡(jiǎn)單,在圖片沒(méi)進(jìn)入可視區(qū)域的時(shí)候询吴,我們只需要讓 img
標(biāo)簽的 src
屬性指向一張默認(rèn)圖片掠河,在它進(jìn)入可視區(qū)后亮元,再替換它的 src
指向真實(shí)圖片地址即可。
如果我們想在 Vue.js 的項(xiàng)目中實(shí)現(xiàn)圖片懶加載唠摹,那么用自定義指令就再合適不過(guò)了爆捞,那么接下來(lái)就讓我手把手帶你用 Vue3 去實(shí)現(xiàn)一個(gè)圖片懶加載的自定義指令 v-lazy
。
插件
為了讓這個(gè)指令方便地給多個(gè)項(xiàng)目使用勾拉,我們把它做成一個(gè)插件:
const lazyPlugin = {
install (app, options) {
app.directive('lazy', {
// 指令對(duì)象
})
}
}
export default lazyPlugin
然后在項(xiàng)目中引用它:
import { createApp } from 'vue'
import App from './App.vue'
import lazyPlugin from 'vue3-lazy'
createApp(App).use(lazyPlugin, {
// 添加一些配置參數(shù)
})`
通常一個(gè) Vue3 的插件會(huì)暴露 install
函數(shù)煮甥,當(dāng) app
實(shí)例 use
該插件時(shí),就會(huì)執(zhí)行該函數(shù)藕赞。在 install
函數(shù)內(nèi)部苛秕,通過(guò) app.directive
去注冊(cè)一個(gè)全局指令找默,這樣就可以在組件中使用它們了吼驶。
指令的實(shí)現(xiàn)
接下來(lái)我們要做的就是實(shí)現(xiàn)該指令對(duì)象,一個(gè)指令定義對(duì)象可以提供多個(gè)鉤子函數(shù)蟹演,比如 mounted
风钻、updated
、unmounted
等骡技,我們可以在合適的鉤子函數(shù)中編寫(xiě)相應(yīng)的代碼來(lái)實(shí)現(xiàn)需求羞反。
在編寫(xiě)代碼前布朦,我們不妨思考一下實(shí)現(xiàn)圖片懶加載的幾個(gè)關(guān)鍵步驟。
- 圖片的管理
管理圖片的 DOM昼窗、真實(shí)的 src
、預(yù)加載的 url
澄惊、加載的狀態(tài)以及圖片的加載掸驱。
- 可視區(qū)的判斷
判斷圖片是否進(jìn)入可視區(qū)域。
關(guān)于圖片的管理温赔,我們?cè)O(shè)計(jì)了 ImageManager
類(lèi):
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
}
})
}
首先鬼癣,對(duì)于圖片而言,它有三種狀態(tài)骇窍,加載中腹纳、加載完成和加載失敗。
當(dāng) ImageManager
實(shí)例化的時(shí)候足画,除了初始化一些數(shù)據(jù)佃牛,還會(huì)把它對(duì)應(yīng)的 img
標(biāo)簽的 src
執(zhí)行加載中的圖片 loading
俘侠,這就相當(dāng)于默認(rèn)加載的圖片象缀。
當(dāng)執(zhí)行 ImageManager
對(duì)象的 load
方法時(shí)央星,就會(huì)判斷圖片的狀態(tài)惫东,如果仍然在加載中,則去加載它的真實(shí) src
颓遏,這里用到了 loadImage
圖片預(yù)加載技術(shù)實(shí)現(xiàn)去請(qǐng)求 src
圖片滞时,成功后再替換 img
標(biāo)簽的 src
漂洋,并修改狀態(tài),這樣就完成了圖片真實(shí)地址的加載演训。
有了圖片管理器贝咙,接下來(lái)我們就需要實(shí)現(xiàn)可視區(qū)的判斷以及對(duì)多個(gè)圖片的管理器的管理,設(shè)計(jì) Lazy
類(lèi):
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)
})
}
}
這樣每當(dāng)圖片元素綁定 v-lazy
指令,且在 mounted
鉤子函數(shù)執(zhí)行的時(shí)候震糖,就會(huì)執(zhí)行 Lazy
對(duì)象的 add
方法吊说,其中第一個(gè)參數(shù) el
對(duì)應(yīng)的就是圖片對(duì)應(yīng)的 DOM 元素對(duì)象,第二個(gè)參數(shù) binding
就是指令對(duì)象綁定的值厅贪,比如:
<img class="avatar" v-lazy="item.pic">
其中 item.pic
對(duì)應(yīng)的就是指令綁定的值雅宾,因此通過(guò) binding.value
就可以獲取到圖片的真實(shí)地址眉抬。
有了圖片的 DOM 元素對(duì)象以及真實(shí)圖片地址后,就可以根據(jù)它們創(chuàng)建一個(gè)圖片管理器對(duì)象,并添加到 managerQueue
中昏苏,同時(shí)對(duì)該圖片 DOM 元素進(jìn)行可視區(qū)的觀察威沫。
而對(duì)于圖片進(jìn)入可視區(qū)的判斷,主要利用了 IntersectionObserver
API孵构,它對(duì)應(yīng)的回調(diào)函數(shù)的參數(shù) entries
颈墅,是 IntersectionObserverEntry
對(duì)象數(shù)組雾袱。當(dāng)觀測(cè)的元素可見(jiàn)比例超過(guò)指定閾值時(shí)芹橡,就會(huì)執(zhí)行該回調(diào)函數(shù),對(duì) entries
進(jìn)行遍歷煎殷,拿到每一個(gè) entry
,然后判斷 entry.isIntersecting
是否為 true
劣摇,如果是則說(shuō)明 entry
對(duì)象對(duì)應(yīng)的 DOM 元素進(jìn)入了可視區(qū)顶伞。
然后就根據(jù) DOM 元素的比對(duì)從 managerQueue
中找到對(duì)應(yīng)的 manager
唆貌,并且判斷它對(duì)應(yīng)圖片的加載狀態(tài)。
如果圖片是加載中的狀態(tài)语卤,則此時(shí)執(zhí)行 manager.load
函數(shù)去完成真實(shí)圖片的加載酪刀;如果是已加載狀態(tài)骂倘,則直接從 managerQueue
中移除其對(duì)應(yīng)的管理器,并且停止對(duì)圖片 DOM 元素的觀察诅需。
目前荧库,我們實(shí)現(xiàn)了圖片元素掛載到頁(yè)面后分衫,延時(shí)加載的一系列處理。不過(guò)牵现,當(dāng)元素從頁(yè)面卸載后邀桑,也需要執(zhí)行一些清理的操作:
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)
})
}
}
當(dāng)元素被卸載后概漱,其對(duì)應(yīng)的圖片管理器也會(huì)從 managerQueue
中被移除,并且停止對(duì)圖片 DOM 元素的觀察竿裂。
此外腻异,如果動(dòng)態(tài)修改了 v-lazy
指令綁定的值,也就是真實(shí)圖片的請(qǐng)求地址影斑,那么指令內(nèi)部也應(yīng)該做對(duì)應(yīng)的修改:
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)
})
}
}
至此矫户,我們已經(jīng)實(shí)現(xiàn)了一個(gè)簡(jiǎn)單的圖片懶加載指令残邀,在這個(gè)基礎(chǔ)上芥挣,還能做一些優(yōu)化嗎?
指令的優(yōu)化
在實(shí)現(xiàn)圖片的真實(shí) url
的加載過(guò)程中空另,我們使用了 loadImage
做圖片預(yù)加載蹋砚,那么顯然對(duì)于相同 url
的多張圖片都弹,預(yù)加載只需要做一次即可匙姜。
為了實(shí)現(xiàn)上述需求氮昧,我們可以在 Lazy
模塊內(nèi)部創(chuàng)建一個(gè)緩存 cache
:
export default class Lazy {
constructor(options) {
// ...
this.cache = new Set()
}
}
然后在創(chuàng)建 ImageManager
實(shí)例的時(shí)候,把該緩存?zhèn)魅耄?/p>
const manager = new ImageManager({
el,
src,
loading: this.loading,
error: this.error,
cache: this.cache
})
然后對(duì) 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()
})
}
}
在每次執(zhí)行 load
前從緩存中判斷是否已存在,然后在執(zhí)行 loadImage
預(yù)加載圖片成功后更新緩存油狂。
通過(guò)這種空間換時(shí)間的手段,就避免了一些重復(fù)的 url 請(qǐng)求弱贼,達(dá)到了優(yōu)化性能的目的磷蛹。
總結(jié)
懶加載圖片指令完整的指令實(shí)現(xiàn)味咳,可以在 vue3-lazy 中查看, 在我的課程《Vue3 開(kāi)發(fā)高質(zhì)量音樂(lè) Web app》中也有應(yīng)用责嚷。
懶加載圖片指令的核心是應(yīng)用了 IntersectionObserver
API 來(lái)判斷圖片是否進(jìn)入可視區(qū)再层,該特性在現(xiàn)代瀏覽器中都支持堡纬,但 IE 瀏覽器不支持,此時(shí)可以通過(guò)監(jiān)聽(tīng)圖片可滾動(dòng)父元素的一些事件如 scroll
蛋济、resize
等碗旅,然后通過(guò)一些 DOM 計(jì)算來(lái)判斷圖片元素是否進(jìn)入可視區(qū)镜悉。不過(guò) Vue3 已經(jīng)明確不再支持 IE侣肄,那么僅僅使用 IntersectionObserver
API 就足夠了。
除了懶加載圖片自定義指令中用到的鉤子函數(shù)吼具,Vue3 的自定義指令還提供了一些其它的鉤子函數(shù)矩距,你未來(lái)在開(kāi)發(fā)自定義指令時(shí)锥债,可以去查閱它的文檔痊臭,在適合的鉤子函數(shù)去編寫(xiě)相應(yīng)的代碼邏輯趣兄。
相關(guān)鏈接
[1] IntersectionObserver: https://developer.mozilla.org/zh-CN/docs/Web/API/IntersectionObserver
[2] vue3-lazy: https://github.com/ustbhuangyi/vue3-lazy
[3] Vue3 自定義指令文檔: https://v3.cn.vuejs.org/guide/custom-directive.html
推薦閱讀:
關(guān)注小編不定時(shí)更新更多精彩內(nèi)容