vue自定义指令实现懒加载的优化指南
作者:骑着乌龟去看海818
前言
今天搞首页优化的时候,发现首页有很多内容,需要用户向下滚动才会看到,这部分内容包括很多图片、组件、请求,这些请求其实都可以等到用户可见时再开始加载的,因为之前没有做处理,首页一加载弹出了一堆网络请求,这些网络请求和首页关键资源一起加载,争抢带宽,拖慢了关键资源的加载速度。
设计
首先就是想清楚我的需求。我要做到的是懒加载资源,就是用户能看到的时候才加载相应的资源,这里用户能看到也就是进入可视区范围内。
这个可以通过IntersectionObserver
API实现,因为IntersectionObserver
会自动监听dom是否移动到可视区范围,监听到之后会调用callback执行,这里的callback我传递一个加载资源的函数就可以了,这样,当移动到可视区范围内我就可以动态加载资源了。
因为项目中需要这样懒加载的资源很多,并且分布在不同位置,不可能每个都手动去写代码实现,所以我想要一个通用的并且能用很少的代码就应用在不同目录下的vue文件中,那这个可以用组件或自定义指令去实现
当然这两种方案都可以,我这里选择自定义指令,因为用起来代码会更少,更方便一点。
自定义指令就叫v-lazy,在dom标签上我可以规定这样使用:
<!-- 图片懒加载 --> <img v-lazy="()=>import("@/assets/images/...")" /> <!-- 组件懒加载 可以配合异步组件实现 --> <div v-lazy="()=>show.value=true"> <ProductList v-if="show"/> </div> <!-- 延迟请求接口数据 --> <div v-lazy="getListData"> <ul> <li v-for="item in List">{{item}}</li> </ul> </div>
只用传递一个方法告诉v-lazy指令,当目标元素出现在可视区时才执行就行了,接下来开始着手实现v-lazy指令
注:因为项目式Vue3,所以整体代码采用vue3写法,如果项目是Vue2的话,用Vue2实现也可以,毕竟思路是相通的。
v-lazy实现
跟大象装进冰箱一样,大部分的逻辑实现都可以简单划分成三步,每一步带着目的去完成,这样一步一步到完成核心代码, 最后再进行一些边边角角的修补
现在把 v-lazy 指令实现简按划分成三个步骤:
拿到自定义指令绑定的方法,也就是要执行的函数,存储起来。
通过IntersectionObserver
监听目标元素是否进入可视区范围。
当目标元素进入可视区范围时,就取出存储的函数,执行。
这样一看是不是简单多了,只需要按顺序完成上面三个步骤,我们就实现了v-lazy指令。
第一步
首先完成第一步,通过binding
参数可以拿到函数,接下来如何存储呢,平常的Object肯定不行,因为Object的key只能是字符串,但是这里的key应该和目标元素联系起来,目标元素是一个dom,不能存在Object里.
那可以用Map
,因为Map
的key可以是任意类型,包括对象,我们可以用Map
存储起来。
但是WeakMap
用在这里好像会更好一点,因为WeakMap
的key也可以是任意类型,并且WeakMap
的key是弱引用,也就是说如果key所对应的对象没有其他引用,那么这个key就会被自动回收,用在这里正好合适, 用dom元素作为key,dom元素如果不存在了,对应的key也会被自动回收,不会造成内存泄漏,省掉了手动删除key的麻烦。虽然WeakMap
不能遍历,但是我这里不用遍历啊,只用通过key存和取就行了。
既然这样,那选定WeakMap
来存储函数。
第一步具体实现:
// 存储函数的WeakMap const weakMap = new WeakMap() // 自定义指令 export default { mounted(el, binding, vnode) { // 拿到函数 const fun = binding.value // 存储函数 weakMap.set(el, fun) }, }
就这样,我通过binding.value
拿到了自定义指令绑定的函数,并且用WeakMap
存储起来了,key是dom元素,value是函数,这样我想用的时候就可以通过dom元素找到对应的函数了。
第二歩
接下来,要监听dom元素是否进入可视区范围,这就需要用到IntersectionObserver
了.
可能有些人对IntersectionObserver
有些遗忘, 先来复习一下。
IntersectionObserver
是一个交叉观察器,可以用来监听元素是否进入可视区范围,这个构造函数接收一个callback参数,callback是一个函数,会在监听的目标元素可见和不可见时触发
const io=new IntersectionObserver((entries)=>{ entries.forEach((entrie) => { // 操作 }) }) // 监听目标元素 io.observe(document.getElementById('container')) // 停止监听 io.unobserve(document.getElementById('container'))
这个callback函数接收一个参数(entries),entries是一个数组,里面存放了一组IntersectionObserverEntry
对象,每一个IntersectionObserverEntry
对象对应一个被观察的目标元素,有几个观察元素,就会有几个IntersectionObserverEntry
对象.
IntersectionObserverEntry
对象有一些属性,我这里着重讲一下intersectionRatio
属性,因为待会会用到.
intersectionRatio
是一个0-1之间的数字。如果目标元素进入可视区,就会和可视区有一个重叠区域,也就是交叉区域,intersectionRatio
表示的就是交叉区域占目标元素的比例,如果目标元素还没进入可视区,intersectionRatio
就是0,如果完全进入可视区,intersectionRatio
就是1
了解完IntersectionObserver
之后我们开始实现第二歩,监听元素是否进入可视区
第二歩具体实现:
const weakMap = new WeakMap() // ---------- 第二歩 ------------------------ // 元素进入可视区 要执行的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
完成了自动监听目标元素是否进入可视区,一旦进入可视区就会执行lazyEnter
方法。
细心的你肯定发现了 if (entrie.intersectionRatio > 0)
这个判断. 为什么要加入这个判断? 因为执行lazy.observe(el)开始监听元素时,无论元素可不可见,lazy的回调都会被调用一次,所以我们要加入这个判断,只有元素进入可视区才执行操作。
第三歩
我们已经开始监听元素是否可见了,下一步就是在元素可见时取出之前存储的函数并执行了,也就是在lazyEnter
函数中取出函数并执行。
之前通过el作为key,存储了函数,现在也需要通过el取出函数,但是之前是在自定义指令的钩子函数mounted中拿到的el,现在在lazyEnter
如何拿到? 正好IntersectionObserverEntry
对象中提供了这个属性——target
,是监听的目标元素,通过这个属性我们就可以拿到之前存储的函数了。
第三步具体实现:
const weakMap = new WeakMap() function lazyEnter(entries) { entries.forEach(async (entrie) => { if (entrie.intersectionRatio > 0) { // ---------- 第三歩 ------------ // 取出函数 const el=entrie.target const fun = weakMap.get(el) // 图片懒加载要设置src属性 需要特殊处理 const isImg = el.tagName === "IMG" if(isImg){ el.src=(await fun()).default }else{ // 执行函数 fun() } // 停止监听 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
的实现,需要说明的是图片处理部分,因为之前设计v-lazy时,对于图片是这样使用的:
<img v-lazy="()=>import("@/assets/images/...")" />
也就是说这个函数只是动态引入了图片,对于图片加载成功并显示出来,还需要将引入的图片链接设置到src属性上,当然也可以将这部分逻辑抽离出来作为函数传递,比如:
// 处理图片加载成功的函数 async function lazyImg(el) { const img = await import("@/assets/images/...") el.src = img } <img v-lazy="lazyImg" />
相应的自定义指令也需要修改lazyEnter
的部分代码:
function lazyEnter(entries) { entries.forEach(async (entrie) => { if (entrie.intersectionRatio > 0) { // ---------- 第三歩 ------------ // 取出函数 const el = entrie.target const fun = weakMap.get(el) // 执行函数 fun(el) // 停止监听 lazy.unobserve(el) // ----------------------------- } }) }
这样做虽然 v-lazy 指令是优美一点, 但是加大了使用开销,每次在img上使用v-lazy都需要重写一遍逻辑,如果项目中使用了很多图片,那这部分代码的累积起来还是很恐怖的,所以我还是更偏向前一种.
图片布局抖动问题
v-lazy
已经基本实现,但是还有一些问题需要精益求精一下。
图片布局抖动问题,是因为一开始没有给图片设置宽高,导致图片资源获取完成后撑开图片,让图片高度从无到有,造成布局抖动。
对于这个问题,只需要给图片预留空间就可以解决,也就是给图片一个占位,不会影响到其他元素,这样就不会造成布局抖动的情况了。
给图片预留空间要分两种情况讨论,一种是非响应式布局,一种是响应式布局。
对于非响应式布局,图片宽高一般不会变化,这样可以给图片容器设置一个固定宽高,这个宽高可以是图片等比例宽高,也可以自定义宽高,自定义宽高最好搭配cssobject-fit: cover
让图片自适应.
对于响应式布局,因为布局是变化的,所以大部分图片是随父元素宽度变化的,在css上通常是下面这样的:
img{ width:100%; height:auto; }
这样的图片没有固定的宽高,而是随父元素宽度自动变化,为了给这个图片一个占位空间,就需要一个和图片同比例的容器,并且能够和图片一样根据父元素宽度变化而保持比例变化
通过给padding-top
设置百分比可以实现这样的图片容器,因为padding-top:xx%
中的百分数是根据父元素的宽度计算的,类似padding-top:50%
,就是一个2:1的盒子,并且这个比例会随宽度变化保持不变,所以只需要传递图片的宽高再加以计算就可以得到一个和图片比例保持一致的图片容器,封装成组件IpImage
:
<template> <div class="img" :style="{ 'padding-top': paddingTop }"> <div class="container"> <slot></slot> </div> </div> </template> <script setup> // 引入四舍五入函数,默认取小数点后两位 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>
假设我有一张500*400的图片,我就可以把图片包裹起来,形成一个占位,这样就不会造成布局抖动了:
<IpImage :width="500" :height="400"> <img v-lazy="()=>import('@/assets/images/...')" alt=""> </IpImage>
图片srcset和sizes
为了适应不同屏幕分辨率的设备,img提供了srcset和sizes属性,使得可以根据设备的分辨率使用不同的分辨率的图片.
但是我们之前实现的v-lazy
指令并不支持响应式图片,只会动态导入一张指定的图片,这样在高分辨率设备上图片就会显示得很模糊,为了解决这个问题,我们必须重新设计v-lazy
,从而使v-lazy
支持响应式图片.
仔细思考之后,我发现之前img标签的用法其实还是会加重使用负担,要写一段import引入代码,并且不太符合原生img标签的写法,或许我们可以直接这样使用:
<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标签是懒加载的.
仔细思考良久,对于如何实现这个用法,并且保持懒加载功能,我有两个想法:
- 在这个img标签挂载之前拦截挂载,然后在可见时再挂载
- 拦截属性,在挂载之前拿到几个属性,存起来,可见时在重新设置上去
第一个想法,好像vue也没提供拦截挂载的方案,只能控制显示隐藏, 即使给img标签设置display:none
,浏览器还是会正常加载其中的图片资源. 没办法那只能从第二个想法入手了, 第二个想法实现起来就简单多了, 因为vue自定义指令提供了挂载dom之前的钩子函数beforeMount
, 我只用在这里把属性拦截下来, 在目标元素可见时再把属性添加到元素上就可以了.
具体实现(省略不作修改的代码):
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") // 在目标元素可见时 执行这个函数 把属性添加到元素上 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) }, }
这样通过 拦截属性, 再添加属性 的方式就支持了响应式图片, 并且保留了懒加载的功能.
顺便提一嘴
实际上img标签本身也提供了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上是这样描述的:
延迟加载图像,直到它和视口接近到一个计算得到的距离(由浏览器定义)。目的是在需要图像之前,避免加载图像所需要的网络和存储带宽。这通常会提高大多数典型用场景中内容的性能。
一开始我也是准备用loading
属性的, 但是实际测试时我发现我的Edge浏览器不支持这个属性, 更新了一下浏览器后倒是可以了, 然后查了一下兼容性, 发现loading='lazy'
属性的兼容性比IntersectionObserver
API要差很多, 最后果断放弃了.
兼容性问题
现代浏览器已经广泛支持 IntersectionObserver
API, 支持vue3的浏览器大多都支持这个API, 如果要用vue2实现上面的v-lazy
指令然后考虑兼容性 或者 兼容支持vue3不支持IntersectionObserver
API的这类浏览器, 为了不用大范围修改v-lazy
指令的代码, 那就用scroll
+getBoundingClientRect
手搓一个简单版的IntersectionObserver
吧, 其实我们实现的v-lazy
指令只用到了IntersectionObserver
API 的一点点方法和属性, 实现起来也不难.
用到的方法:
observe()
unobserve()
用到的属性:
intersectionRatio
target
贴一下我用scroll
+getBoundingClientRect
实现的简单版IntersectionObserver
, 提供一个思路
// 引入防抖 和 节流 方法 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) // 监听滚动和调整大小事件 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, } } // 检查目标元素与根元素的相交情况 checkIntersections() { const rootRect = this.getRootRect() const entries = [] for (const [target, _] of this.targets) { const targetRect = target.getBoundingClientRect() // 根元素 和 目标元素是否相交 const isIntersecting = (0 < targetRect.top && targetRect.top < rootRect.bottom) || (0 < targetRect.bottom && targetRect.bottom < rootRect.bottom) // 交叉区域 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 // 交叉区域 占目标元素的比例 // 注意: 图片还不可见时, 没有加载资源, 也就没有高度, 相交区域也为0, 但是实际上img可能已经进入可视区了, 需要特殊判断一下 const intersectionRatio = targetArea > 0 ? intersectionArea / targetArea : isIntersecting ? 1 : 0 entries.push({ target, intersectionRect, intersectionRatio, boundingClientRect: targetRect, isIntersecting, time: Date.now(), }) } // 有元素可见才触发callback, 实际上IntersectionObserver API不可见时也会触发一次,但是v-lazy没有用到不可见的这次触发,所以不用管 if (entries.some((item) => item.intersectionRatio > 0)) { this.callback(entries) } } observe(target) { if (!this.targets.has(target)) { this.targets.set(target, true) // 因为可能有的元素一开始就在可视区 但是添加的监听只有触发scroll事件才会检查相交情况 // 所以需要等待所有元素都添加监听完成后再统一执行一次 checkIntersections 方法,检查相交情况 // 这里利用防抖可以实现, 因为防抖在一段时间内高频触发只会执行最后一次 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
到此这篇关于vue自定义指令实现懒加载的优化指南的文章就介绍到这了,更多相关vue懒加载内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!