在现代网页开发中,实现元素可见性检测是非常常见的需求。以前的解决方案通常会涉及到滚动事件监听或者定时器来检测元素是否进入或离开视口。然而,这些方法可能会导致性能问题,尤其是在页面有大量需要检测的元素时。为了解决这个问题,IntersectionObserver
API应运而生。
使用定时器来检查页面某个元素是否进入或离开视口的例子:
js
// 获取要检查的目标元素
const targetElement = document.querySelector('.target-element');
// 定义检查函数
function checkVisibility() {
const rect = targetElement.getBoundingClientRect();
// 判断元素是否在视口内
if (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
) {
console.log('元素进入视口');
// 元素进入视口后的逻辑操作
} else {
console.log('元素离开视口');
// 元素离开视口后的逻辑操作
}
}
// 定时调用检查函数
setInterval(checkVisibility, 100);
上述代码中,我们使用 getBoundingClientRect()
方法来获取目标元素相对于视口的位置和尺寸信息。然后,我们通过判断元素的上下左右边界是否在视口内,来确定元素是否可见。如果元素在视口内,则执行相关逻辑;如果元素离开视口,则执行另外的逻辑。
需要注意的是,使用定时器来检查元素可见性会消耗更多的计算资源,并且不如 IntersectionObserver
API 准确和高效。因此,对于大型和复杂页面,推荐使用 IntersectionObserver
API 以获得更好的性能和用户体验。
什么是 IntersectionObserver API
IntersectionObserver
API是一种用于监测元素是否进入或离开视口的浏览器API。它提供了一种高性能的方式来观察元素的可见性,并在目标元素跟视口发生交叉时触发回调函数。通过使用IntersectionObserver API,我们可以避免常规的滚动事件监听或定时器来检测元素是否可见,从而提高页面性能。
IntersectionObserver API 简要说明
js
let options = {
// 观察器的根元素,默认为整个视口
root: document.querySelector("#scrollArea"),
// 根元素的margin,用于扩大或缩小可见性检测的范围
rootMargin: "0px",
// 目标元素进入视口的百分比,触发回调函数
threshold: 1.0,
};
let observer = new IntersectionObserver(callback, options);
IntersectionObserver
构造函数接受一个 callback 和 options 作为参数。
callback 会在目标对象达到阈值时被调用,阈值通过 options 参数传入。
options 是由 root
、rootMargin
、threshold
组成的对象
-
root
,用作检查目标可见性的视口的元素。 必须是目标的祖先。 如果未指定或为null
,则默认为浏览器视口。 -
rootMargin
,root
周围的边距。 可以具有类似于 CSS margin 属性的值,例如 "10px 20px 30px 40px"(上、右、下、左)。 这些值可以是百分比。 这组值用于在计算交集之前增大或缩小root
元素边界框的每一侧。 默认为全零。 -
threshold
,单个数字或数字数组,指示应在目标可见性的百分比下执行观察者的回调。 如果您只想检测可见性何时超过 50% 标记,则可以使用值 0.5。 如果您希望每次可见性再超过 25% 时都运行回调,则可以指定数组 [0, 0.25, 0.5, 0.75, 1]。 默认值为 0(意味着只要有一个像素可见,回调就会运行)。 值为 1.0 意味着在每个像素都可见之前,不会认为阈值已通过。
更多关于 IntersectionObserver
的介绍可查看 Intersection Observer API - Web APIs | MDN
IntersectionObserver API 的优势
使用IntersectionObserver API相比传统的滚动事件监听或定时器方法有以下几个优势:
-
高性能 :IntersectionObserver API利用浏览器的底层机制来异步处理元素可见性的变化。这意味着它可以更有效地处理和管理元素的可见性,从而减少了性能开销。
-
可配置性:IntersectionObserver API提供了一些配置选项,例如root、rootMargin和threshold等,使我们能够更精确地观察和控制元素的可见性。这样,我们可以根据具体的需求来调整观察器的行为。
IntersectionObserver API 实战案例
图片懒加载
实现图片懒加载的步骤如下:
-
创建
IntersectionObserver
实例,并指定观察的目标元素。 -
在回调函数中,判断目标元素是否进入视窗。
-
若目标元素进入视窗,将其真实的图片地址赋给元素的
src
属性,触发图片加载。
相关 js
js
const imgList = [...document.querySelectorAll('img')]
const io = new IntersectionObserver((entries) => {
entries.forEach(item => {
const element = item.target
if (item.isIntersecting) {
element.src = element.dataset.src
}
if (item.intersectionRatio >= 0.5) {
element.classList.add('enlarge')
io.unobserve(element)
}
})
}, {
root: null,
rootMargin: "0px",
threshold: [0, 0.5]
})
imgList.forEach(img => io.observe(img))
相关 html
html
<div class="wrap">
<div class="img-item">
<img
data-src="https://t7.baidu.com/it/u=4162611394,4275913936&fm=193&f=GIF"
class="img-item__img"
>
</div>
<div class="img-item">
<img
data-src="https://t7.baidu.com/it/u=1951548898,3927145&fm=193&f=GIF"
class="img-item__img"
>
</div>
<div class="img-item">
<img
data-src="https://t7.baidu.com/it/u=1831997705,836992814&fm=193&f=GIF"
class="img-item__img"
>
</div>
<div class="img-item">
<img
data-src="https://t7.baidu.com/it/u=2582370511,530426427&fm=193&f=GIF"
class="img-item__img"
>
</div>
<div class="img-item">
<img
data-src="https://t7.baidu.com/it/u=993577982,1027868784&fm=193&f=GIF"
class="img-item__img"
>
</div>
</div>
相关 css
css
.wrap {
width: 500px;
height: 500px;
overflow-y: auto;
overflow-x: hidden;
font-size: 0;
}
.img-item {
background: #f5f7fa;
width: 100%;
height: 100%;
}
.img-item__img {
height: 100%;
width: 100%;
object-fit: contain;
transform: scale(0.5);
transition: transform 0.3s ease;
}
.enlarge {
transform: scale(1.2);
}
上面的代码实现了在目标元素进入视口相交比例为 0% 、50% 时加载图片并在 50% 时放大图片 1.2 倍。

