IT加油站

React 滚动动画:不碰主线程的原生方案与 3.7kB Hook 库

21浏览 1天前 软件教程 MA123212

原文:https://dev.to/saadahmad/scroll-animations-in-react-that-never-touch-the-main-thread-473b(作者 @saadahmad)

过去几年,我用过的每一个滚动动画库都在做同样的事:监听滚动事件,读取元素位置,然后写入一个新的 transform 值。每秒六十次,在主线程上,与你的应用水合、数据获取以及你自己的点击处理程序并行运行。

自 Chrome 115(2023年7月发布)起,浏览器就提供了更好的方式。ScrollTimelineViewTimeline 让你将关键帧交给浏览器自身的动画引擎,并指定“此动画的播放头即为滚动位置”。对于 transformopacityfilter,合成层会接管一切。无需每帧运行 JavaScript。一帧都不用。

几乎没人使用它。不是因为它冷门——而是因为它用起来很别扭:

@keyframes reveal {
  from { opacity: 0; translate: 0 32px; }
  to   { opacity: 1; translate: 0 0; }
}

.card {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 40%;
}


在样式表里这样写没问题。但在组件内部,当距离是属性、范围取决于布局时,这意味着要动态生成 CSS 字符串或手动编写 <style> 标签。而且你仍然需要回答“在 Firefox 中怎么办?”——截至今天,其稳定版中滚动驱动动画仍需通过 flag 启用。

这两个问题正是一个 Hook 应该隐藏的。于是我构建了它。

use-scroll-timeline

npm i use-scroll-timeline


"use client";
import { useScrollReveal } from "use-scroll-timeline";

export function Card() {
  const ref = useScrollReveal<HTMLDivElement>({ variant: "fade-up" });
  return <div ref={ref}>I reveal myself as I scroll into view</div>;
}


  1. 7 kB(压缩后+gzip)。零依赖。六个 Hook。无需 Provider、Context 或 CSS 导入。

