配了三年 IntersectionObserver,每次写 reveal 动画都要靠它——今天 Chrome 145 把这件事彻底原生化了,5 行 CSS 替换 11 行 JS,性能还更好

我盯了三年 IntersectionObserver,每次写 reveal 动画都要靠它——今天 Chrome 145 把这件事彻底原生化了,5 行 CSS 替换 11 行 JS,性能还更好。

事情是这样的

配滚动触发动画,前几年只有一条路:IntersectionObserver。

这套流程写过一次的人都懂——建 observer、监听元素、entry.isIntersecting 时调用 animate()、然后 unobserve()。功能没问题,但它是跑在主线程的 JS,每次滚动都在和你的业务逻辑抢资源。

2026 年了,Chrome 145 带来了 scroll-triggered animations,把这套逻辑直接做进了浏览器渲染引擎里。

scroll-driven vs scroll-triggered:两个东西,别混了

在说新特性之前,先把这两个概念分清楚。

scroll-driven animation:动画进度和滚动位置连续绑定。滚多少,动画就走到多少。进度条随页面滚、视差效果——这类用 scroll() 或 view() 的 timeline,动画和滚动是同步的。

scroll-triggered animation:时间轴动画,但触发时机由滚动位置决定。元素进入视口 → 动画按自己的时长播放一遍 → 播完停在终点。和 IntersectionObserver 做的事情一样,只是实现方式从 JS 变成了 CSS。

两个东西解决不同问题,别混用。

11 行 JS,5 行 CSS

这是最直接的对比。

原来的写法:

const io = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.animate([
        { opacity: 0, translate: '0 32px' },
        { opacity: 1, translate: '0 0' }
      ], {
        duration: 600,
        easing: 'ease-out',
        fill: 'both'
      });
      io.unobserve(entry.target);
    }
  });
});
document.querySelectorAll('.reveal').forEach((el) => io.observe(el));

现在的写法:

.reveal {
  animation: fade-up 0.6s ease-out both;
  animation-trigger: --t play-forwards;
  timeline-trigger-name: --t;
  timeline-trigger-source: view();
}

@keyframes fade-up {
  from {
    opacity: 0;
    translate: 0 32px;
  }
  to {
    opacity: 1;
    translate: 0 0;
  }
}

5 行 CSS 替换了 11 行 JS,还少了 unobserve 的清理逻辑。

三个关键属性

timeline-trigger-source: view():告诉浏览器用元素的视口进入状态作为触发源。view() 会跟踪元素在视口中的可见性变化,和 IntersectionObserver 的逻辑完全对应。

timeline-trigger-name: –t:给触发器起个名字。名字空间是全局的,所以建议用 dashed ident(–xxx)的格式。

animation-trigger: –t play-forwards:当 –t 触发器激活时,执行 play-forwards 动作——动画从 0% 播到 100%,然后停在终点。

两个细节决定用对还是用错

第一个:play-forwards 和 play-once 的区别。

play-forwards 每次元素进入触发区都会重新播放。配合 animation-fill-mode: forwards 使用时,如果元素离开视口又重新进入,动画会重新触发——视觉上就是一次”闪”。

如果希望动画只触发一次,之后永久保持:play-once + forwards fill-mode

.square {
  animation: fade-bg-in 300ms forwards;
  animation-trigger: --trigger play-once;
  timeline-trigger: --trigger view() entry 100% exit 0%;
}

这个组合常用于”一次性入场动画”——卡片第一次滑入之后就固定不动了。

第二个:entry 和 exit 范围。

entry 100% exit 0% 的意思是:元素完全进入视口(底部边缘进入)时触发,元素顶部边缘离开视口时停用/反向。

常见写法:

  • entry 100% exit 0%:完全进入时触发,完全离开时反向
  • entry 0% exit 100%:元素顶部一进入就触发,完全离开才停用
  • contain 15% contain 85%:在视口中段 15%~85% 区间内保持激活

进阶:跨元素触发和 stagger

一个触发器可以同时控制多个动画。通过 trigger-scope 限制名字的作用域,可以避免全局名字污染:

.card {
  trigger-scope: --card-trigger;
  timeline-trigger: --card-trigger view() contain / entry 100% exit 0%;
  animation: unclip 0.4s ease-in-out both;
  animation-trigger: --card-trigger play-forwards play-backwards;
}

如果要实现 stagger 效果,每个卡片的 entry 时间点错开:

.card {
  --stagger-interval: calc(100% / sibling-count());
  --entry-point: calc(sibling-index() * var(--stagger-interval));
  timeline-trigger: --t view() entry var(--entry-point) exit 0%;
}

sibling-index()sibling-count() 会返回元素在兄弟列表中的位置,浏览器自己会算——不需要 JS 手动算 index。

性能账:为什么这件事值得关注

IntersectionObserver 跑在主线程。页面滚动时,浏览器需要遍历所有 observer、计算每个目标元素的可见性、然后触发回调——这些都是 JS 执行。

scroll-triggered animations 的触发逻辑做进了浏览器的布局引擎。浏览器在计算布局时”顺手”判断了触发条件,不需要额外触发 JS 回调。

对于一个拥有 20 个 reveal 动画的落地页:

  • IntersectionObserver 方案:每次滚动事件都可能触发 20 次回调
  • scroll-triggered 方案:零 JS 回调,动画进度由合成器线程独立更新

这不是微优化。JS 主线程的空闲程度直接影响 INP(Interaction to Next Paint),用 CSS 原生方案把这个负担卸掉,对真实用户体验有可测量的影响。

浏览器支持

Chrome 145+ 已支持。Safari 和 Firefox 还在跟进中。

渐进增强写法:

/* 默认:元素直接可见(降级方案) */
.reveal {
  opacity: 1;
  transform: none;
}

/* 现代浏览器:使用 CSS 动画 */
@supports (animation-timeline: view()) {
  .reveal {
    animation: fade-up linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 40%;
  }
}

@supports 检测比 UA 检测更可靠——Safari 哪天支持了,动画自然切换过去,不需要改代码。

下一步

这周挑一个你项目里的 reveal 动画,把 IntersectionObserver 那套逻辑卸掉,换成 animation-trigger + timeline-trigger-source: view()。11 行 JS 变 5 行 CSS,主线程少一堆回调。

不要一次全换。先换一两个 section,用 Chrome DevTools 的 Performance 面板对比一下 JS 执行时间,感受一下差异。

评论区

0 条评论

登录后可评论。

阿速·性能优化 52 阅读