交叉观察器 IntersectionObserver
可以观察
元素
是否可见,由于目标元素与视口产生一个交叉区,我们可以观察到目标元素的可见区域,通常称这个API
为交叉观察器
前段时间内部系统业务需要,用 IntersectionObserver
实现了table
中的上拉数据加载,如果有类似需求,希望本文能带给你一点思考和帮助
正文开始...
vite初始化一个项目
参考官网vite[1]快速启动一个项目
$ npm init vite@latest
选择一个vue
模板快速初始化一个页面后,我们添加路由页面
npm i vue-router@4
在已有项目上添加路由
// main.ts import { createApp } from 'vue' import route from './router/index'; import App from './App.vue' const app = createApp(App); app.use(route); app.mount('#app');
修改App
模板,另外我们引入elementPlus
,引入它主要是我们在实际项目中,我们用第三方UI库非常高频,在之前一篇文章中有提到虚拟列表优化大数据量
,具体参考测试脚本把页面搞崩了。今天用交叉观察器
也算是优化大数据量渲染的一种方案。
// App.vue <script setup lang="ts"> // This starter template is using Vue 3 <script setup> SFCs // Check out https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup import { ElConfigProvider } from "element-plus"; import { ref } from "vue"; const zIndex = ref(1000); const size = ref("small"); </script> <template> <el-config-provider :size="size" :z-index="zIndex"> <router-view></router-view> </el-config-provider> </template> <style> #app { font-family: Avenir, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-align: center; color: #2c3e50; margin-top: 60px; } </style>
创建router
文件夹,新建index.ts
,添加路由页面
// router/index.ts import { createWebHashHistory, createRouter } from 'vue-router'; import HelloWorld from '../components/HelloWorld.vue' import ShopListPage from '../view/shopList/Index.vue'; const routes = [ { path: '/hello', component: HelloWorld }, { path: '/', component: ShopListPage } ] const router = createRouter({ history: createWebHashHistory(), routes }) export default router;
我们新建一个view/shopList
目录,在shopList
中新建一个Index.vue
开始今天的栗子。
本地开发环境安装mockjs
模拟接口数据
npm i mockjs --save-dev
新建mock
我们使用它模拟接口随机数据,我们会在main.ts
引入该mock/index.js
// mock/index.ts import Mockjs from 'mockjs'; import mockFetch from 'mockjs-fetch'; // 拦截mock mockFetch(Mockjs); // 生成随机长度的数组 const createMapRandom = (len: number) => { const data = new Array(len); return data.fill('Maic') } Mockjs.mock('\/shoplist\/list.json', () => { return { code: 0, data: Mockjs.mock({ 'list|10': [{ 'id|+1': createMapRandom(10).map(() => Mockjs.mock('@id')), 'adress|1': createMapRandom(10).map(() => Mockjs.mock('@city')), "age|1": createMapRandom(10).map(() => Mockjs.mock('@integer(0,100)')), 'name|1': createMapRandom(10).map(() => Mockjs.mock("@cname")), } ] }) } });
注意我们在使用mockjs
时,我们使用了另外一个库mockjs-fetch
,如果在项目中使用fetch
做ajax
请求,那么必须要使用这个库拦截mock
请求,在默认情况下,如果你使用的是axios
库,那么mock
会默认拦截请求。
在view/shopList
目录下,我们创建Index.vue
<template> <div class="shopList"> <h3>intersectionObserver交叉器实现上拉加载</h3> <el-table :data="tableData" border stripe style="width: 100%"> <el-table-column type="index" width="50" /> <el-table-column property="id" label="id" width="180" /> <el-table-column property="name" label="Name" width="180" /> <el-table-column property="adress" label="Address" /> <el-table-column property="age" label="Age" /> </el-table> <div @click="handleMore" v-if="hasMore">点击加载更多</div> <div v-else>没有数据啦</div> </div> </template>
对应的js
,这段js
逻辑非常简单,就是请求模拟的mock
数据,然后设置table
所需要的数据,点击加载更多
就继续请求,如果没有数据了,就显示没有数据。
<script setup lang="ts"> import { reactive, ref, onMounted } from "vue"; import { ElTable, ElTableColumn } from "element-plus"; import "element-plus/dist/index.css"; const hasMore = ref(false); const tableData = ref([]); const condation = reactive({ pageParams: { page: 1, pageSize: 10, }, }); // TODO 请求数据 const featchList = async () => { const res = await fetch("/shoplist/list.json", { method: "GET", headers: { "Content-Type": "application/json", }, body: JSON.stringify(condation.pageParams), }); const json = await res.json(); tableData.value = tableData.value.concat(json.data.list); }; onMounted(() => { featchList(); }); // TODO 加载更多 const handleMore = () => { featchList(); }; </script>
我们用vite
初始化的项目是vue3
,在vue3
的script
我们使用了setup
,那么我们在script
中不再用返回一个对象,申明的方法和变量可以直接在模板中使用,这里与组合式API
有点区别,但是从功能上并没有什么区别。
在传统上,我们实现上拉加载,我们会监听滚动条到底部的距离,我们计算滚动条距离顶部位置、浏览器可视区域的高度、body的高度,监听滚动事件,判断scrollTop + clientHeight > bodyScrollHeight
,然后就判断是否需要加载下一页。
监听滚动事件,我们会加防抖处理事件,即使这样scroll
事件也会高频触发,这样也会影响性能。
因此我们使用IntersectionObserver
这个API
实现上拉加载。
我们看下IntersectionObserver
这个API
// callback是一个回调函数,options是可配置的参数 var observer = new IntersectionObserver(callback, options); // target1是一个具体的dom元素 observer.observe(target1) // 开始观察 observer.observe(target2) observer.unobserve(target) // 停止观察 observer.disconnect(); // 停止观察
我们可以在页面中用observer
可以观察多个dom
,同时我们也需要知道new IntersectionObserver()
这个是异步的,并不会随着页面的滚动而时时触发,它只会在线程空闲下来才会执行,因此它在事件循环中,优先级很低,只有等其他任务执行完了,浏览器有了空闲才会执行它。
当目标元素可见时,会触发callback
,另一次是当元素完全不可见时也会触发该callback
const options = {}; var observer = new IntersectionObserver( (entries, observer) => { console.log(entries);// entries 是一个数组,监听几个dom就会有几个 } , options);
在IntersectionObserver
中的entries
第一个参数里,其中有几个参数我们需要了解下
// entries type clientRect = { top: number; bottom: number, left: number, right: number, width: number, height: number } const entriesRes = { time: 12334, rootBounds: { bottom: 920, height: 1024, left: 0, right: 1024, top: 0, width: 920 } as clientRect, boundingClientRect: { ... } as clientRect, intersectionRect: { } as clientRect, intersectionRatio: 0, target: dom }; const entries = [entriesRes] // observer { delay: 0 root: null rootMargin: "0px 0px 0px 0px" thresholds: [0] trackVisibility: false }
在第二个参数options
中可配置参数
var options = { threshold: [0, 0.5, 1], root: document.getElementById('box1') }
threshold
这个可以设置目标元素可见范围在0,50%,100%时触发回调callback
,root
就是可以目标元素所在的祖先节点
我们花了一些时间了解IntersectionObserver
这个API
,接下来我们用它实现一个上拉加载。
// 关键代码 ... // 自定义一个上拉加载的指令 const vScrollTable = { created: (el, binding, vnode, prevVnod) => { handleScrollTable(el, binding); }, };
然后就是handleScrollTable
这个方法
... // 自定义指令的created中调用该方法 const handleScrollTable = (el, binding) => { const { infiniteScrollDisable, cb } = binding.value; // 如果el不存在,则禁止后面IntersectionObserver的实例化 if (!el && !cb) { return; } // 核心上拉加载代码 const intersectionObserver = new IntersectionObserver((enteris, observer) => { // console.log(enteris, observer); const [curentEnteris] = enteris; const { intersectionRatio } = curentEnteris; // 不可见的时候,禁止加载 if (intersectionRatio <= 0) return; // 设置一个可以加载更多的开关 if (infiniteScrollDisable) { cb(); } }); // 开始监听 intersectionObserver.observe(el); };
在模板里我们只需在目标元素上绑定指令就行
... <div class="load-more-btn" @click="handleMore" v-if="hasMore" v-scroll-table="{ cb: handleMore, infiniteScrollDisable: hasMore }" > 点击加载更多<el-icon :class="[loading ? 'is-loading' : '']"> <component :is="Loading"></component> </el-icon >({{ tableData.length }}/{{ total }}) </div> <div v-else>没有数据啦</div>
我们直接在元素上绑定自定义的指令v-scroll-table="{cb: handleMore,infiniteScrollDisable: hasMore}"
就行
完整的全部示例见下面代码
<!--shopList/Index.vue--> <template> <div class="shopList"> <h3>intersectionObserver交叉器实现上拉加载</h3> <el-table :data="tableData" border stripe style="width: 100%" v-loading="loading" > <el-table-column type="index" width="50" /> <el-table-column property="id" label="id" width="180" /> <el-table-column property="name" label="Name" width="180" /> <el-table-column property="adress" label="Address" /> <el-table-column property="age" label="Age" /> </el-table> <div class="load-more-btn" @click="handleMore" v-if="hasMore" v-scroll-table="{ cb: handleMore, infiniteScrollDisable: hasMore }" > 点击加载更多<el-icon :class="[loading ? 'is-loading' : '']"> <component :is="Loading"></component> </el-icon >({{ tableData.length }}/{{ total }}) </div> <div v-else>没有数据啦</div> </div> </template> <script setup lang="ts"> import { reactive, ref, onMounted } from "vue"; import { ElTable, ElTableColumn, ElIcon } from "element-plus"; import { Loading } from "@element-plus/icons-vue"; import "element-plus/dist/index.css"; const hasMore = ref(false); const tableData = ref([]); const loading = ref(false); const condation = reactive({ pageParams: { page: 1, pageSize: 10, }, }); const total = ref(100); // TODO 请求数据 const featchList = async () => { const res = await fetch("/shoplist/list.json", { method: "GET", headers: { "Content-Type": "application/json", }, body: JSON.stringify(condation.pageParams), }); const json = await res.json(); tableData.value = tableData.value.concat(json.data.list); hasMore.value = true; if (total.value === tableData.value.length) { hasMore.value = false; // 没有更多了 } loading.value = false; }; onMounted(() => { featchList(); }); // TODO 加载更多 const handleMore = () => { loading.value = true; // 加一个延时1s显示loading效果 setTimeout(() => { featchList(); }, 1000); }; const handleScrollTable = (el, binding) => { const { infiniteScrollDisable, cb } = binding.value; // 如果el不存在,则禁止后面IntersectionObserver的实例化 if (!el && !cb) { return; } const intersectionObserver = new IntersectionObserver((enteris, observer) => { // console.log(enteris, observer); const [curentEnteris] = enteris; const { intersectionRatio } = curentEnteris; // 不可见的时候,禁止加载 if (intersectionRatio <= 0) return; // 设置一个可以加载更多的开关 if (infiniteScrollDisable) { cb(); } }); // 开始监听 intersectionObserver.observe(el); }; // 自定义一个上拉加载的指令 const vScrollTable = { created: (el, binding, vnode, prevVnod) => { handleScrollTable(el, binding); }, }; </script> <style> .load-more-btn { display: flex; align-items: center; justify-content: center; } </style>
打开页面,我们可以看到
点击加载操作就会加载更多,当滚动到底部时,就会加载更多。当数据加载完时,我们就设置hasMore = false
;
核心代码非常简单,就是利用IntersectionObserver
监测目标元素的可见,当目标元素可见时,我们加载更多,在目标元素不可见时,我们禁止加载更多,当数据加载完了,就禁止加载更多。
总结
1.使用vite
与vue3
模板搭建一个简易的demo
模板,结合vue-router
、mockjs
、elementPlus
,fetch
实现基本路由搭建,数据请求
2.了解核心IntersectionObserver
API,用vue3
指令,实现加载更多,这里用指令的原因是因为可以在多个类似模块复用指令内部那段逻辑,这样可以提高我们业务功能的复用能力
3.我们看到在vue3
中script
中使用了setup
,在注册组件和模板上使用的变量,当前组件可以直接使用。如果你未在script
中使用setup
,那么就要与组合式API
一样使用setup
,返回模板中使用的变量以及绑定的方法,并且注册局部组件依旧要像以前方式一样在component
中引入
4.更多关于IntersectionObserver
的实践,我们可以用它做图片懒加载
,视频播放暂停与播放
等,具体可以参考这篇文章IntersectionObserver[2]
5.本文示例源码地址intersectionObserver[3]