[在线演示 — 每个 Hook 及实时数据 →](https://use-scroll-timeline-demo.vercel.app/)

任何更具体的需求都通过核心 Hook 实现,它接受标准的 Web Animations 关键帧对象——与你传给 element.animate() 的对象相同:

const ref = useScrollTimeline<HTMLElement>({
  keyframes: {
    opacity: [0, 1],
    scale: ["0.8", "1"],
    filter: ["blur(8px)", "blur(0px)"],
  },
  range: ["entry 0%", "cover 40%"],
});


我最喜欢的部分:进度作为 CSS 变量

大多数滚动库会给你一个数字,让你把它存入 state。这个数字每帧都会到达,因此你的组件每帧都会重新渲染,于是你又得到了一个带有额外步骤的 JavaScript 滚动动画。

useScrollProgress 则是将进度值写入一个 CSS 自定义属性,并且不触发任何重渲染:

const ref = useScrollProgress<HTMLDivElement>({ timeline: "scroll" });

<div ref={ref} className="reading-bar" />;


.reading-bar {
  transform-origin: left;
  scale: var(--progress, 0) 1;
}


当原生 API 可用时,Hook 会使用 CSS.registerProperty 注册该属性,让浏览器对其进行动画处理。因此,页面顶部的阅读进度条——每个博客都用滚动监听器实现的那个东西——主线程开销为零。

对于确实需要在 React 中获取数字的场景,useScrollProgressValue 仍然可用。它通过 precision 选项进行节流,默认在整个范围内大约只产生 100 次渲染,而非每帧一次。

最巧妙的部分在于回退方案

这是我原本以为会很臃肿,结果却非常简洁的部分。

朴素的回退方案是在 JavaScript 中重新实现关键帧插值——解析值,进行线性插值,写入样式。这会带来大量代码,且在处理 translatetransformfilter 的区别时很容易出错。

相反:构建完全相同的 WAAPI 动画,暂停它,然后控制其播放。

const animation = element.animate(keyframes, { duration: 1000, fill: "both" });
animation.pause();

// 之后,在来自滚动驱动的回调中:
animation.currentTime = progress * 1000;


浏览器本身就是一个卓越的关键帧插值器。回退方案借用了它,只替换了时钟源。相同的关键帧、相同的缓动、相同的填充行为,无需自己编写任何插值代码——原生路径与回退方案的差异仅在于“播放头从何而来”,仅此而已。

驱动器本身每个元素仅使用一个 IntersectionObserver 来控制工作启动,而整个页面只需一个共享的 requestAnimationFrame 循环加上一个捕获阶段的 scroll 监听器。十个动画元素只需承担一次帧回调,而非十次。

命名范围:真正的 API

变体是营销,范围才是特性。这是 CSS 规范中的词汇,钩子直接将其作为字符串接收:

  • `cover`:当主体的前沿触及滚动容器时开始,当主体完全离开时结束。
  • `entry`:当主体开始进入时开始,当主体完全进入时结束。
  • `exit`:当主体开始离开时开始,当主体完全离开时结束。
  • `contain`:当主体完全进入时开始,当主体开始离开时结束。
range: ["entry 25%", "contain 50%"]


有一点让我耗费了一个下午,并且无论你是否使用这个包都值得一知:`entry` 范围的长度恰好等于元素自身的高度。 一张 150px 高的卡片,其整个显现过程会在 150px 的滚动距离内完成。它技术上可行,符合规范,但看起来就像什么都没发生一样。

将范围结束点设得更靠后,同样的动画就变得清晰可见了:

useScrollReveal({ range: ["entry 0%", "cover 40%"] }); // ~400px 的滚动行程


直到我构建了一个面板,通过 entrycontainexit 同时观察一个元素并打印出所有三个数值,我才真正理解了这一点。观察它们如何相互交接,是理解该模型的最快方式——这也是演示的第一部分。

减弱运动,以及为什么你的页面可能看起来坏了

默认会尊重 prefers-reduced-motion: reduce:钩子会跳过动画,直接让元素停留在最终的关键帧上。

这是正确的行为,但看起来完全像个 Bug。如果你在 Windows 中关闭了“动画效果”,或在 macOS 中开启了“减弱动态效果”,页面上的每个显现动画都会直接显示为已显现,视差层保持静止,更改属性似乎毫无作用。我自己就因此浪费了二十多分钟。两个演示现在都会检测这种情况并显示提示横幅,你也可以通过 respectReducedMotion: false 在每个钩子中选择不遵循此设置。

原生支持情况

  • Chrome / Edge 115+:原生 ViewTimeline / ScrollTimeline
  • Safari 26+:原生支持,26.4 版本后采用线程化执行
  • Firefox:回退方案——在稳定版中仍需启用 flag
  • 其他旧版浏览器:回退方案

目前大约 80% 的用户可以使用合成器路径,这个比例只会继续增长。其余用户得到的动画在视觉上效果相同,且仍然比典型的 scroll-listener 实现更高效,因为整个页面共享一个循环。

发布过程教会我的三件事

tsup 静默吃掉了我的 `"use client"` 指令。 横幅配置正确,但 tsup 的 rollup 树摇过程会从打包产物中剥离模块级指令——因此构建后的文件没有指令,这个包在 App Router 中会失效。通过一个小型的后构建脚本修复,该脚本在第一条语句的同一行重新附加指令,从而保持 source-map 的行号准确。

`publint` 发现了我导出映射中的一个真实 Bug。 我只有一个 types 字段,这意味着即使消费者使用 require(),TypeScript 也会解析 ESM 的 .d.ts。CJS 用户将只能获得在动态 import() 下才有效的类型定义。将其拆分为指向 .d.ts.d.ctsimportrequire 条件。在首次发布前,务必运行 publint --strictattw --pack .

`publishConfig` 中的 `"provenance": true` 会导致手动发布失败。 来源证明只能在受支持的 CI 运行器上生成,因此这个标志会使从笔记本电脑执行的 npm publish 直接失败。将其从清单文件中移除,改为在发布工作流中传递 --provenance 参数。

链接

目前版本是 0.1.0,因此 API 仍可能变动。如果你使用它并觉得哪里不便,提一个 issue 真的很有帮助——这是目前我能收到的最有用的东西。


Saad Ahmad 打造。我构建小型、专注的前端库以及展示它们的网站——涉及 TypeScript、React、Next.js,以及对平台在不依赖外部库的情况下能做什么抱有略显执着的兴趣。

更多作品请访问 [isaadahmad.com](https://www.isaadahmad.com)

如果你的团队正在为卡顿的滚动页面而挣扎,为四个效果携带了 70 kB 的动画库,或是需要正确打包的组件库——这类工作正是我接手的范畴,我的收件箱随时开放。

原文:https://dev.to/saadahmad/scroll-animations-in-react-that-never-touch-the-main-thread-473b(作者 @saadahmad)

#react #滚动动画 #性能优化 #Web Animations API #前端开发