前言
在项目开发中,往往需要去判断一个元素是否被用户看见(进入可视区
),而这是为了去实现某些目的,例如:
- 优化资源加载、渲染
- 图片懒加载
- 无限滚动(数据分批渲染)
- 虚拟列表
- ...
- 添加动画效果
- 对于进入可视区的元素添加动画,让页面更具有 交互感
- 判断广告曝光
- 用于去判断页面中的广告点位是否被曝光
- ...
之前的做法就是通过监听 scroll
事件,然后再事件 回调 中调用 目标元素 的getBoundingClientRect()
方法,获取其相对于 视口左上角的坐标,从而判断其是否存在于可视区中。
function isElementInViewport(element) {
const rect = element.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
// 判断元素是否在可见区域的垂直范围内
const isInVerticalViewport = rect.top <= viewportHeight && rect.bottom >= 0;
// 判断元素是否在可见区域的水平范围内
const isInHorizontalViewport = rect.left <= viewportWidth && rect.right >= 0;
return isInVerticalViewport || isInHorizontalViewport;
}
window.addEventListener('scroll', () => {
const inViewport = isElementInViewport(document.querySelector('.target-item'));
...
});
但上述方式存在由于 滚动事件频发 导致频繁调用元素的 getBoundingClientRect()
进而引发页面的 重排或回流,而现在通过 IntersectionObserver 可以更便捷的实现这个功能,避免造成性能问题。
IntersectionObserver
元素是否可见,实际上就是 目标元素 与 可视区 是否产生了 交叉区,因此 IntersectionObserver
也被称为 “交叉观察器”。
如下是其简单的一个使用:
const isView = ref(false);
onMounted(() => {
const intersectionObserver = new IntersectionObserver((entries) => {
console.log(1111);
// 若 intersectionRatio 为 0,则目标在视野外
isView.value = entries[0].intersectionRatio > 0;
},{
root: document.querySelector('.box')!,// 监听目标的祖先元素
});
// 开始监听
intersectionObserver.observe(document.querySelector('.item_10')!);
});
通过这个例子我们来简单介绍下其相关的配置 new IntersectionObserver(callback[, options])
,更具体可见 此处。
callback 回调函数
执行时机
通过上述例子不难发现,其回调函数的执行时机 默认只有两次:
- 目标元素
进入视口
- 目标元素
离开视口
这也是为什么前面说使用 IntersectionObserver API
比之前在 scroll
事件 回调中判断目标元素是否进入视口性能更好的原因之一。
但实际上我们还可以通过 options.threshold 配置项来设定其更具体的触发时机,后面会提到。
接收参数
var observer = new IntersectionObserver((entries, observer) => {...});
- entries
- 一个 IntersectionObserverEntry 对象的数组,可自行通过链接查看
- observer
- 被调用的 IntersectionObserver 实例,可自行通过链接查看
options 配置项
var observer = new IntersectionObserver(callback, {
root: document.querySelector('.container'),
rootMargin: '0px 0px 0px 0px',
threshold: [0.25, 0.5, 0.75, 1]
});
-
root
- 传入监听
目标元素的祖先元素
,会将传入的元素作为视口元素
,如果没有指定默认就为整个页面视口
- 传入监听
-
rootMargin
- 顾名思义,实际上就相当于 CSS 的
margin
属性,可以放大或缩小 视口元素 的判定范围,默认值是"0px 0px 0px 0px"
- 例如,
0px 0px -100px 0px
相当于 视口元素 下边缘向上 收缩100px
,此时需要 目标元素顶部 进入可视区域>= 100px
才会触发回调
- 顾名思义,实际上就相当于 CSS 的
-
threshold
- 能够控制回调函数的触发时机,实际上它指定了 目标元素 与 视口元素 之间的 交叉比例,是
0.0
到1.0
之间的数组 - 例如,
[0.25, 0.5, 0.75, 1]
相当于指定了目标元素
与视口元素
交叉比例达到25%、50%、75%、100%
时各触发一次回调函数
- 能够控制回调函数的触发时机,实际上它指定了 目标元素 与 视口元素 之间的 交叉比例,是
实例方法
-
IntersectionObserver.disconnect()
- 调用后就会停止对 所有目标元素 的监听
-
IntersectionObserver.observe()
- 调用后就会开启对 目标元素 的监听
-
IntersectionObserver.unobserve()
- 调用后就会停止对 目标元素 监听
应用场景
常见的图片懒加载、无限滚动、虚拟列表等这里就不再实现,我们来看一些其他的场景吧。
标题与内容的联动
很多站点都会有这样的功能,以 Vue 文档 为例,如下:
如果你有兴趣去查看一下其实现,会发现其用的就是 IntersectionObserver:
具体实现
观察上述效果,不难看出就是一个双向交互:
- 点击标题,可以直接定位到 内容区
- 可以通过
<a :href="#">
锚点的方式实现
- 可以通过
- 浏览内容,可以自动定位 当前标题
- 当目标内容进入视口后,获取其
关键信息,如 id
去匹配标题项即可
- 当目标内容进入视口后,获取其
于是可以很容易就写出如下效果:
<template>
<!-- 标题区 -->
<div class="title-box">
<div class="active-line" :style="{ top: activeOffsetTop + 'px' }"></div>
<a
:class="[
'title-item',
`title-item-${item.id}`,
item.active ? 'active' : '',
]"
:href="`#${item.id}`"
v-for="(item, i) in data"
:key="item.id"
@click="clickAction($event, item.id)"
>{{ item.title }}</a
>
</div>
<!-- 内容区 -->
<div class="content-box">
<div class="content-item" v-for="item in data" :data-id="item.id">
<h3 :id="item.id">{{ item.title }}</h3>
<div class="content-item-text" v-for="text in item.content">
{{ text }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
...
const data = ref([
{
id: "1",
title: "标题 1",
content: ["内容 1".repeat(100)],
active: true,
},
...
]);
let intersectionObserver: IntersectionObserver;
const observe = () => {
const root = document.querySelector(".content-box")!;
const contents: HTMLElement[] = [] = Array.from(document.querySelectorAll(".content-item")!);
// 实例化 IntersectionObserver
intersectionObserver = new IntersectionObserver(
(entries) => {
// 针对
entries.forEach((entry) => {
const { target, isIntersecting } = entry;
// 当前目标元素是否进入视口
if (isIntersecting) {
const id = target.dataset.id;
clickAction(
{ target: document.querySelector(`.title-item-${id}`), mock: true },
id
);
}
});
},
{
root,
threshold: [0.5],
}
);
// 开始监听每一个内容项
contents.forEach((target) => {
intersectionObserver.observe(target);
});
};
onMounted(() => {
observe();
});
onBeforeUnmount(() => {
intersectionObserver.disconnect();
});
// 偏移量
const activeOffsetTop = ref(0);
// 标题点击事件
const clickAction = (e, id: string) => {
data.value.forEach((v) => {
if (id === v.id) {
v.active = true;
activeOffsetTop.value = e.target.offsetTop;
} else {
v.active = false;
}
});
};
</script>
存在缺点
基于 IntersectionObserver 实现很便捷,但也会存在一个缺点,如下所示:
当点击 还有其他问题?
标题时,选中样式不会在此标题上,而是选中了它下面的那个标题 选择你的学习路径
,这是为啥呢?
这是因为 还有其他问题?
标题对应的内容很短,于是在基于 a 标签
锚点跳转的时候,将它下面标题的内容也带入了 可视区 中,此时 IntersectionObserver 的回调就会触发,所以最终选中的标题就是 选择你的学习路径
。
入场动画
IntersectionObserver API 的特性非常适合用来处理元素的 入场动画
,很多官网的动画也是这么去实现的,那下面就实现一个简单的入场动画,如下:
Element.animate()
这里使用 Element.animate()
方法来执行上面这个简单的动画,针对一些较为复杂的动画可以通过 @keyframes + animation
的方式定义在具体的 选择器 中,然后在目标元素进入视口时为其添加对应的 选择器 即可。
Element.animate() 方法接收两个参数:
keyframes
,关键帧对象数组或一个关键帧对象,关键帧格式options
,代表动画持续时间的整数(以毫秒为单位),或者一个包含一个或多个时间属性,KeyframeEffect() 参数
看着很抽象,实际上就是把之前在 CSS 写动画的方式抽离到 JS 中了。
具体实现
<script setup lang="ts">
...
// 开启监听
const startObserve = (selector: string) => {
const contents: HTMLElement[] = Array.from(
document.querySelectorAll(selector)!
);
// 实例化 IntersectionObserver
let intersectionObserverInstance = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const { target, isIntersecting } = entry;
// 目标元素进入视口
if (isIntersecting) {
// 执行入场动画
startAnimate(target);
}
});
});
// 开始监听每一个内容项
contents.forEach((target) => {
intersectionObserverInstance.observe(target);
});
return intersectionObserverInstance;
};
// 开始动画
const startAnimate = (target: Element) => {
const newspaperSpinning = [
{
transform: `translate(100px, 50px) scale(0.5)`,
opacity: 0,
background: "linear-gradient(225deg, #CC4D82, #FDCA8A)",
color: "red",
},
{ transform: "translate(0px, 0px) scale(1)" },
];
const newspaperTiming = {
duration: 300,// 动画执行时间
iterations: 1,// 动画执行次数
};
target.animate(newspaperSpinning, newspaperTiming);
};
let intersectionObserver: IntersectionObserver;
onMounted(() => {
intersectionObserver = startObserve(".item");
});
onBeforeUnmount(() => {
intersectionObserver.disconnect();
});
</script>
<template>
<div class="list">
<div class="item" v-for="i in 20" :data-index="i">{{ i }}</div>
</div>
</template>
兼容性
从下图可以看出各主流浏览器都有相应的实现,针对一些兼容性比较差的可以使用对应的 polyfill。
最后
IntersectionObserver API 提供给我们一种更便捷的判断目标元素是否处于可视区的方式,它相对于传统滚动事件的实现方式来讲 性能更好
,除此之外还允许我们自定义 thresholds
阈值 来实现 自定义回调触发时机
,还可以实现 同时监听多个目标元素
。
除此之外,也要注意在 快速滚动 的场景下 IntersectionObserver
可能会不执行的问题,详情可见。