了解 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

相关推荐
品克缤14 小时前
Element UI MessageBox 增加第三个按钮(DOM Hack 方案)
前端·javascript·vue.js
小沐°14 小时前
vue-设置不同环境的打包和运行
前端·javascript·vue.js
qq_4198540515 小时前
CSS动效
前端·javascript·css
南村群童欺我老无力.16 小时前
Flutter应用鸿蒙迁移实战:性能优化与渐进式迁移指南
javascript·flutter·ci/cd·华为·性能优化·typescript·harmonyos
花哥码天下16 小时前
恢复网站console.log的脚本
前端·javascript·vue.js
奔跑的呱呱牛16 小时前
geojson-to-wkt 坐标格式转换
javascript·arcgis
康一夏17 小时前
React面试题,封装useEffect
前端·javascript·react.js
❆VE❆18 小时前
WebSocket与SSE深度对比:技术差异、场景选型及一些疑惑
前端·javascript·网络·websocket·网络协议·sse
ConardLi18 小时前
SFT、RAG 调优效率翻倍!垂直领域大模型评估实战指南
前端·javascript·后端
over69719 小时前
🌟 JavaScript 数组终极指南:从零基础到工程级实战
前端·javascript·前端框架