【Appear】如何实现元素可见/不可见的监听

如何判定元素出现/离开屏幕可视区呢?

本文通过分析rax-appear库(可以忽略rax,直接简单把它认为react即可),来获得一些启发,那我们就开始吧!

平时开发中,有遇到一些这样的需求

  • 判断导航栏滚出屏幕时,让导航栏变为fixed状态,反之则正常放置在文档流中
  • 当一个商品卡片/广告出现时,需要对其进行曝光埋点
  • 在瀑布流中,可以通过判定"加载更多"div的出现,来发起下一页的请求
  • 对图片们做懒加载,优化网页性能

本文结构如下:

  • IntersectionObserver的用法
  • IntersectionObserver的polyfill
  • DOM元素监听onAppear/onDisappear事件
  • DOM元素设置appear/disappear相关属性

IntersectionObserver的用法

Intersection Observer API允许你注册一个回调函数,当出现下面的行为时,会触发该回调:

  • 每当一个元素进入/退出与另一个元素(或视口)的交集时,或者当这两个元素之间的交集发生指定量的变化时
  • 首次观测该元素时会触发 不用时,还需要终止对所有目标元素可见性变化的观察,调用disconnect方法即可

用法

js 复制代码
let options = {
  root: document.querySelector("#scrollArea"), // 观察目标元素的容器
  rootMargin: "0px", // room容器的margin,计算交集前,通过margin扩充/缩小root的高宽
  threshold: 1.0, // 交集占多少时,才执行callback。为1就是要等目标元素的每一个像素都进入root容器时才执行,默认为0,即只要目标元素刚刚1px进入root容器就触发
};

// 回调函数
let callback = (entries, observer) => {
  entries.forEach((entry) => {
    // Each entry describes an intersection change for one observed
    // target element:
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
    if (entry.isIntersecting) {
      let elem = entry.target;

      if (entry.intersectionRatio >= 0.75) {
        intersectionCounter++;
      }
    }
  });
ini 复制代码
};

let observer = new IntersectionObserver(callback, options);
// 目标元素
let target = document.querySelector("#listItem");
// 直到我们为观察者设置一个目标元素(即使目标当前不可见)时,回调才开始首次执行
observer.observe(target);

rootMargin不同时的效果,一图胜千言:

图片出处:velog.io/@katanazero...

关于回调函数

需要注意的是回调函数是在主线程上执行的,如果它里面存在执行耗时长的任务,就会阻碍主线程做其他事情,势必会影响到页面的渲染。对于耗时长的任务,建议放到Window.requestIdleCallback(),或者放到新的宏任务中去

js 复制代码
const handleIntersection = (entries, observer) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      // 元素进入视口时执行的任务
      window.requestIdleCallback(() => {
        // 在浏览器空闲时执行的任务
        console.log('元素进入视口并浏览器处于空闲状态');
      });
    }
  });
};

const options = {
  root: null,
  rootMargin: '0px',
  threshold: 0.5,
};

const observer = new IntersectionObserver(handleIntersection, options);
const targetElement = document.getElementById('target');

observer.observe(targetElement);

神奇的问题&思路

stack overflow上有一个这样的问题,他用IntersectionObserver来监听一个列表的滚动,当滚动慢点时候表现正常,但是滚动非常快的时候,会发现列表元素并不能被Observer来捕获,想问这是什么造成的?

一位大佬解释说,IO的主要目标是检查某个元素是否对人眼可见,根据Intersection Observer规范,其目标是提供一个简单且最佳的解决方案来推迟或预加载图像和列表、检测商务广告可见性等。但当移动滚动条的速度快于这些检查发生的速度,也就是当IO过于频繁,可能无法检测到某些可见性更改,甚至这个没有被检测到的元素,都还没有被渲染。

对于这个问题,提供了如下解决方案,即通过计算本次观测到的列表元素范围变化,知道了当前滚动的minId和maxId,就能知道列表的哪部分被检测了,从而进行业务处理。

js 复制代码
let minId = null;
let maxId = null;
let debounceTimeout = null;

function applyChanges() {
  console.log(minId, maxId);
  const items = document.querySelectorAll('.item');
  // perform action on elements with Id between min and max
  minId = null;
  maxId = null;
}

