vueuse useIntersectionObserver
概要
- 介绍了 IntersectionObserver 的作用和基本使用
- 分析 vueuse 中 useIntersectionObserver 的源码以及如何使用
- 对比 ahooks 中 useInViewport 的写法
- 业务中利用 IntersectionObserver 实现表格数据懒加载的简单实现
前置知识:IntersectionObserver
- IntersectionObserver api 的作用
提供了一种异步观察目标元素与祖先元素或顶级文档 viewport 的交集中的变化的方法,其中祖先元素与视窗 viewport 被称为根(root)
- 使用
const io = new IntersectionObserver(callback, options)
io.observe(DOM)
const options = {
// 所监听对象的具体祖先元素。如果未传入值或值为null,
// 则默认使用顶级文档的视窗(一般为html)
root: null,
// 计算交叉时添加到根(root)边界盒bounding box的矩形偏移量,默认为0
rootMargin: 0,
// 一个包含阈值的列表, 按升序排列, 列表中的每个阈值都是监听对象的交叉
// 区域与边界区域的比率。可以是一个具体的数值或是一组 0.0 到 1.0 之间的数组,
// 若指定值为 0.0,则意味着监听元素即使与根有 1 像素交叉,
// 此元素也会被视为可见。若指定值为 1.0,则意味着整个元素都在可见范围内时才算可见
thresholds: 1,
}
// callback: 当监听目标发生滚动变化时触发的回调函数
const handless = (entries) => {
console.log(entries)
// Do something
}
const io = new IntersectionObserver(handless, options)
io.observe(DOM)
- 实际应用的例子 —— 图片懒加载
const imgList = [...document.querySelectorAll('img')]
var io = new IntersectionObserver(
(entries) => {
entries.forEach((item) => {
// isIntersecting是一个Boolean值,判断目标元素当前是否可见
if (item.isIntersecting) {
item.target.src = item.target.dataset.src
// 图片加载后即停止监听该元素
io.unobserve(item.target)
}
})
},
{
root: document.querySelector('.root'),
},
)
// observe遍历监听所有img节点
imgList.forEach((img) => io.observe(img))
开始学习
了解完 IntersectionObserver 这个 api 的用法后,我们开始学习 vueuse 中的 useIntersectionObserver。
先放源码:
import { watch } from 'vue-demi' // 第三方开源库,用于兼容 vue2 / vue3
import { noop, tryOnScopeDispose } from '@vueuse/shared'
import { ConfigurableWindow, defaultWindow } from '../_configurable'
import { MaybeElementRef, unrefElement } from '../unrefElement'
// 定义 IntersectionObserver 的 options 入参的类型
export interface IntersectionObserverOptions extends ConfigurableWindow {
root?: MaybeElementRef
rootMargin?: string
threshold?: number | number[]
}
export function useIntersectionObserver(
target: MaybeElementRef,
callback: IntersectionObserverCallback,
options: IntersectionObserverOptions = {},
) {
const {
root, // 监听的根元素
rootMargin = '0px',
threshold = 0.1, // 元素 10% 可见时触发
window = defaultWindow,
} = options
// 判断当前是否是浏览器环境,是否支持 IntersectionObserver
const isSupported = window && 'IntersectionObserver' in window
// noop 是一个返回空对象的箭头函数: const noop = () => {}
let cleanup = noop
const stopWatch = isSupported
? watch(
() => ({
el: unrefElement(target),
root: unrefElement(root),
}),
({ el, root }) => {
cleanup()
// 如果没有观察的元素,直接 return
if (!el) return
// @ts-expect-error missing type
// 创建一个 IntersectionObserver 实例
const observer = new window.IntersectionObserver(
callback, {
root,
rootMargin,
threshold,
}
)
// 观察目标元素
observer.observe(el)
cleanup = () => {
// 取消监听
observer.disconnect()
// 并且重置为 noop
cleanup = noop
}
},
{
immediate: true,
flush: 'post',
},
)
: noop
const stop = () => {
cleanup()
stopWatch()
}
tryOnScopeDispose(stop)
return {
isSupported,
stop,
}
}
export type UseIntersectionObserverReturn =
ReturnType<typeof useIntersectionObserver>
是不是脑壳嗡嗡的,开局新手村就给详细介绍一下各部分的作用,后面接下来的文章可能只会重点分析一部分代码,大部分比较简单明了的就直接写注释好了。好了,开启新手村任务。。。
第一步,先看所需要的依赖
import { watch } from 'vue-demi' // 第三方开源库,用于兼容 vue2 / vue3
import { noop, tryOnScopeDispose } from '@vueuse/shared'
import { ConfigurableWindow, defaultWindow } from '../_configurable'
import { MaybeElementRef, unrefElement } from '../unrefElement'
可以看到,第一个导入的就是 vue-demi
作用是用于兼容 vue2 / vue3。
看到第二行,找到对应的源码
export const noop = () => {}
// tryOnScopeDispose 比较复杂,放在后面一块分析
export function tryOnScopeDispose(fn: Fn) {
// 巴拉巴拉。。。
}
同样,找到第三、四行对应的源码
// 第三行
export interface ConfigurableWindow {
window?: Window
}
export const isClient = typeof window !== 'undefined'
export const defaultWindow = /* #__PURE__ */ isClient ? window : undefined
// 第四行
export type MaybeRef<T> = T | Ref<T>
export type MaybeElementRef = MaybeRef<
HTMLElement | SVGElement | VueInstance | undefined | null
>
export type UnrefElement = HTMLElement | SVGElement | undefined
export function unrefElement(elRef: MaybeElementRef): UnrefElement {
const plain = unref(elRef)
return (plain as VueInstance)?.$el ?? plain
}
ok, 接下来终于是正文了,先看源码实现
export function useIntersectionObserver(
target: MaybeElementRef, // 用户要监听的目标元素
callback: IntersectionObserverCallback, // 触发 IntersectionObserver 后的回调
options: IntersectionObserverOptions = {}, // 用户传入的 IntersectionObserver 配置项
) {
const {
// 根元素(不传或为 null,则默认为 html 元素)
root,
// 监听元素与根的偏移量,默认为 0px
rootMargin = '0px',
// 监听的元素可见范围达到指定比例时触发 callback, 默认 10%
threshold = 0.1,
// 判断是在浏览器环境还是node环境
window = defaultWindow,
} = options
// 在浏览器环境并判断浏览器是否支持 IntersectionObserver 这个api
const isSupported = window && 'IntersectionObserver' in window
// 定义清空函数,返回一个空对象
let cleanup = noop
// node 环境或者浏览器不支持 IntersectionObserver api,则直接返回一个空对象
const stopWatch = isSupported
? watch(
// vue-demi 提供的监听函数,用于兼容 vue2/3 的 watch
() => ({
// 监听用户传入的需要监听的元素
el: unrefElement(target),
// 监听用户传入的根元素
root: unrefElement(root),
}),
// 需要监听的元素或根元素发生变化时触发的回调
({ el, root }) => {
// 进入回调先清空上一次的 IntersectionObserver
cleanup()
// 用户没有传入需要监听的元素或被监听元素为 null / undefined,直接 return
if (!el) return
// 实例化一个 IntersectionObserver
const observer = new window.IntersectionObserver(callback, {
root,
rootMargin,
threshold,
})
// 监听用户传入的需要监听的元素
observer.observe(el)
// 修改清空函数,执行时先移除对用户传入元素的监听后,在返回一个空对象
cleanup = () => {
// 终止对所有目标元素可见性变化的观察
observer.disconnect()
cleanup = noop
}
},
{
immediate: true,
flush: 'post',
},
)
: noop
// 定义停止函数,先清空,在调用上面定义的 stopWatch 函数
// 作用:vue 组件使用的时候,可以在组件卸载时用于移除监听
const stop = () => {
cleanup()
stopWatch()
}
// 如果在 vue 生命周期内,则执行 stop 函数,否则啥也不做
tryOnScopeDispose(stop)
return {
isSupported,
stop,
}
}
接下来就剩下 tryOnScopeDispose(stop)
这一行代码我没还不知道它的作用,let's go
还是先看源码
import { getCurrentScope, onScopeDispose } from 'vue-demi'
type Fn = () => void
/**
* Call onScopeDispose() if it's inside a effect scope lifecycle, if not, do nothing
* 翻译:如果在作用域生命周期内,则调用onScopeDispose(),如果不在,则什么也不做
* @param fn
*/
export function tryOnScopeDispose(fn: Fn) {
if (getCurrentScope()) {
onScopeDispose(fn)
return true
}
return false
}
备注: getCurrentScope
和 onScopeDispose
, 这两货没在 github 的 readme 文档上找到,后面有机会再补充这两货的作用。不过 tryOnScopeDispose
的大致作用应该就是用于判断是否处于 vue 的生命周期内,如果不在就什么都不做,如果在的话就执行传入的函数,在这里传入的是 stop
函数。
到这里 useIntersectionObserver 的源码分析基本结束,最后看下实际应用。
vueuse 官网案例
<template>
<div ref="target">
<h1>Hello world</h1>
</div>
</template>
<script>
import { ref } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
export default {
setup() {
const target = ref(null)
const targetIsVisible = ref(false)
const { stop } = useIntersectionObserver(
target,
// 这一步需要验证下 callback 为什么有两个入参
([{ isIntersecting }], observerElement) => {
targetIsVisible.value = isIntersecting
},
)
return {
target,
targetIsVisible,
}
},
}
</script>
对比 ahooks
同样,直接上源码
import 'intersection-observer'
import { useState } from 'react'
import type { BasicTarget } from '../utils/domTarget'
import { getTargetElement } from '../utils/domTarget'
import useEffectWithTarget from '../utils/useEffectWithTarget'
export interface Options {
rootMargin?: string
threshold?: number | number[]
root?: BasicTarget<Element>
}
function useInViewport(target: BasicTarget, options?: Options) {
const [state, setState] = useState<boolean>()
const [ratio, setRatio] = useState<number>()
useEffectWithTarget(
() => {
const el = getTargetElement(target)
if (!el) {
return
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
setRatio(entry.intersectionRatio)
setState(entry.isIntersecting)
}
},
{
...options,
root: getTargetElement(options?.root),
},
)
observer.observe(el)
return () => {
observer.disconnect()
}
},
[],
target,
)
return [state, ratio] as const
}
export default useInViewport
同样,先看导入的都是写什么东西
import 'intersection-observer'
import { useState } from 'react'
import type { BasicTarget } from '../utils/domTarget'
import { getTargetElement } from '../utils/domTarget'
import useEffectWithTarget from '../utils/useEffectWithTarget'
第一个导入的看着像一个封装好的 intersectionObserver 的第三方包,找到对应的 packages.json
看看 ---> packages/hooks/packages.json
第二个不用说了,了解 react 的都知道是啥, 第三个是一个类型提示,找到它看看到底是啥
// packages/hooks/src/utils/domTarget.ts
type TargetValue<T> = T | undefined | null
type TargetType = HTMLElement | Element | Window | Document
export type BasicTarget<T extends TargetType = Element> =
| (() => TargetValue<T>)
| TargetValue<T>
| MutableRefObject<TargetValue<T>>
结合 ahooks 代码和前文的分析,可以知道这个类型提示是给 intersectionObserver 的 options.root
用的,所以它表达的类型是 element | null | undefined
接下来看剩下的这两货(getTargetElement
、useEffectWithTarget
)是啥东西了:
// 获取要监控的元素
import { getTargetElement } from '../utils/domTarget'
分析直接写在里面了
// utils/isBrowser.js
const isBrowser = !!(
typeof window !== 'undefined' && window.document && window.document.createElement
)
export default isBrowser
// utils/domTarge
export function getTargetElement<T extends TargetType>(
target: BasicTarget<T>,
defaultElement?: T
) {
// 判断是否是浏览器
if (!isBrowser) {
return undefined
}
// 判断用户有没有传入要监控的节点
if (!target) {
return defaultElement
}
let targetElement: TargetValue<T>
if (isFunction(target)) {
// 判断是否是函数(是的话执行函数获取 dom),传入一个可以获取 dom 元素的函数
targetElement = target()
} else if ('current' in target) {
// 判断是否是 ref, 使用 createRef / useRef 获取的
targetElement = target.current
} else {
// 判断是否是 dom, 直接使用 document 获取的
targetElement = target
}
return targetElement
}
import useEffectWithTarget from '../utils/useEffectWithTarget'
// utils/useEffectWithTarget.ts
import { useEffect } from 'react'
import createEffectWithTarget from './createEffectWithTarget'
const useEffectWithTarget = createEffectWithTarget(useEffect)
export default useEffectWithTarget
// utils/createEffectWithTarget.ts
import type { DependencyList, EffectCallback, useEffect, useLayoutEffect } from 'react'
import { useRef } from 'react'
import useUnmount from '../useUnmount'
import depsAreSame from './depsAreSame'
import type { BasicTarget } from './domTarget'
import { getTargetElement } from './domTarget'
const createEffectWithTarget = (
useEffectType: typeof useEffect | typeof useLayoutEffect
) => {
const useEffectWithTarget = (
effect: EffectCallback,
deps: DependencyList,
target: BasicTarget<any> | BasicTarget<any>[],
) => {
const hasInitRef = useRef(false)
const lastElementRef = useRef<(Element | null)[]>([])
const lastDepsRef = useRef<DependencyList>([])
const unLoadRef = useRef<any>()
useEffectType(() => {
// 在当前 useInViewport 中 useEffectType 相当于 useEffect
const targets = Array.isArray(target) ? target : [target]
const els = targets.map((item) => getTargetElement(item))
// init run
// 用于缓存数据
if (!hasInitRef.current) {
hasInitRef.current = true
lastElementRef.current = els
lastDepsRef.current = deps
unLoadRef.current = effect()
return
}
if (
els.length !== lastElementRef.current.length ||
!depsAreSame(els, lastElementRef.current) ||
!depsAreSame(deps, lastDepsRef.current)
) {
unLoadRef.current?.()
lastElementRef.current = els
lastDepsRef.current = deps
unLoadRef.current = effect()
}
})
useUnmount(() => {
unLoadRef.current?.()
hasInitRef.current = false
})
}
return useEffectWithTarget
}
export default createEffectWithTarget
实际应用
// lazyLoadTable.types.ts
interface ITableHeaderItem {
slot: string
name: string
width?: number
}
interface LazyLoadProps {
tableHeader: ITableHeaderItem[]
tableList: unknown[]
updateDataLength: number
}
<!-- 延迟加载表格组件 lazyLoadTable.vue -->
<template>
<table class="lazy-load-table">
<thead>
<tr>
<colGroup>
<col
v-for="item in props.tableHeader"
:width="`${item}px`"
:key="item.slot"
><col>
</colGroup>
<th>
<th v-for="item in props.tableHeader"
:key="item.slot">
{{ item.name }}
</th>
</th>
</tr>
</thead>
<tbody>
<colGroup>
<col
v-for="item in props.tableHeader"
:width="`${item}px`"
:key="item.slot"
><col>
</colGroup>
<tr v-for="(item, index) in props.tableList">
<td v-for="thItem in props.tableHeader">
<!-- 使用作用域插槽封装表格内容 -->
<slot
:name="thItem.slot"
:item="item"
:index="index">
{{ item[thItem.slot] ? item[thItem.slot] : ''}}
</slot>
</td>
</tr>
<!-- 标识符,这个可见说明需要加载更多内容 -->
<div ref="target" style="height: 3px;"></div>
</tbody>
</table>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnMounted } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
import type { LazyLoadProps } from './lazyLoadTable.types.ts'
const props = withDefaults(defineProps<ITableHeaderItem>(), {
tableHeader: [],
tableList: [],
updateDataLength: 10
})
const renderList = ref<unknown[]>([])
const target = ref<HTMLElement|null>(null);
const targetIsVisible = ref<boolean>(false);
onMounted(() => {
updateRenderList();
})
onUnMounted(() => {
stop();
})
watch(targetIsVisible, (val) => {
if (val) {
updateRenderList();
}
})
watch(props.tableList, () => {
renderList.value = [];
updateRenderList();
})
const updateRenderList = () => {
const currentLen = renderList.value.length;
const totalDataLen = props.tableList.length;
if (totalDataLen > currentLen) {
const startIndex = currentLen;
let endIndex = currentLen + props.updateDataLength;
endIndex = endIndex > totalDataLen ? totalDataLen : endIndex;
const addData = props.tableList.slice(startIndex, endIndex);
renderList.value.push(addData);
}
}
const { stop } = useIntersectionObserver(
target,
([{ isIntersecting }], observerElement) => {
targetIsVisible.value = isIntersecting;
}
);
</script>
<style lang="scss" scoped>
.lazy-load-table {
table-layout: fixed;
th, td {
padding: 12px;
}
}
</style>
项目中的简单使用及实现懒加载表格数据
<template>
<!-- 简单实现 -->
<div>
<table>
<thead>
<tr>
......
</tr>
</thead>
</table>
</div>
<!-- tbody 和 thead 分开是为了可以固定表头 -->
<div ref="body-content">
<table>
<tbody>
<tr v-for="item in rows">
.......
</tr>
<div ref="lazyLoadRef"></div>
</tbody>
</table>
</div>
</template>
<script>
export default {
props: {
tableList: { // 所有的数据
type: Array,
default: () => []
},
isNeedLazyLoad: { // 是否开启延迟加载数据
type: Boolean,
default: false,
},
lazyAddDataNum: { // 每次延迟加载的数量
type: Number,
default: 15
}
},
data() {
return {
rows: [],
allRows: [],
ob: null,
isSupportInterSectionObserver: false
}
},
watch: {
tableList(newVal) {
this.formatRows()
}
},
mounted() {
this.isSupportInterSectionObserver = this.isNeedLazyLoad && !!window.IntersectionObserver ? true : false
if (this.isSupportInterSectionObserver) {
this.setInterSectionObserver()
}
},
unMounted() {
if (!!this.ob) {
this.ob.unobserve(this.$refs['interSectionObserverDom']);
this.ob = null;
}
}
methods: {
setInterSectionObserver() {
this.ob = new IntersectionObserver((entries) => {
entries.forEach(item => {
// 元素进入可见区域
if (item.isIntersecting) {
const allLength = this.allRows.length;
const currentLength = this.rows.length;
if (allLength - currentLength > 0) {
if (allLength - currentLength <= this.lazyAddDataNum) {
this.rows = [...this.rows, ...this.allRows.slice(currentLength)];
} else {
this.rows = [...this.rows, ...this.allRows.slice(currentLength, currentLength + this.lazyAddDataNum)];
}
}
}
})
}, {
root: this.$refs['body-content'],
threshold: 0.5, // 可见区域百分比
})
const target = this.$refs['interSectionObserverDom'];
this.ob.observe(target)
},
formatRows() {
this.allRows = this.tableList.map((item) => {
return Object.assign(item, {
// 合并单元格的
rowspan: item.hasOwnProperty('rowspan') ? item.rowspan : 1,
})
});
// 传入的所有数据数量小数每次延迟加载的数据 / 没开启延迟加载 / 浏览器不支持 IntersectionObserver 这个 api 的都直接渲染所有数据
if (this.allRows.length < this.lazyAddDataNum || !this.isSupportInterSectionObserver) {
this.rows = this.allRows;
} else {
this.rows = this.allRows.slice(0, this.lazyAddDataNum)
}
// 。。。。。。。。。其他具体操作
},
}
}
</script>