DOM – IntersectionObserver

介绍

IntersectionObserver 的作用是监听某个元素是否出现在框内 (比如 viewport).

它可以实现 lazy load image, 一开始图片是没有加载的, 当图片出现在 viewport 时才去加载.

也可以用来做 tracking, 比如某个商品 (card 元素), 是否出现在 viewport, 这样就可以检测用户是否看了某个商品.

 

效果

 

参考:

IntersectionObserver’s Coming into View

Medium – IntersectionObserver’s coming into view

Trust is good, observation is better: Intersection Observer v2 (v2, 但是目前只有 chrome 99 支持, 所以这篇先不介绍了)

 

getBoundingClientRect

参考: getBoundingClientRect() 详解

先了解一下 rect, 通过 getBoundingClientRect 可以得知一个元素, 对标 viewport 的位置坐标. 它不管 scroll bar 有点像 position fixed 对标 viewport.

x, y, width, height 是 detect 到的,其它的都是冗余:

top 就是 y 

left 就是 x

right 就是 left + width 

bottom 就是 top + height

另外,width height 会受到 scale 的影响哦。

另外,x,y 是可以修改的,top, left 则是 readonly。

至于该使用 x, y 还是 top, left 呢?

看个人习惯吧,top, left 比 x, y 诞生的早,很多人一路写下来习惯了,而且 css 许多也是用 top left 比如 scrollTop, scrollLeft。

在 IntersectionObserver 诞生以前, 要模拟它的效果就得监听 scroll 然后通过调用 getBoundingClientRect 来判断 2 个元素是否交会.

补上一个计算细节:

rect.left 10 表示这个 element 左边有 10px (10 个 dot) 距离 viewport,第 11 个 dot 才是这个 element(或者更严谨的说, 10.0000...1 dot 就是 element, rect.left 是可能出现小数点的)。

rect.left + rect.width = 100 代表第 100 个 dot 是这个 element 的结尾,第 101 个 dot 就不是这个 element 了。

 

场景

先搭建一个简单的场景. 方便解释

<div class="container">
  <div class="box">box1</div>
  <div class="box">box2</div>
  <div class="box">box3</div>
  <div class="box">box4</div>
  <div class="box">box5</div>
  <div class="box">box6</div>
  <div class="box">box7</div>
  <div class="box">box8</div>
  <div class="box">box9</div>
  <div class="box">box10</div>
</div>

1 个 container 包着 10 个 box

CSS Style

.container {
  margin-top: 100px;
  margin-inline: auto;
  width: fit-content;
  .box {
    border: 1px solid red;
    width: 100px;
    height: 100px;
  }
}

效果

 

new IntersectionObserver()

const io = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      console.log(entry);
    });
  },
  {
    root: document,
    rootMargin: "0px",
    threshold: [0],
  }
);

首先看看它的调用方式.

new 一个 IntersectionObserver 实例.

第 1 个参数是触发时的回调. 不关心这个先.

第 2 个参数是一个 config.

root 指的是框, 一旦观察元素出现在框内就会触发回调. 默认是 viewport 也就是 document, 也可以设置成任何一个 element (通常是 scrollable 的).

rootMargin 指的是 extra 的范围, 如同框变大了. 比如说, 不想等到 element 出现在框内了才触发, 想提早, 那可以写 rootMargin: 50% (也可以写 px, percentage 对应框的大小)

所以它会提早半个屏幕就触发. 一般上 lazy load 图片都会提早, 而不是等到用户已经看见 img element 了才去 load, 这样就慢掉了.

threshold 是一个元素显示多少 % 时要触发, 比如

threshold: [0.1, 0.5, 1], 观察元素是 box4

分别会在这 3 个阶段触发回调. 比较常用的方式是 [0, 1], 刚出现时触发一次, 完整出现时触发一次.

注意:

1. 如果被观察的元素 height 超过框的 height, 那意味着永远不会出现 100% 显示. 那么 1 就不出触发了.

2. 只要元素出现在框内就会触发 (哪怕 viewport 看不见), 看下面的例子:

root 是 container 框 (不是 viewport 哦), click button 会 scroll container. 当把 viewport 移开以后, 点击 button 依然触发了. 因为元素显示在 container 框了. 

它不需要出现在 viewport 的框.

3. 不仅仅是 scroll 无论以何种方式出现在框内都会触发, 比如 transform translate 也会触发的.

 

observe, unobserve

observe

把框定义好以后, 就开始放入要观察的元素.

const box9 = document.querySelectorAll(".box")[8];
io.observe(box9);

注意:

1. 触发频率, 它不是立马触发的 (性能考量), 可能会有几毫秒的微差, 比如监听的是 0.5 (50% 显示), 但触发的时候是 0.52 (intersectionRatio).

2. observe 调用后, 不管元素是否出现在框内, 它都会触发第一次.

unobserve, disconnect

io.unobserve(box9)
io.disconnect()

不想监听了,可以调用 unobserve 把指定的元素监听去掉。

disconnect 并不会把 IntersectionObserver 整个关掉,它只是会把所以当前监听的元素 unobserve 而已。

没有 re-connect 的功能,我们也无法获取当前监听了的元素。

 

Callback Info

const io = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      console.log(entry);
      // entry.target;
      // entry.boundingClientRect;
      // entry.intersectionRect;
      // entry.isIntersecting;
      // entry.time;
      // entry.intersectionRatio;
      // entry.rootBounds
    });
  }
);

当元素显示时, 它就会触发 callback, entries 就是这一轮所有触发的 element (不是所有监听的 element 哦, 不关事的不会在 entries 里)

出于性能原因, 即使 element 是先后出现在框的, 但也可能被放到同一轮触发列表内.

target = 被观察的元素.

