算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 进入视口触发:IntersectionObserver 待审核 32 / 47
IntersectionObserver · reveal · trigger

进入视口触发:IntersectionObserver

「滚动驱动」有两种语义,容易混。高级交互里的 scroll-linked 是把播放头绑死在滚动量上:进度等于 scrollTop 归一化,滚动条本身就是播放头,来回拖会让动画来回 scrub。

本页是另一种——元素进入视口时触发一次入场动画。滚动只负责「越过阈值」这个一次性事件,触发后动画按自己的时间轴播完,与后续滚动无关。一个是 scrub(连续映射),一个是 trigger(离散触发)。

1 · 跨阈值触发

图 1-1 · 卡片露出比例越过 threshold 时播一次入场动画。可调阈值与错峰间隔,或打开 repeat 让离开视口的卡片重置。

2 · trigger 与 scrub 的分野

差别有两处。其一,scrub 的进度是滚动量的连续函数,回滚动画就倒退,动画长度等于滚动区间;trigger 只取跨阈值这一离散事件,动画长度是它自己的 duration,与滚动多少无关。

其二,scrub 永远可逆,trigger 在 once 模式下不回退——这正是入场动画想要的:出现一次就留在那里。实现上,观察到 isIntersecting 且比例达标即播放入场,并对该元素 unobserve,此后无论怎样滚动都不再回调。repeat 模式则保留观察:进入时播放、离开时把元素重置回隐藏初态。

注 · 手动监听 scroll 需要每帧读 getBoundingClientRect 判断是否进入视口,既频繁又易触发重排。IntersectionObserver 把「相交比例跨过阈值」交给浏览器异步算,只在跨阈值时回调一次,天然适合 trigger 类场景。root 设为滚动容器即可在容器内而非整页判定相交。

警示 · threshold 传的是一个数组而非单值:本页给的是 [0, threshold, 1]。只传目标阈值的话,元素从「完全不可见」跳到「超过阈值」的那一帧可能不产生回调——观察器只在跨过列表中的某个值时才通知。补上 0 与 1 两端,进入与离开都能稳定拿到事件,repeat 模式的重置才不会漏。

3 · 错峰

多张卡片同时越过阈值时,同一批 entries 会一起送达。按元素索引排序后叠加 delay,就得到依次浮现的节奏——与交错是同一件事,只是这一「组」由观察器的一次回调界定,而非预先给定的元素列表。

// trigger 语义: 跨阈值触发一次, 与后续滚动无关 (对比 scroll-linked scrub)
const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    if (e.isIntersecting && e.intersectionRatio >= threshold) {
      new Animation({                  // 入场: fade + slide-up
        keyframes: [0, 1],
        duration: 520,
        ease: 'easeOut',
        onUpdate: (p) => {
          el.style.opacity = p;
          el.style.transform = `translateY(${(1 - p) * 28}px)`;
        },
      });
      if (once) io.unobserve(e.target); // once: 出现过就不再回退
    } else if (repeat) {
      reset(e.target);                 // repeat: 离开视口重置, 再进入重播
    }
  }
}, { root: scroller, threshold });     // root = 滚动容器, 容器内判定相交

与之对照的 scroll-linked scrub 见高级交互;入场动画本身用的多关键帧 fade 加 slide-up 见关键帧