虚拟列表
实现虚拟列表的核心步骤如下:
-
使用
IntersectionObserver
监听一个固定长度列表的首尾元素进入/离开视窗 -
更新当前页面渲染的第一个元素对应的序号 (firstIndex)
-
根据上述序号,获取对应数据元素,列表重新渲染成新的内容
-
padding 调整,模拟滚动实现
虚拟滚动的基本原理:
-
当滚动条往下走的时候,上面的元素会不断增多,这里用容器的 paddingTop 不断增大,paddingBottom 随之减小来模拟
-
当滚动条往上走的时候,下面的元素会不断增多,这里用容器的 paddingBottom 不断增大,paddingTop 随之减小来模拟
相关 js
js
const $listContainer = document.getElementById('list-container')
const itemFirst = 'item-first'
const itemLast = 'item-last'
const listSize = 21
const itemHeight = 150
const $itemFirst = document.getElementById(itemFirst)
const $itemLast = document.getElementById(itemLast)
const $Lis = document.querySelectorAll('#list-container li')
const domDataCache = {
prePaddingTop: 0,
prePaddingBottom: 0,
currentIndex: 0,
preTopY: 0,
preTopRadio: 0,
preBottomY: 0,
preBottomRadio: 0
}
const updateDomDataCache = (param) => {
Object.assign(domDataCache, param)
}
const getFirstIndex = (isScrollDown) => {
const {
currentIndex
} = domDataCache
const changeIndex = Math.floor(listSize / 2)
if (isScrollDown) {
const firstIndex = currentIndex + changeIndex
return firstIndex
}
let firstIndex = currentIndex - changeIndex
if (firstIndex < 0) {
firstIndex = 0
}
return firstIndex
}
const adjustPadding = (isScrollDown) => {
const {
prePaddingTop,
prePaddingBottom
} = domDataCache
const changePadding = itemHeight * Math.floor(listSize / 2)
let currentPaddingTop, currentPaddingBottom
if (isScrollDown) {
currentPaddingTop = prePaddingTop + changePadding
if (prePaddingBottom === 0) {
currentPaddingBottom = 0
} else {
currentPaddingBottom = prePaddingBottom - changePadding
}
} else {
currentPaddingBottom = prePaddingBottom + changePadding
if (prePaddingTop === 0) {
currentPaddingTop = 0
} else {
currentPaddingTop = prePaddingTop - changePadding
}
}
$listContainer.style.paddingTop = `${currentPaddingTop}px`
$listContainer.style.paddingBottom = `${currentPaddingBottom}px`
updateDomDataCache({
prePaddingTop: currentPaddingTop,
prePaddingBottom: currentPaddingBottom
})
}
const itemTopCb = (entry) => {
const {
preTopY,
preTopRadio
} = domDataCache
const isIntersecting = entry.isIntersecting
const currentTopRadio = entry.intersectionRatio
const currentTopY = entry.boundingClientRect.top
// 滚动条往上走
if (
isIntersecting
&& currentTopRadio >= preTopRadio
&& currentTopY > preTopY
) {
const firstIndex = getFirstIndex(false)
renderPage(firstIndex)
adjustPadding(false)
updateDomDataCache({
currentIndex: firstIndex,
preTopY: currentTopY,
preTopRadio: currentTopRadio
})
} else {
updateDomDataCache({
preTopY: currentTopY,
preTopRadio: currentTopRadio
})
}
}
const itemBottomCb = (entry) => {
const {
preBottomY,
preBottomRadio
} = domDataCache
const isIntersecting = entry.isIntersecting
const currentBottomRadio = entry.intersectionRatio
const currentBottomY = entry.boundingClientRect.top
// 滚动条往下走
if (
isIntersecting
&& currentBottomRadio >= preBottomRadio
&& currentBottomY < preBottomY
) {
const firstIndex = getFirstIndex(true)
renderPage(firstIndex)
adjustPadding(true)
updateDomDataCache({
currentIndex: firstIndex,
preBottomY: currentBottomY,
preBottomRadio: currentBottomRadio
})
} else {
updateDomDataCache({
preBottomY: currentBottomY,
preBottomRadio: currentBottomRadio
})
}
}
const renderPage = (firstIndex) => {
$Lis.forEach((li, index) => {
li.innerHTML = firstIndex + index
})
}
const initIntersectionObserver = () => {
const option = {
//
}
const callback = (entries) => {
entries.forEach((entry) => {
if (entry.target.id === itemFirst) {
itemTopCb(entry)
}
if (entry.target.id === itemLast) {
itemBottomCb(entry)
}
})
}
const observer = new IntersectionObserver(callback, option)
observer.observe($itemFirst)
observer.observe($itemLast)
}
initIntersectionObserver()
renderPage(0)
相关 html
html
<ul id="list-container">
<li id="item-first"></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li id="item-last"></li>
</ul>
相关 css
css
ul, li {margin: 0; padding: 0;}
li { list-style: none; }
#list-container li {
height: 150px;
font-size: 25px;
display: flex;
justify-content: center;
align-items: center;
color: red;
}
#list-container li:nth-of-type(2n) {
background: #fff;
}
#list-container li:nth-of-type(2n+1) {
background: #999;
}
可以看到滚动页面时,页面只会有 20 个列表元素,因此实现了虚拟列表。