function reportIntersection(entries) {
  clearTimeout(debounceTimeout);
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const entryId = parseInt(entry.target.id);
      if (minId === null || maxId === null) {
        minId = entryId;
        maxId = entryId;
      } else {
        minId = Math.min(minId, entryId);
        maxId = Math.max(maxId, entryId);
      }
    }
  });
  debounceTimeout = setTimeout(applyChanges, 500);
}

const container = document.querySelector('#container');
const items = document.querySelectorAll('.item');
const io = new IntersectionObserver(reportIntersection, container);
let idCounter = 0;
items.forEach(item => {
  item.setAttribute('id', idCounter++);
  io.observe(item)
});

IntersectionObserver的polyfill

通过setInterval或者监听resize、scroll、MutationObserver(用于观察 DOM 树的变化并在发生变化时触发回调函数。它可以监听 DOM 的插入、删除、属性修改、文本内容修改等变化)来触发检测,这里只贴了一些关键路径代码,想了解更多的可以网上搜搜看。

js 复制代码
_proto._monitorIntersections = function _monitorIntersections() {
    if (!this._monitoringIntersections) {
      this._monitoringIntersections = true;

      // If a poll interval is set, use polling instead of listening to
      // resize and scroll events or DOM mutations.
      if (this.POLL_INTERVAL) {
        this._monitoringInterval = setInterval(this._checkForIntersections, this.POLL_INTERVAL);
      } else {
        addEvent(window, 'resize', this._checkForIntersections, true);
        addEvent(document, 'scroll', this._checkForIntersections, true);
        if (this.USE_MUTATION_OBSERVER && 'MutationObserver' in window) {
          this._domObserver = new MutationObserver(this._checkForIntersections);
          this._domObserver.observe(document, {
            attributes: true,
            childList: true,
            characterData: true,
            subtree: true
          });
        }
      }
    }
  }

然后通过getBoundingClientRect来计算目标元素和root容器的intersection

js 复制代码
const intersectionRect = computeRectIntersection(parentRect, targetRect);
js 复制代码
/**
 * Returns the intersection between two rect objects.
 * @param {Object} rect1 The first rect.
 * @param {Object} rect2 The second rect.
 * @return {?Object} The intersection rect or undefined if no intersection
 *     is found.
 */
function computeRectIntersection(rect1, rect2) {
  var top = Math.max(rect1.top, rect2.top);
  var bottom = Math.min(rect1.bottom, rect2.bottom);
  var left = Math.max(rect1.left, rect2.left);
  var right = Math.min(rect1.right, rect2.right);
  var width = right - left;
  var height = bottom - top;
  return width >= 0 && height >= 0 && {
    top: top,
    bottom: bottom,
    left: left,
    right: right,
    width: width,
    height: height
  };
}

DOM元素监听onAppear/onDisappear事件

raxpollfill需要在Rax环境(简单认为Rax=React)运行,开发者在使用时,需要给元素绑定onAppear和onDisappear事件:

tsx 复制代码
<div
  id="myDiv"
  onAppear={(event) => {
    console.log('appear: ', event.detail.direction);
  }}
  onDisappear={() => {
    console.log('disappear: ', event.detail.direction);
  }}
>
  hello
</div>

但是上面的代码(React的jsx)不能运行在浏览器中,还需要经过编译,然后借助react的运行时跑在浏览器中,归根结底会变为如下形式:

html 复制代码
<div id="myDiv">
  hello
</div>
<script>
  const myDiv = document.getElementById('myDiv');
  function handlerAppear(event) {
    console.log('appear: ', event.detail.direction);
  }
  myDiv.addEventListener('appear', handlerAppear);
  
  function handlerDisAppear(event) {
    console.log('disappear: ', event.detail.direction);
  }
  myDiv.addEventListener('disappear', handlerDisAppear);
</script>

