了解 IntersectionObserver API:实现高性能的元素可见性检测

在现代网页开发中,实现元素可见性检测是非常常见的需求。以前的解决方案通常会涉及到滚动事件监听或者定时器来检测元素是否进入或离开视口。然而,这些方法可能会导致性能问题,尤其是在页面有大量需要检测的元素时。为了解决这个问题,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 是由 rootrootMarginthreshold 组成的对象

  • root ,用作检查目标可见性的视口的元素。 必须是目标的祖先。 如果未指定或为 null ,则默认为浏览器视口。

  • rootMarginroot 周围的边距。 可以具有类似于 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 实战案例

图片懒加载

实现图片懒加载的步骤如下:

  1. 创建 IntersectionObserver 实例,并指定观察的目标元素。

  2. 在回调函数中,判断目标元素是否进入视窗。

  3. 若目标元素进入视窗,将其真实的图片地址赋给元素的 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 倍。

虚拟列表

实现虚拟列表的核心步骤如下:

  1. 使用 IntersectionObserver 监听一个固定长度列表的首尾元素进入/离开视窗

  2. 更新当前页面渲染的第一个元素对应的序号 (firstIndex)

  3. 根据上述序号,获取对应数据元素,列表重新渲染成新的内容

  4. padding 调整,模拟滚动实现

虚拟滚动的基本原理:

  1. 当滚动条往下走的时候,上面的元素会不断增多,这里用容器的 paddingTop 不断增大,paddingBottom 随之减小来模拟

  2. 当滚动条往上走的时候,下面的元素会不断增多,这里用容器的 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 个列表元素,因此实现了虚拟列表。

标题和导航联动

实现标题和导航联动的总体步骤如下:

  1. 创建 IntersectionObserver 实例,并观察相关的标题元素

  2. 在回调函数中,进入视窗则执行自定义的 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)
  }
})

上面的代码需要注意以下两点:

  1. reverse 方法的目的是为了实现如果同时有多个标题元素在视窗时,需要将第一个标题元素相关的导航元素高亮

  2. 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 有更加深刻的理解

参考

  1. 一个简洁、有趣的无限下拉方案

  2. 超好用的API之IntersectionObserver

  3. 尝试使用JS IntersectionObserver让标题和导航联动

  4. IntersectionObserver API 使用教程

  5. Intersection Observer API

相关推荐
颜酱26 分钟前
使用useReducer和Context进行React中的页面内部数据共享
前端·javascript·react.js
Jackson_Mseven1 小时前
🧺 Monorepo 是什么?一锅端的大杂烩式开发幸福生活
前端·javascript·架构
我想说一句1 小时前
JavaScript数组:轻松愉快地玩透它
前端·javascript
晓13131 小时前
JavaScript进阶篇——第七章 原型与构造函数核心知识
开发语言·javascript·ecmascript
爱学习的茄子2 小时前
JS数组高级指北:从V8底层到API骚操作,一次性讲透!
前端·javascript·深度学习
晓13133 小时前
JavaScript进阶篇——第八章 原型链、深浅拷贝与原型继承全解析
开发语言·javascript·原型模式
不懂英语的程序猿3 小时前
【JEECG 组件扩展】JSwitch开关组件扩展单个多选框样式
java·前端·javascript·后端
cg50174 小时前
AJAX 技术
前端·javascript·ajax
白仑色4 小时前
AJAX 入门到精通
前端·javascript·ajax·okhttp·web开发·前端开发
风无雨4 小时前
前端 cookie 使用
开发语言·前端·javascript