交叉观察器 IntersectionObserver

可以观察元素是否可见,由于目标元素与视口产生一个交叉区,我们可以观察到目标元素的可见区域,通常称这个API为交叉观察器

前段时间内部系统业务需要,用 IntersectionObserver实现了table中的上拉数据加载,如果有类似需求,希望本文能带给你一点思考和帮助

正文开始...

vite 初始化一个项目

参考官网viteopen in new window快速启动一个项目

code
$ npm init vite@latest

选择一个vue模板快速初始化一个页面后,我们添加路由页面

code
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 库非常高频,在之前一篇文章中有提到虚拟列表优化大数据量,具体参考测试脚本把页面搞崩了open in new window。今天用交叉观察器也算是优化大数据量渲染的一种方案。

code
// 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,添加路由页面

code
// 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

code
// 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,如果在项目中使用fetchajax请求,那么必须要使用这个库拦截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所需要的数据,点击加载更多就继续请求,如果没有数据了,就显示没有数据。

code
<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,在vue3script我们使用了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第一个参数里,其中有几个参数我们需要了解下

code
// 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这个方法

code
...
// 自定义指令的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}"就行

完整的全部示例见下面代码

code
<!--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.使用vitevue3模板搭建一个简易的demo模板,结合vue-routermockjselementPlus,fetch实现基本路由搭建,数据请求

2.了解核心IntersectionObserverAPI,用vue3指令,实现加载更多,这里用指令的原因是因为可以在多个类似模块复用指令内部那段逻辑,这样可以提高我们业务功能的复用能力

3.我们看到在vue3script中使用了setup,在注册组件和模板上使用的变量,当前组件可以直接使用。如果你未在script中使用setup,那么就要与组合式API一样使用setup,返回模板中使用的变量以及绑定的方法,并且注册局部组件依旧要像以前方式一样在component中引入

4.更多关于IntersectionObserver的实践,我们可以用它做图片懒加载视频播放暂停与播放等,具体可以参考这篇文章IntersectionObserveropen in new window

5.本文示例源码地址intersectionObserveropen in new window

扫二维码,关注公众号
专注前端技术,分享web技术
加作者微信
扫二维码 备注 【加群】
教你聊天恋爱,助力脱单
微信扫小程序二维码,助你寻他/她
专注前端技术,分享Web技术
微信扫小程序二维码,一起学习,一起进步
前端面试大全
海量前端面试经典题,助力前端面试