上面这段代码,这个div元素将被作为IntersectionObserver观察的目标对象,当满足了交集判断的条件,就会触发onAppear的回调,可以在回调中处理自定义的逻辑。就像onClick一样,当点击发生后,执行onClick回调的内容,那这是怎么做到的呢?

  • 拦截原型方法:需要找一个时机(addEventListener的回调触发前),为当前元素绑定IntersectionObserver事件
  • 自定义事件:当元素的Observer的callback触发后,需要抛出onAppear/onDisappear事件,执行事件回调

先通过hack DOM元素上的原型方法,当eventName为appear的事件时,给元素绑定上IntersectionObserver,即执行observerElement(this),当eventName为其他事件时,不进行特殊处理。

js 复制代码
// hijack Node.prototype.addEventListener
const injectEventListenerHook = (events = [], Node, observerElement) => {
  let nativeAddEventListener = Node.prototype.addEventListener;

  Node.prototype.addEventListener = function (eventName, eventHandler, useCapture, doNotWatch) {
    const lowerCaseEventName = eventName && String(eventName).toLowerCase();
    const isAppearEvent = events.some((item) => (item === lowerCaseEventName));
    if (isAppearEvent) observerElement(this);

    nativeAddEventListener.call(this, eventName, eventHandler, useCapture);
  };

  return function unsetup() {
    Node.prototype.addEventListener = nativeAddEventListener;
    destroyAllIntersectionObserver();
  };
};

injectEventListenerHook(['appear', 'disappear'], window.Node, observerElement)

那当元素在root容器内的交集发生变化时,触发了Observer的回调,里面会执行dispatchEvent,如下:

js 复制代码
function handleIntersect(entries) {
  entries.forEach((entry) => {
    const {
      target,
      boundingClientRect,
      intersectionRatio
    } = entry;
    const { currentY, beforeY } = getElementY(target, boundingClientRect);
    // is in view
    if (
      intersectionRatio > 0.01 &&
      !isTrue(target.getAttribute('data-appeared')) &&
      !appearOnce(target, 'appear')
    ) {
      target.setAttribute('data-appeared', 'true');
      target.setAttribute('data-has-appeared', 'true');
      // 主要关注这里👇
      target.dispatchEvent(createEvent('appear', {
        direction: currentY > beforeY ? 'up' : 'down'
      }));
    } else if (
      intersectionRatio === 0 &&
      isTrue(target.getAttribute('data-appeared')) &&
      !appearOnce(target, 'disappear')
    ) {
      target.setAttribute('data-appeared', 'false');
      target.setAttribute('data-has-disappeared', 'true');
      // 主要关注这里👇
      target.dispatchEvent(createEvent('disappear', {
        direction: currentY > beforeY ? 'up' : 'down'
      }));
    }

    target.setAttribute('data-before-current-y', currentY);
  });
}

当执行了dispatchEvent,发出appear事件,也就会触发appear的addEventListener里的回调,就执行自定义逻辑。

DOM元素设置appear/disappear相关属性

用到了DOM的api,即setAttribute,通过这个api把appear和disppear的信息暴露在目标元素上,比如暴露元素是否是首次曝光,元素距离root容器的y值距离等等,方便业务开发获取相应值,从而执行业务自定义逻辑。


恭喜你,看到了文章的最后,希望能帮助到你,有任何疑问,欢迎留言,共同进步!

相关推荐
程序员阿超的博客23 分钟前
React动态渲染:如何用map循环渲染一个列表(List)
前端·react.js·前端框架
magic 24524 分钟前
模拟 AJAX 提交 form 表单及请求头设置详解
前端·javascript·ajax
小小小小宇5 小时前
前端 Service Worker
前端
只喜欢赚钱的棉花没有糖6 小时前
http的缓存问题
前端·javascript·http
小小小小宇6 小时前
请求竞态问题统一封装
前端
loriloy6 小时前
前端资源帖
前端
源码超级联盟6 小时前
display的block和inline-block有什么区别
前端
GISer_Jing6 小时前
前端构建工具(Webpack\Vite\esbuild\Rspack)拆包能力深度解析
前端·webpack·node.js
让梦想疯狂6 小时前
开源、免费、美观的 Vue 后台管理系统模板
前端·javascript·vue.js
海云前端6 小时前
前端写简历有个很大的误区,就是夸张自己做过的东西。
前端