标题和导航联动
实现标题和导航联动的总体步骤如下:
-
创建
IntersectionObserver
实例,并观察相关的标题元素 -
在回调函数中,进入视窗则执行自定义的
active
方法,否则执行unactive
方法
相关 js
js
const elements = document.querySelectorAll('article h3');
// 创建导航元素
const nav = document.createElement('div')
nav.className = 'title-nav-ul'
document.body.appendChild(nav)
let isAvoid = false
let lastScrollTop = document.scrollingElement.scrollTop
const hObserver = new IntersectionObserver(function (entries) {
if (isAvoid) {
return
}
entries.reverse().forEach(function(entry) {
if (entry.isIntersecting) {
entry.target.active()
} else if (entry.target.isActived) {
entry.target.unactive();
}
})
lastScrollTop = document.scrollingElement.scrollTop
})
elements.forEach(function(ele, index) {
const id = `nav${Math.random()}`.replace('0.', '')
ele.id = id
// 导航元素创建
const eleNav = document.createElement('a')
eleNav.href = `#${id}`
eleNav.className = 'title-nav-li';
eleNav.innerHTML = ele.textContent;
nav.appendChild(eleNav)
ele.active = function () {
// 对应的导航元素高亮
eleNav.parentElement.querySelectorAll('.active').forEach(function (eleActive) {
ele.isActived = false
eleActive.classList.remove('active')
})
eleNav.classList.add('active')
ele.isActived = true
}
ele.unactive = function () {
// 对应的导航元素高亮
if (document.scrollingElement.scrollTop > lastScrollTop) {
elements[index + 1] && elements[index + 1].active()
} else {
elements[index - 1] && elements[index - 1].active()
}
eleNav.classList.remove('active')
ele.isActived = false
}
hObserver.observe(ele)
})
nav.addEventListener('click', function(event) {
const eleLink = event.target.closest('a')
// 导航对应的标题元素
const eleTarget = eleLink && document.querySelector(eleLink.getAttribute('href'))
if (eleTarget) {
event.preventDefault()
eleTarget.scrollIntoView({
behavior: "smooth",
block: 'center'
})
isAvoid = true
const delay = Math.abs(eleTarget.getBoundingClientRect().top - window.innerHeight / 2) / 2
setTimeout(function () {
eleTarget.active()
isAvoid = false
}, delay + 100)
}
})
上面的代码需要注意以下两点:
-
reverse
方法的目的是为了实现如果同时有多个标题元素在视窗时,需要将第一个标题元素相关的导航元素高亮 -
setTimeout
中的延迟加 100 毫秒的原因当用户点击导航元素时,如果点击事件的回调先于IntersectionObserver
的回调,则会出现最后高亮的导航元素不是当前点击的导航元素的情况。
相关 html
html
<article>
<h3>标题一</h3>
<section class="content1">内容一</section>
<h3>标题二</h3>
<section class="content2">内容二</section>
<h3>标题三</h3>
<section class="content3">内容三</section>
<h3>标题四</h3>
<section class="content4">内容四</section>
</article>
相关 css
css
.content1 {
height: 300px;
background-color: pink;
}
.content2 {
height: 200px;
background-color: aliceblue;
}
.content3 {
height: 500px;
background-color: black;
}
.content4 {
height: 800px;
background-color: aquamarine;
}
.title-nav-ul {
width: 200px;
border: 1px solid #ccc;
background-color: #fff;
box-shadow: 1px 1px 3px rgba(0,0,0,.25);
position: fixed;
top: 20px;
right: 10px;
}
.title-nav-li {
display: block;
padding: 10px;
text-decoration: none;
color: inherit;
}
.title-nav-li.active {
font-weight: bold;
}
最后的实现效果

总结
IntersectionObserver
API是一种用于监测元素可见性的高性能方法。通过使用 IntersectionObserver
API,我们可以避免传统的滚动事件监听或定时器方法带来的性能问题,并且可以更精确地观察和控制元素的可见性。在现代网页开发中,深入了解和使用 IntersectionObserver
API将为我们带来更好的用户体验和性能优化。
"纸上得来终觉浅,绝知此事要躬行",大家也可以自己实现一遍上面的三个案例,一定会对 IntersectionObserver
有更加深刻的理解