前言
大家好,我是沐浴在曙光下的贰货道士 。最近在处理收单系统的过程中,发现虽然有使用阿里云去压缩订单列表的图片,但由于列表图片数目过多,导致网页渲染较慢。为此,我研究了利用IntersectionObserver
和element
源码实现图片懒加载的思路。本文只提供一个解决懒加载问题的小demo
,如果掘友们有更好的处理方式,欢迎在评论区指点江山。同时,有喜欢本文的朋友,也欢迎一键三连哦~
图片懒加载
何为图片懒加载?图片懒加载是一种优化网页加载速度的技术,它可以推迟图片的加载时间,直到图片进入用户的视野范围内才会进行加载。这样能够减少页面的初始加载时间和带宽使用,提升用户体验。
通常情况下,网页中的图片会在页面加载完成后立即进行加载。这意味着即使用户并没有看到这些图片,它们仍然会占用带宽和加载时间,导致页面加载速度变慢。而图片懒加载技术则可以解决这个问题,它只会加载用户当前视野范围内的图片,而不会加载其他图片,从而减少页面的加载时间和带宽使用。
理解图片懒加载的概念后,我们就能掌握实现图片懒加载的核心思想。
判断图片是否出现在可视区域:
-
如果图片未出现在可视区域,则不需要加载图片。我们可以通过使用提前占位(给定和需要懒加载图片同样大小的容器,类似没有样式的骨架屏。或者直接为图片加上
loading
效果,防止由于后续出现的图片导致页面抖动) 的方式去处理。 -
如果图片出现在可视区域,则展示图片。
图片懒加载方法
1. 使用 IntersectionObserver
核心思想:
- 为需要懒加载的图片添加
src
属性,默认展示loading
状态下的图片 - 为需要懒加载的图片添加自定义属性
data-src
, 用于接收图片的真实地址 - 找到页面上所有
img
标签对应的dom
元素, 使用IntersectionObserver
对它们进行监听 - 如果图片
dom
出现在可视区域,则将src
属性替换为图片的真实地址
a. 简单实现
html
`对于占位的loading图片,因为他们时相同图片,所以浏览器只会加载一次`
<template>
<div class="lazy-warpper">
<img
ref="img"
v-for="src in imageList"
:key="src"
:src="require('@/assets/images/loading.gif')"
:data-src="src"
/>
</div>
</template>
<script>
import { getPrototypeList } from '@/api/product'
import { REQUEST_ALL_DATA } from '@/utils/constant'
import { throttle, map } from 'lodash'
export default {
data() {
return {
imageList: []
}
},
async mounted() {
await this.getImageList()
this.initObserver()
},
methods: {
async getImageList() {
const res = await awaitResolve(getPrototypeList({ ...REQUEST_ALL_DATA }))
if (!res) return
this.imageList = map(res.detail, `styleList[0].displayImageUrl`)
},
initObserver() {
const observer = new IntersectionObserver(
`节流函数可用可不用,因为回调方法并不复杂`
throttle((entries) => {
entries.forEach(({ isIntersecting, target }) => {
`isIntersecting用于判断dom元素target是否进入了视口`
if (isIntersecting) {
`获取dom元素自定义属性的方式一:`
target.src = target.dataset.src
`获取dom元素自定义属性的方式二:`
// target.src = target.getAttribute('data-src')
`移除的意义在于,防止加载过的图片再次出现在视口时,重新执行回调函数`
observer.unobserve(target)
}
})
}, 200),
{
`root的指向:需要懒加载元素的最近具有滚动条的祖先dom元素`
root: document.querySelector('.topic-page')
}
)
this.$refs.img.forEach((image) => {
`开始监听之后,才会触发回调`
observer.observe(image)
})
}
}
}
</script>
<style lang="scss" scoped>
.lazy-warpper {
img {
width: 300px;
height: 300px;
object-fit: contain;
display: block;
border: 1px solid #ccc;
margin-bottom: 20px;
}
}
</style>
实现效果:
b. 从图像懒加载,过渡到万物皆可懒加载
核心思想:
- 需要懒加载的
dom
元素最好使用v-if
来控制。如果使用v-show
来控制,其实页面还是渲染了这些dom
, 只不过通过display: none
给隐藏掉了。 - 在懒加载的
dom
元素未出现之前,使用等高等宽的loading
图片进行占位,防止页面闪烁 - 因为需要懒加载的
dom
在页面初始化时是隐藏的,所以无法通过IntersectionObserver
去监听页面上需要懒加载的dom
。既然无法监听需要懒加载的dom
, 我们可以换一种思路:去监听它的父级(即整个子组件)。只要这个子组件出现在了可视区域,我们就将visible
置为true
, 需要懒加载的dom
自然就显现真容了。 - 为什么循环需要放在懒加载组件上? 因为懒加载组件下,默认插槽的内容会被视为需要懒加载的
dom
元素。如果循环放在具有lazy-warpper
的div
元素上,因为页面初始化时,懒加载组件就出现在可视区域,此时所有循环的卡片都会渲染,会失去懒加载的效果。而单独放在一个懒加载组件下,只是在当前懒加载组件出现在可视区域时,加载当前需要渲染的某个卡片,也就有了懒加载的效果。
js
`公共方法:`
`是否是数字`
export function isNumber(val){
`非负浮点数`
var regPos = /^\d+(\.\d+)?$/
`负浮点数`
var regNeg = /^(-(([0-9]+\.[0-9]*[1-9][0-9]*)|([0-9]*[1-9][0-9]*\.[0-9]+)|([0-9]*[1-9][0-9]*)))$/
return regPos.test(val) || regNeg.test(val)
}
`设置px`
export function setPx(val, defval) {
if (validatenull(val)) {
val = defval
}
val = val + ''
if (val.indexOf('%') === -1 && isNumber(val)) {
val = val + 'px'
}
return val
}
html
`子组件: lazyload.vue`
<template>
<div ref="container">
<slot v-if="visible"></slot>
<el-image
v-else
fit="contain"
:src="require('@/assets/images/loading.gif')"
:style="{
width: setPx(width),
height: setPx(height),
marginBottom: setPx(mb)
}"
></el-image>
</div>
</template>
<script>
import { throttle } from 'lodash'
import { setPx } from '@/components/avue/utils/util'
export default {
props: {
`IntersectionObserver的第二个参数,因为其本身就具备默认值,就没有用计算属性finalOption`
`一般的组件封装,会给定一个默认配置项,比如defaultOption,以及传入的option`
`那么计算属性finalOption({defaultOption, option})的值,结果就是{...defaultOption, ...option}`
`这样既不会影响defaultOption,后面传入的option如果和defaultOption有冲突,也会以后面的为准`
`因为这个栗子比较特殊,就没做类似处理`
option: Object,
width: String | Number,
height: String | Number,
mb: {
type: Number,
default: 20
},
`节流时间控制`
throttleTime: {
type: Number,
default: 200
}
},
data() {
return {
visible: false
}
},
mounted() {
this.initObserver()
},
methods: {
setPx,
initObserver() {
const observer = new IntersectionObserver(
throttle((entries) => {
entries.forEach(({ isIntersecting, target }) => {
if (isIntersecting) {
this.visible = true
observer.unobserve(target)
}
})
}, this.throttleTime),
this.option
)
;[this.$refs.container].forEach((dom) => {
observer.observe(dom)
})
}
}
}
</script>
html
`父组件`
<template>
<div class="lazy-container">
<lazyLoad v-for="(src, index) in imageList" :key="index" width="300" height="330" :option="option">
<div class="lazy-warpper">
<el-image class="image" :src="src" fit="contain" />
<div class="mb20">{{ getTitle(index) }}</div>
</div>
</lazyLoad>
</div>
</template>
<script>
import lazyLoad from './LazyLoad'
import { getPrototypeList } from '@/api/product'
import { REQUEST_ALL_DATA } from '@/utils/constant'
import { map } from 'lodash'
export default {
components: { lazyLoad },
data() {
return {
imageList: [],
option: {}
}
},
mounted() {
this.option = {
`.topic-page为最近的滚动父级元素类`
root: document.querySelector('.topic-page')
}
this.getImageList()
},
methods: {
async getImageList() {
const res = await awaitResolve(getPrototypeList({ ...REQUEST_ALL_DATA }))
if (!res) return
this.imageList = map(res.detail, `styleList[0].displayImageUrl`)
},
getTitle(index) {
return `产品 ${index + 1}`
}
}
}
</script>
<style lang="scss" scoped>
.lazy-warpper {
.image {
width: 300px;
height: 300px;
display: block;
border: 1px solid #ccc;
margin-bottom: 10px;
}
}
</style>
实现效果:
2. 使用element
源码懒加载的思路(getBoundingClientRect()与碰撞思想的花火)
核心思想和使用IntersectionObserver
这一小节半斤八两,只不过判断需要懒加载的dom
元素是否有进入可视区域的方法不一样。
- 当我们使用
IntersectionObserve
监听需要懒加载的dom
元素后,其回调函数中提供了一个内置参数isIntersecting
,用于判断当前dom
元素是否有出现在可视区域中。由于在监听需要懒加载的dom
元素,设置root
(不设置即为视口)后,这个api
的回调函数会自动帮我们实时计算当前懒加载元素是否出现在root
中,所以不需要额外去监听滚动事件。 - 而使用
lazyDom.getBoundingClientRect()
时,由于需要懒加载的dom
是实时变化的,所以需要在页面初始化时监听滚动事件 ,实时监听当前需要懒加载的dom
元素是否有出现在视口中。而且由于页面初始化时,并未触发滚动事件,但是那些出现在视口中的dom
元素,是需要一开始就加载的。为此,我们需要在页面初始化时,手动调用一次监听页面滚动事件的方法,以期正常加载出现在视口中的dom
元素,而且需要在beforeDestroy
钩子函数中移除监听的滚动事件。
js
`以下是element内部封装的一个方法,用于判断当前组件的dom元素el是否有出现在可视区域container中`
export const isInContainer = (el, container) => {
if (isServer || !el || !container) return false;
const elRect = el.getBoundingClientRect();
let containerRect;
`当container是全局对象或者未定义时,会将整个浏览器窗口视为containerRect`
`此时containerRect铺满整个可视区域,top和left与浏览器视口的距离自然为0`
`bottom即为浏览器的高,right即为浏览器的宽`
if ([window, document, document.documentElement, null, undefined].includes(container)) {
containerRect = {
top: 0,
right: window.innerWidth,
bottom: window.innerHeight,
left: 0
};
} else {
`当container是特定dom元素时,就用getBoundingClientRect()这个api,`
`计算这个dom元素与浏览器视口的距离`
containerRect = container.getBoundingClientRect();
}
`判断elRect是否与containerRect碰撞的核心思想:`
``
return elRect.top < containerRect.bottom &&
elRect.bottom > containerRect.top &&
elRect.right > containerRect.left &&
elRect.left < containerRect.right;
}
浅析两个dom
元素的碰撞原理:
el
如果要想出现在container
垂直方向内部,必须同时满足两个条件:el
的bottom
必须大于container
的top
,此时el
从container
上方进入container
。为了避免el
在竖直方向上离开container
(即出现在container
下方),此时需要满足container
的bottom
大于el
的top
;- 两个
dom
元素想要碰撞,仅仅在竖直方向上满足条件是不行的。因为el
在container
垂直方向内部,但是它可能向container
左边或者右边偏移,此时无法达到这两个dom
有相交重叠的目的。el
除了需要满足在container
垂直方向内部, 还需要满足在container
水平方向内部,这时才能达到真正意义上的相交和重叠。同理,在水平方向上,需要满足el
的left
需要小于container
的right
,el
的right
需要大于container
的left
。
a. 简单实现
js
<template>
<div class="lazy-warpper">
<img ref="img" v-for="src in imageList" :key="src" :src="require('@/assets/images/loading.gif')" :data-src="src" />
</div>
</template>
<script>
import { getPrototypeList } from '@/api/product'
import { REQUEST_ALL_DATA } from '@/utils/constant'
import { isInContainer } from 'element-ui/src/utils/dom'
import { map } from 'lodash'
export default {
data() {
return {
imageList: []
}
},
async mounted() {
await this.getImageList()
`一般情况下,是监听dom上某个滚动元素的滚动事件, 即document.querySelector('.topic-page')上绑定`
`但是我们可以指定addEventListener的第三个参数,来判断事件处理函数是在捕获阶段还是冒泡阶段被调用`
`true: 捕获。从最顶层的元素开始传播,沿着 DOM 树向下传播,直到到达最具体的元素。`
`false: 冒泡。事件首先被触发在最具体的元素上,然后沿着DOM树依次向上传播,直到到达最顶层的元素(一般window)`
document.addEventListener('scroll', this.onScroll, true)
this.onScroll()
this.$once('hook:beforeDestroy', () => document.removeEventListener('scroll', this.onScroll, true))
},
methods: {
async getImageList() {
const res = await awaitResolve(getPrototypeList({ ...REQUEST_ALL_DATA }))
if (!res) return
this.imageList = map(res.detail, `styleList[0].displayImageUrl`)
},
onScroll() {
const root = document.querySelector('.topic-page')
this.$refs.img.forEach((image) => {
const isIntersecting = isInContainer(image, root)
if (isIntersecting && !image.dataset.load) {
image.src = image.dataset.src
image.dataset.load = true
}
})
}
}
}
</script>
<style lang="scss" scoped>
.lazy-warpper {
img {
width: 300px;
height: 300px;
object-fit: contain;
display: block;
border: 1px solid #ccc;
margin-bottom: 20px;
}
}
</style>
b. 从图像懒加载,过渡到万物皆可懒加载
html
`子组件:`
<template>
<div ref="container">
<slot v-if="visible"></slot>
<el-image
v-else
fit="contain"
:src="require('@/assets/images/loading.gif')"
:style="{
width: setPx(width),
height: setPx(height),
marginBottom: setPx(mb)
}"
></el-image>
</div>
</template>
<script>
import { isInContainer } from 'element-ui/src/utils/dom'
import { setPx } from '@/components/avue/utils/util'
export default {
props: {
option: {
type: Object,
default: {
root: document.querySelector('.app-container')
}
},
width: String | Number,
height: String | Number,
mb: {
type: Number,
default: 20
}
},
data() {
return {
visible: false
}
},
mounted() {
document.addEventListener('scroll', this.onScroll, true)
this.onScroll()
this.$once('hook:beforeDestroy', () => document.removeEventListener('scroll', this.onScroll, true))
},
methods: {
setPx,
onScroll() {
;[this.$refs.container].forEach((dom) => {
const isIntersecting = isInContainer(dom, this.option.root)
if (isIntersecting && !dom.dataset.load) {
this.visible = true
dom.dataset.load = true
}
})
}
}
}
</script>
html
`父组件和使用IntersectionObserver b小节的父组件一样,就不再赘述`
c. 灵魂拷问
通过对比两种方法,我们不难发现,相比getBoundingClientRect
,使用IntersectionObserve
操作起来会更加简单。那我们为什么不使用IntersectionObserve
呢?答案其实很简单:条条大路通罗马,解决问题的方式有成千上万种。但是作为前端开发,除了完成基本功能之外,我们还需要考虑很多其它东西,比如兼容性。IntersectionObserve
是一个比较新的api
,相比于getBoundingClientRect
, 它的兼容性要更差。
来自caniuse
的灵魂对比:
3. 利用element
懒加载组件, 实现万物皆可懒加载
在这一小节,我们利用element
懒加载组件的部分props
和methods
, 结合我们封装在组件中的默认插槽,实现万物皆可懒加载的效果,其实核心原理就是我们第2
小节讲述的思路。具体细节我就不赘述了,有兴趣的小伙伴可以自行查看element
源码。
html
`子组件:`
<template>
<div class="lazy-warpper">
<slot v-if="show" v-on="$listeners" v-bind="$attrs" />
<!--采用无样式的骨架屏进行占位-->
<div
v-else
:style="{
width: setPx(width),
height: setPx(height)
}"
>
</div>
</div>
</template>
<script>
`imageData为element全局注册的组件`
import imageData from 'element-ui/packages/image'
import { setPx } from '@/components/avue/utils/util'
`拿到el-image组件实例的部分props和methods,并挂载到自定义组件中`
`本组件只用到了addLazyLoadListener这个方法,为什么还引入handleLazyLoad和removeLazyLoadListener?`
`因为这两个方法有在addLazyLoadListener中调用`
function createExtendOption() {
const DEFAULT_EXTEND = {
props: ['lazy', 'scrollContainer'],
methods: ['addLazyLoadListener', 'handleLazyLoad', 'removeLazyLoadListener']
}
return Object.keys(DEFAULT_EXTEND).reduce((cur, prev) => {
const val = imageData[prev]
const props = DEFAULT_EXTEND[prev]
const extendVal = (cur[prev] = {})
props.map((prop) => {
extendVal[prop] = val[prop]
})
return cur
}, {})
}
const option = createExtendOption()
export default {
props: {
...option.props,
lazy: {
type: Boolean,
default: true
},
width: {
type: String | Number,
default: '68'
},
height: {
type: String | Number,
default: '68'
}
},
data() {
return {
show: !this.lazy
}
},
mounted() {
if (this.lazy) {
this.addLazyLoadListener()
}
},
methods: {
...option.methods,
setPx
}
}
</script>
<style lang="scss" scoped></style>
html
`父组件:`
<template>
<div class="lazy-container">
<lazyLoad v-for="(src, index) in imageList" :key="index" width="300" height="330">
<div class="lazy-warpper">
<el-image class="image" :src="src" fit="contain" />
<div class="mb20">{{ getTitle(index) }}</div>
</div>
</lazyLoad>
</div>
</template>
<script>
import lazyLoad from './lazyLoad'
import { getPrototypeList } from '@/api/product'
import { REQUEST_ALL_DATA } from '@/utils/constant'
import { map } from 'lodash'
export default {
components: { lazyLoad },
data() {
return {
imageList: []
}
},
mounted() {
this.getImageList()
},
methods: {
async getImageList() {
const res = await awaitResolve(getPrototypeList({ ...REQUEST_ALL_DATA }))
if (!res) return
this.imageList = map(res.detail, `styleList[0].displayImageUrl`)
},
getTitle(index) {
return `产品 ${index + 1}`
}
}
}
</script>
<style lang="scss" scoped>
.lazy-warpper {
.image {
width: 300px;
height: 300px;
display: block;
border: 1px solid #ccc;
margin-bottom: 10px;
}
}
</style>
实现效果:
结语
往期精彩推荐(强势引流):
大概就这样吧, 有兴趣的掘友们可以去试试~ 更多精彩内容,正在努力摸鱼创作中,尽请期待。