boundingClientRect = 被观察元素的 getBoundingClientRect(). (记得哦, getBoundingClientRect 一定是对标 viewport 的坐标, 所以这里是 target element 对标 viewport)

intersectionRatio = 多少 percentage 显示, 0.52 = 52% 显示在框内

rootBounds = 框 element 的 getBoundingClientRect(), (new IO 时的 root element 对标 viewport 坐标)

intersectionRect 

它和 boundingClientRect 差不多, 微差体现在:

区别在于它的 height 和 bottom。

bottom 是冗余, 我们看 height 就好了.

100px 是整个 box9 的高, intersectionRect 是 41px, 因为 threshold 是 0.4, 40% 显示时触发. 

所以 intersection 的 height 拿的不是完整的 box9 height, 而是已经显示的 box9 height. 所以就是大约 40px 了.

height 不同, bottom 自然也就不同了. 因为 bottom = y + height.

isIntersecting = 元素是否显示

isIntersecting 蛮妙的。

假设 threshold [0.5, 0.8],item height 100px

当 item 显示超过 50% 时,触发。

isIntersecting 会是 true,intersectionRect.height 大约是 52px

当 item 显示超过 80% 时,又触发。

isIntersecting 会是 true,intersectionRect.height 大约是 82px

接着退回,当 item 显示少过 80% 时,触发

isIntersecting 还是 true,intersectionRect.height 大约是 78px

最后,当 item 显示少过 50% 时,触发

isIntersecting 还是 false,intersectionRect.height 大约是 48px

可以看到 intersectionRect.height = 48px 表示实际上是有 intersect 的,只是因为没有达到 threshold 门槛,所以被认定为 isIntersecting false。

time = IO 有一个计时器, 从 new IO 后开始跑 start from 0ms.

每当触发的时候它就去截取当前的 time, 比如 5000 表示从 new IO 到这个元素触发已经过了 5 秒了. 它会一直跑, 不会 reset 的.

通过时间差就可以判断用户是否停留看着某个元素多久了. 具体的需求可能是, 想 tracking 某个 page section 用户是否游览. 但是那种一秒涮过的不算.

这时候就可以看 intersect in/out 的时间差, 来判断是否用户是慢慢划过的.

小心坑 の 微差

假设屏幕 window.innerHeight 是 945px

有一个 div 被 transform translateY(945px)

请问屏幕里看得见这个 div 吗?

答案是完全看不见,因为第 946 dot 才是这个 div 的 first dot。

那在 threshold: 0 的设定下,这样算 isIntersecting 吗?

答案是算!

这个时候触发 callback,得到的 isIntersecting 会是 true,但 intersectionRect.height 会是 0px。

我不确定是不是所有浏览器都会如此,但 Chrome 会。

要解决这个问题,比较好的方式是设置 threshold::0.01。

虽然查看 intersectionRect.height 也能确定有没有真的相交,但关键是它触发的机制,过了就不会在触发了,所以还是得准一点。

而且 threshold 一个可能还不够,比如说我的要求是 item 出现 1px 触发,0.5px 不要触发。

我们设置 threshold 它是一个 percentage,并不是多少 px,假设 item 是 100px,那 0.01 刚好是 1px 时触发一次。

但如果是 50px,就变成 0.5px 时触发,我们虽然可以过滤掉,但重点是 1px 时它就不触发了啊,唯一的解法是多设置几个 threshold: [0.01, 0.02, 0.03]。

设置多并不会影响性能,因为快起来的话,它会合并处理,只会触发一次。

 

触发时机

处于性能考量,IntersectionObserver 触发的非常晚

window.setTimeout(() => {
  const h1 = document.querySelector('h1')!;
  h1.classList.add('showing');

  const io = new IntersectionObserver(() => {
    console.log('IntersectionObserver', performance.now());           // 3, 1106.3999999910593
    h1.classList.add('showed');
  });
  io.observe(h1);

  requestAnimationFrame(() => {
    console.log('first requestAnimationFrame', performance.now());    // 1, 1086.5999999940395
    requestAnimationFrame(() => {
      console.log('second requestAnimationFrame', performance.now()); // 2, 1105.0999999940395
    });
  });
}, 1000);

第一次 requestAnimationFrame 触发在 ui render 之前

第二次 requestAnimationFrame 触发在 ui render 之后

IntersectionObserver 比第二次 requestAnimationFrame 还要晚触发。

Multiple IntersectionObserver 之前的触发时机

有两个 ResizeObserver 同时 observe 一个 element

const div = document.createElement('div');
document.body.append(div);
const io1 = new IntersectionObserver(() => {
  log('io1');
  queueMicrotask(() => log('mic1'));
  window.requestAnimationFrame(() => log('raf1'));
});
const io2 = new IntersectionObserver(() => log('io2'));
io1.observe(div);
io2.observe(div);

// 触发顺序:io1...mic1...io2...raf1

当这个 element 变更时

  1. 哪一个 ResizeObserver 先触发?

     不一定,有时候是 io1,有时候是 io2。

    我不清楚原理,但测试结果就是这样。

    注:Chrome 是这样,Firefox 的顺序始终如一,先 io1 后 io2,合理嘛。

  2. 它们触发之间的间隔又是多久?

    视乎有一个 microtask 间隔,这就类似于 multi listen click event

    我们在 io1 callback 里 queueMicrotask,它会比 io2 callback 还要早被执行。

 

当遇上 display: none, visibility: hidden 和 opacity: 0

display: none 的情况下,isIntersecting 会是 false。

visibility: hidden 和 opacity: 0 则 isIntersecting 是 true。

 

posted @ 2022-03-11 15:13  兴杰  阅读(434)  评论(0)    收藏  举报