vue自定義指令實現(xiàn)懶加載的優(yōu)化指南
前言
今天搞首頁優(yōu)化的時候,發(fā)現(xiàn)首頁有很多內(nèi)容,需要用戶向下滾動才會看到,這部分內(nèi)容包括很多圖片、組件、請求,這些請求其實都可以等到用戶可見時再開始加載的,因為之前沒有做處理,首頁一加載彈出了一堆網(wǎng)絡(luò)請求,這些網(wǎng)絡(luò)請求和首頁關(guān)鍵資源一起加載,爭搶帶寬,拖慢了關(guān)鍵資源的加載速度。
設(shè)計
首先就是想清楚我的需求。我要做到的是懶加載資源,就是用戶能看到的時候才加載相應(yīng)的資源,這里用戶能看到也就是進(jìn)入可視區(qū)范圍內(nèi)。
這個可以通過IntersectionObserver API實現(xiàn),因為IntersectionObserver會自動監(jiān)聽dom是否移動到可視區(qū)范圍,監(jiān)聽到之后會調(diào)用callback執(zhí)行,這里的callback我傳遞一個加載資源的函數(shù)就可以了,這樣,當(dāng)移動到可視區(qū)范圍內(nèi)我就可以動態(tài)加載資源了。
因為項目中需要這樣懶加載的資源很多,并且分布在不同位置,不可能每個都手動去寫代碼實現(xiàn),所以我想要一個通用的并且能用很少的代碼就應(yīng)用在不同目錄下的vue文件中,那這個可以用組件或自定義指令去實現(xiàn)
當(dāng)然這兩種方案都可以,我這里選擇自定義指令,因為用起來代碼會更少,更方便一點。
自定義指令就叫v-lazy,在dom標(biāo)簽上我可以規(guī)定這樣使用:
<!-- 圖片懶加載 -->
<img v-lazy="()=>import("@/assets/images/...")" />
<!-- 組件懶加載 可以配合異步組件實現(xiàn) -->
<div v-lazy="()=>show.value=true">
<ProductList v-if="show"/>
</div>
<!-- 延遲請求接口數(shù)據(jù) -->
<div v-lazy="getListData">
<ul>
<li v-for="item in List">{{item}}</li>
</ul>
</div>
只用傳遞一個方法告訴v-lazy指令,當(dāng)目標(biāo)元素出現(xiàn)在可視區(qū)時才執(zhí)行就行了,接下來開始著手實現(xiàn)v-lazy指令
注:因為項目式Vue3,所以整體代碼采用vue3寫法,如果項目是Vue2的話,用Vue2實現(xiàn)也可以,畢竟思路是相通的。
v-lazy實現(xiàn)
跟大象裝進(jìn)冰箱一樣,大部分的邏輯實現(xiàn)都可以簡單劃分成三步,每一步帶著目的去完成,這樣一步一步到完成核心代碼, 最后再進(jìn)行一些邊邊角角的修補(bǔ)
現(xiàn)在把 v-lazy 指令實現(xiàn)簡按劃分成三個步驟:
拿到自定義指令綁定的方法,也就是要執(zhí)行的函數(shù),存儲起來。
通過IntersectionObserver監(jiān)聽目標(biāo)元素是否進(jìn)入可視區(qū)范圍。
當(dāng)目標(biāo)元素進(jìn)入可視區(qū)范圍時,就取出存儲的函數(shù),執(zhí)行。
這樣一看是不是簡單多了,只需要按順序完成上面三個步驟,我們就實現(xiàn)了v-lazy指令。
第一步
首先完成第一步,通過binding參數(shù)可以拿到函數(shù),接下來如何存儲呢,平常的Object肯定不行,因為Object的key只能是字符串,但是這里的key應(yīng)該和目標(biāo)元素聯(lián)系起來,目標(biāo)元素是一個dom,不能存在Object里.
那可以用Map,因為Map的key可以是任意類型,包括對象,我們可以用Map存儲起來。
但是WeakMap用在這里好像會更好一點,因為WeakMap的key也可以是任意類型,并且WeakMap的key是弱引用,也就是說如果key所對應(yīng)的對象沒有其他引用,那么這個key就會被自動回收,用在這里正好合適, 用dom元素作為key,dom元素如果不存在了,對應(yīng)的key也會被自動回收,不會造成內(nèi)存泄漏,省掉了手動刪除key的麻煩。雖然WeakMap不能遍歷,但是我這里不用遍歷啊,只用通過key存和取就行了。
既然這樣,那選定WeakMap來存儲函數(shù)。
第一步具體實現(xiàn):
// 存儲函數(shù)的WeakMap
const weakMap = new WeakMap()
// 自定義指令
export default {
mounted(el, binding, vnode) {
// 拿到函數(shù)
const fun = binding.value
// 存儲函數(shù)
weakMap.set(el, fun)
},
}
就這樣,我通過binding.value拿到了自定義指令綁定的函數(shù),并且用WeakMap存儲起來了,key是dom元素,value是函數(shù),這樣我想用的時候就可以通過dom元素找到對應(yīng)的函數(shù)了。
第二歩
接下來,要監(jiān)聽dom元素是否進(jìn)入可視區(qū)范圍,這就需要用到IntersectionObserver了.
可能有些人對IntersectionObserver有些遺忘, 先來復(fù)習(xí)一下。
IntersectionObserver是一個交叉觀察器,可以用來監(jiān)聽元素是否進(jìn)入可視區(qū)范圍,這個構(gòu)造函數(shù)接收一個callback參數(shù),callback是一個函數(shù),會在監(jiān)聽的目標(biāo)元素可見和不可見時觸發(fā)
const io=new IntersectionObserver((entries)=>{
entries.forEach((entrie) => {
// 操作
})
})
// 監(jiān)聽目標(biāo)元素
io.observe(document.getElementById('container'))
// 停止監(jiān)聽
io.unobserve(document.getElementById('container'))
這個callback函數(shù)接收一個參數(shù)(entries),entries是一個數(shù)組,里面存放了一組IntersectionObserverEntry對象,每一個IntersectionObserverEntry對象對應(yīng)一個被觀察的目標(biāo)元素,有幾個觀察元素,就會有幾個IntersectionObserverEntry對象.
IntersectionObserverEntry對象有一些屬性,我這里著重講一下intersectionRatio屬性,因為待會會用到.
intersectionRatio是一個0-1之間的數(shù)字。如果目標(biāo)元素進(jìn)入可視區(qū),就會和可視區(qū)有一個重疊區(qū)域,也就是交叉區(qū)域,intersectionRatio表示的就是交叉區(qū)域占目標(biāo)元素的比例,如果目標(biāo)元素還沒進(jìn)入可視區(qū),intersectionRatio就是0,如果完全進(jìn)入可視區(qū),intersectionRatio就是1
了解完IntersectionObserver之后我們開始實現(xiàn)第二歩,監(jiān)聽元素是否進(jìn)入可視區(qū)
第二歩具體實現(xiàn):
const weakMap = new WeakMap()
// ---------- 第二歩 ------------------------
// 元素進(jìn)入可視區(qū) 要執(zhí)行的callback
function lazyEnter(entries) {
entries.forEach((entrie) => {
if (entrie.intersectionRatio > 0) {
}
})
}
const lazy = new IntersectionObserver(lazyEnter)
// ------------------------------------------
export default {
mounted(el, binding, vnode) {
const fun = binding.value
weakMap.set(el, fun)
// ---------- 第二歩 -------------------------
lazy.observe(el)
// ------------------------------------------
},
}
這樣我們通過IntersectionObserver完成了自動監(jiān)聽目標(biāo)元素是否進(jìn)入可視區(qū),一旦進(jìn)入可視區(qū)就會執(zhí)行lazyEnter方法。
細(xì)心的你肯定發(fā)現(xiàn)了 if (entrie.intersectionRatio > 0) 這個判斷. 為什么要加入這個判斷? 因為執(zhí)行l(wèi)azy.observe(el)開始監(jiān)聽元素時,無論元素可不可見,lazy的回調(diào)都會被調(diào)用一次,所以我們要加入這個判斷,只有元素進(jìn)入可視區(qū)才執(zhí)行操作。
第三歩
我們已經(jīng)開始監(jiān)聽元素是否可見了,下一步就是在元素可見時取出之前存儲的函數(shù)并執(zhí)行了,也就是在lazyEnter函數(shù)中取出函數(shù)并執(zhí)行。
之前通過el作為key,存儲了函數(shù),現(xiàn)在也需要通過el取出函數(shù),但是之前是在自定義指令的鉤子函數(shù)mounted中拿到的el,現(xiàn)在在lazyEnter如何拿到? 正好IntersectionObserverEntry對象中提供了這個屬性——target,是監(jiān)聽的目標(biāo)元素,通過這個屬性我們就可以拿到之前存儲的函數(shù)了。
第三步具體實現(xiàn):
const weakMap = new WeakMap()
function lazyEnter(entries) {
entries.forEach(async (entrie) => {
if (entrie.intersectionRatio > 0) {
// ---------- 第三歩 ------------
// 取出函數(shù)
const el=entrie.target
const fun = weakMap.get(el)
// 圖片懶加載要設(shè)置src屬性 需要特殊處理
const isImg = el.tagName === "IMG"
if(isImg){
el.src=(await fun()).default
}else{
// 執(zhí)行函數(shù)
fun()
}
// 停止監(jiān)聽
lazy.unobserve(el)
// ------------------------
}
})
}
const lazy = new IntersectionObserver(lazyEnter)
export default {
mounted(el, binding, vnode) {
const fun = binding.value
weakMap.set(el, fun)
lazy.observe(el)
},
}
到這里基本就完成了v-lazy的實現(xiàn),需要說明的是圖片處理部分,因為之前設(shè)計v-lazy時,對于圖片是這樣使用的:
<img v-lazy="()=>import("@/assets/images/...")" />
也就是說這個函數(shù)只是動態(tài)引入了圖片,對于圖片加載成功并顯示出來,還需要將引入的圖片鏈接設(shè)置到src屬性上,當(dāng)然也可以將這部分邏輯抽離出來作為函數(shù)傳遞,比如:
// 處理圖片加載成功的函數(shù)
async function lazyImg(el) {
const img = await import("@/assets/images/...")
el.src = img
}
<img v-lazy="lazyImg" />
相應(yīng)的自定義指令也需要修改lazyEnter的部分代碼:
function lazyEnter(entries) {
entries.forEach(async (entrie) => {
if (entrie.intersectionRatio > 0) {
// ---------- 第三歩 ------------
// 取出函數(shù)
const el = entrie.target
const fun = weakMap.get(el)
// 執(zhí)行函數(shù)
fun(el)
// 停止監(jiān)聽
lazy.unobserve(el)
// -----------------------------
}
})
}
這樣做雖然 v-lazy 指令是優(yōu)美一點, 但是加大了使用開銷,每次在img上使用v-lazy都需要重寫一遍邏輯,如果項目中使用了很多圖片,那這部分代碼的累積起來還是很恐怖的,所以我還是更偏向前一種.
圖片布局抖動問題
v-lazy 已經(jīng)基本實現(xiàn),但是還有一些問題需要精益求精一下。
圖片布局抖動問題,是因為一開始沒有給圖片設(shè)置寬高,導(dǎo)致圖片資源獲取完成后撐開圖片,讓圖片高度從無到有,造成布局抖動。
對于這個問題,只需要給圖片預(yù)留空間就可以解決,也就是給圖片一個占位,不會影響到其他元素,這樣就不會造成布局抖動的情況了。
給圖片預(yù)留空間要分兩種情況討論,一種是非響應(yīng)式布局,一種是響應(yīng)式布局。
對于非響應(yīng)式布局,圖片寬高一般不會變化,這樣可以給圖片容器設(shè)置一個固定寬高,這個寬高可以是圖片等比例寬高,也可以自定義寬高,自定義寬高最好搭配cssobject-fit: cover讓圖片自適應(yīng).
對于響應(yīng)式布局,因為布局是變化的,所以大部分圖片是隨父元素寬度變化的,在css上通常是下面這樣的:
img{
width:100%;
height:auto;
}
這樣的圖片沒有固定的寬高,而是隨父元素寬度自動變化,為了給這個圖片一個占位空間,就需要一個和圖片同比例的容器,并且能夠和圖片一樣根據(jù)父元素寬度變化而保持比例變化
通過給padding-top設(shè)置百分比可以實現(xiàn)這樣的圖片容器,因為padding-top:xx%中的百分?jǐn)?shù)是根據(jù)父元素的寬度計算的,類似padding-top:50%,就是一個2:1的盒子,并且這個比例會隨寬度變化保持不變,所以只需要傳遞圖片的寬高再加以計算就可以得到一個和圖片比例保持一致的圖片容器,封裝成組件IpImage:
<template>
<div class="img" :style="{ 'padding-top': paddingTop }">
<div class="container">
<slot></slot>
</div>
</div>
</template>
<script setup>
// 引入四舍五入函數(shù),默認(rèn)取小數(shù)點后兩位
import { roundToDecimal } from "../../utils/tools"
const props = defineProps({
width: Number,
height: Number,
})
const { width, height } = props
const paddingTop = roundToDecimal(height / width) * 100 + "%"
</script>
<style lang="less" scoped>
.img {
width: 100%;
position:relative;
.container{
position: absolute;
inset: 0;
}
}
</style>
假設(shè)我有一張500*400的圖片,我就可以把圖片包裹起來,形成一個占位,這樣就不會造成布局抖動了:
<IpImage :width="500" :height="400">
<img v-lazy="()=>import('@/assets/images/...')" alt="">
</IpImage>
圖片srcset和sizes
為了適應(yīng)不同屏幕分辨率的設(shè)備,img提供了srcset和sizes屬性,使得可以根據(jù)設(shè)備的分辨率使用不同的分辨率的圖片.
但是我們之前實現(xiàn)的v-lazy指令并不支持響應(yīng)式圖片,只會動態(tài)導(dǎo)入一張指定的圖片,這樣在高分辨率設(shè)備上圖片就會顯示得很模糊,為了解決這個問題,我們必須重新設(shè)計v-lazy,從而使v-lazy支持響應(yīng)式圖片.
仔細(xì)思考之后,我發(fā)現(xiàn)之前img標(biāo)簽的用法其實還是會加重使用負(fù)擔(dān),要寫一段import引入代碼,并且不太符合原生img標(biāo)簽的寫法,或許我們可以直接這樣使用:
<img
v-lazy
sizes="(max-width:768px) 764px,382px"
src="@/assets/images/home/banner.png"
srcset="@/assets/images/home/banner.png 382w,
@/assets/images/home/banner@2x.png 764w,
@/assets/images/home/banner@3x.png 1128w"
/>
只用加一個v-lazy指令就可以了, 這樣使用的話, 和我們原來沒有懶加載時的用法也是一模一樣. 并且可讀性更好, 很容易就知道這個img標(biāo)簽是懶加載的.
仔細(xì)思考良久,對于如何實現(xiàn)這個用法,并且保持懶加載功能,我有兩個想法:
- 在這個img標(biāo)簽掛載之前攔截掛載,然后在可見時再掛載
- 攔截屬性,在掛載之前拿到幾個屬性,存起來,可見時在重新設(shè)置上去
第一個想法,好像vue也沒提供攔截掛載的方案,只能控制顯示隱藏, 即使給img標(biāo)簽設(shè)置display:none,瀏覽器還是會正常加載其中的圖片資源. 沒辦法那只能從第二個想法入手了, 第二個想法實現(xiàn)起來就簡單多了, 因為vue自定義指令提供了掛載dom之前的鉤子函數(shù)beforeMount, 我只用在這里把屬性攔截下來, 在目標(biāo)元素可見時再把屬性添加到元素上就可以了.
具體實現(xiàn)(省略不作修改的代碼):
const weakMap = new WeakMap()
function lazyEnter(entries) {
entries.forEach(async (entrie) => {
if (entrie.intersectionRatio > 0) {
// ----------------這里有修改-------------------
const fun = weakMap.get(entrie.target)
await fun()
lazy.unobserve(el)
// ----------------------------------------------
}
})
}
const lazy = new IntersectionObserver(lazyEnter)
export default {
// ----------------這里有新添加的代碼-------------------
beforeMount(el, binding, vnode) {
if (!binding.value && vnode.type === "img") {
const { sizes = "", src = "", srcset = "" } = vnode.props
// 攔截屬性
el.removeAttribute("sizes")
el.removeAttribute("src")
el.removeAttribute("srcset")
// 在目標(biāo)元素可見時 執(zhí)行這個函數(shù) 把屬性添加到元素上
const fun = () => {
sizes && el.setAttribute("sizes", sizes)
src && el.setAttribute("src", src)
srcset && el.setAttribute("srcset", srcset)
}
binding.value = fun
}
},
// ----------------------------------------------
mounted(el, binding, vnode) {
const fun = binding.value
weakMap.set(el, fun)
lazy.observe(el)
},
}
這樣通過 攔截屬性, 再添加屬性 的方式就支持了響應(yīng)式圖片, 并且保留了懶加載的功能.
順便提一嘴
實際上img標(biāo)簽本身也提供了loading屬性支持懶加載, 上面的靈感也是來自于這個屬性的用法, 例如這樣:
<img
loading='lazy'
sizes="(max-width:768px) 764px,382px"
src="@/assets/images/home/banner.png"
srcset="@/assets/images/home/banner.png 382w,
@/assets/images/home/banner@2x.png 764w,
@/assets/images/home/banner@3x.png 1128w"
/>
對于 loading='lazy' MDN上是這樣描述的:
延遲加載圖像,直到它和視口接近到一個計算得到的距離(由瀏覽器定義)。目的是在需要圖像之前,避免加載圖像所需要的網(wǎng)絡(luò)和存儲帶寬。這通常會提高大多數(shù)典型用場景中內(nèi)容的性能。
一開始我也是準(zhǔn)備用loading屬性的, 但是實際測試時我發(fā)現(xiàn)我的Edge瀏覽器不支持這個屬性, 更新了一下瀏覽器后倒是可以了, 然后查了一下兼容性, 發(fā)現(xiàn)loading='lazy'屬性的兼容性比IntersectionObserver API要差很多, 最后果斷放棄了.
兼容性問題
現(xiàn)代瀏覽器已經(jīng)廣泛支持 IntersectionObserver API, 支持vue3的瀏覽器大多都支持這個API, 如果要用vue2實現(xiàn)上面的v-lazy指令然后考慮兼容性 或者 兼容支持vue3不支持IntersectionObserver API的這類瀏覽器, 為了不用大范圍修改v-lazy指令的代碼, 那就用scroll+getBoundingClientRect手搓一個簡單版的IntersectionObserver吧, 其實我們實現(xiàn)的v-lazy指令只用到了IntersectionObserver API 的一點點方法和屬性, 實現(xiàn)起來也不難.
用到的方法:
observe()unobserve()
用到的屬性:
intersectionRatiotarget
貼一下我用scroll+getBoundingClientRect實現(xiàn)的簡單版IntersectionObserver, 提供一個思路
// 引入防抖 和 節(jié)流 方法
import { debounce, throttle } from "@/utils/tools"
class MyIntersectionObserver {
constructor(callback) {
this.callback = callback
this.targets = new Map()
this.debounceCheckIntersections = debounce(this.checkIntersections.bind(this), 50)
this.throttleCheckIntersections = throttle(this.checkIntersections.bind(this), 100)
// 監(jiān)聽滾動和調(diào)整大小事件
window.addEventListener("scroll", this.throttleCheckIntersections, true)
window.addEventListener("resize", this.throttleCheckIntersections, true)
}
// 獲取根元素的邊界矩形
getRootRect() {
return {
top: 0,
left: 0,
bottom: window.innerHeight,
right: window.innerWidth,
width: window.innerWidth,
height: window.innerHeight,
}
}
// 檢查目標(biāo)元素與根元素的相交情況
checkIntersections() {
const rootRect = this.getRootRect()
const entries = []
for (const [target, _] of this.targets) {
const targetRect = target.getBoundingClientRect()
// 根元素 和 目標(biāo)元素是否相交
const isIntersecting =
(0 < targetRect.top && targetRect.top < rootRect.bottom) || (0 < targetRect.bottom && targetRect.bottom < rootRect.bottom)
// 交叉區(qū)域
const intersectionRect = {
top: isIntersecting ? Math.max(rootRect.top, targetRect.top) : 0,
left: isIntersecting ? Math.max(rootRect.left, targetRect.left) : 0,
bottom: isIntersecting ? Math.min(rootRect.bottom, targetRect.bottom) : 0,
right: isIntersecting ? Math.min(rootRect.right, targetRect.right) : 0,
}
intersectionRect.width = Math.max(0, intersectionRect.right - intersectionRect.left)
intersectionRect.height = Math.max(0, intersectionRect.bottom - intersectionRect.top)
const targetArea = targetRect.width * targetRect.height
const intersectionArea = intersectionRect.width * intersectionRect.height
// 交叉區(qū)域 占目標(biāo)元素的比例
// 注意: 圖片還不可見時, 沒有加載資源, 也就沒有高度, 相交區(qū)域也為0, 但是實際上img可能已經(jīng)進(jìn)入可視區(qū)了, 需要特殊判斷一下
const intersectionRatio = targetArea > 0 ? intersectionArea / targetArea : isIntersecting ? 1 : 0
entries.push({
target,
intersectionRect,
intersectionRatio,
boundingClientRect: targetRect,
isIntersecting,
time: Date.now(),
})
}
// 有元素可見才觸發(fā)callback, 實際上IntersectionObserver API不可見時也會觸發(fā)一次,但是v-lazy沒有用到不可見的這次觸發(fā),所以不用管
if (entries.some((item) => item.intersectionRatio > 0)) {
this.callback(entries)
}
}
observe(target) {
if (!this.targets.has(target)) {
this.targets.set(target, true)
// 因為可能有的元素一開始就在可視區(qū) 但是添加的監(jiān)聽只有觸發(fā)scroll事件才會檢查相交情況
// 所以需要等待所有元素都添加監(jiān)聽完成后再統(tǒng)一執(zhí)行一次 checkIntersections 方法,檢查相交情況
// 這里利用防抖可以實現(xiàn), 因為防抖在一段時間內(nèi)高頻觸發(fā)只會執(zhí)行最后一次
this.debounceCheckIntersections()
}
}
unobserve(target) {
this.targets.delete(target)
}
disconnect() {
this.targets.clear()
window.removeEventListener("scroll", this.throttleCheckIntersections)
window.removeEventListener("resize", this.throttleCheckIntersections)
}
}
export default MyIntersectionObserver
到此這篇關(guān)于vue自定義指令實現(xiàn)懶加載的優(yōu)化指南的文章就介紹到這了,更多相關(guān)vue懶加載內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Vue3新特性Suspense和Teleport應(yīng)用場景分析
本文介紹了Vue2和Vue3中的Suspense用于處理異步請求的加載提示,以及如何在組件間實現(xiàn)動態(tài)加載,同時,Teleport技術(shù)展示了如何在DOM中靈活地控制組件的渲染位置,解決布局問題,感興趣的朋友跟隨小編一起看看吧2024-07-07
Vue實現(xiàn)數(shù)據(jù)表格合并列rowspan效果
這篇文章主要為大家詳細(xì)介紹了Vue實現(xiàn)數(shù)據(jù)表格合并列rowspan效果,文中示例代碼介紹的非常詳細(xì),具有一定的參考價值,感興趣的小伙伴們可以參考一下2019-07-07
vue 中 element-ui table合并上下兩行相同數(shù)據(jù)單元格
這篇文章主要介紹了vue 中 element-ui table合并上下兩行相同數(shù)據(jù)單元格,本文通過實例代碼給大家介紹的非常詳細(xì),具有一定的參考借鑒價值,需要的朋友可以參考下2019-12-12
Vue中mintui的field實現(xiàn)blur和focus事件的方法
今天小編就為大家分享一篇Vue中mintui的field實現(xiàn)blur和focus事件的方法,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2018-08-08
vue調(diào)用谷歌授權(quán)登錄獲取用戶通訊錄的實現(xiàn)示例
本文主要介紹了vue調(diào)用谷歌授權(quán)登錄獲取用戶通訊錄的實現(xiàn)示例,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2022-07-07
關(guān)于在vue-cli中使用微信自動登錄和分享的實例
本篇文章主要介紹了關(guān)于在vue-cli中使用微信自動登錄和分享的實例,具有一定的參考價值,感興趣的小伙伴們可以參考一下2017-06-06

