【免费下载链接】interior
micro-interactions for react, built for the half-second after a click
interior 是一套 React 微交互(micro-interactions)组件库,它的核心目标只有一个:在点击发生后的半秒里,用 60fps 的流畅反馈赢得信任。这套库最反直觉的地方在于——动画过程中 React 从不重渲染。每帧变化的数值绕开虚拟 DOM,直接写入真实 DOM;React 只负责"值变了"之后的一次性渲染。本文带你拆解这套性能思路:为什么 60fps 下让 React 参与每帧更新是性能杀手,以及 interior 用哪三种通道实现"直写 DOM"。
为什么 React 不该在 60fps 下重渲染
先给结论。interior 的设计规范里有一条对所有组件生效的不变量(invariant):
当数值每帧都在变化时,直接写 DOM。React 不应该为了移动一个数字而以 60fps 重渲染。
原因并不复杂:React 的重渲染意味着 diff 虚拟 DOM、更新属性、触发副作用。一秒 60 次,哪怕子树只有几个节点,主线程也会持续排队执行这些本可以省掉的工作。动画本身只需要"把元素挪一挪",这完全是浏览器合成器(compositor)和一行style写入就能完成的事。
interior 的每个组件都是 components/interior/ 下的单个自包含文件(无 npm 包,复制即用,唯一依赖是motion),这些性能规则是每个文件都要遵守的硬约束,完整清单见 DESIGN.md。
下面三种"直写 DOM 的通道",分别对应三种典型的每帧变化场景。
直写 DOM 的三种通道
通道一:requestAnimationFrame 循环直写 style.transform
适用场景:无限循环类动画,比如 logo 跑马灯。
components/interior/logo-marquee.tsx 的实现非常直白:
- 位移量
offset存进ref,而不是 state; - 一个
requestAnimationFrame循环每帧推进偏移量,并直接执行track.style.transform = "translate3d(x, 0, 0)"; - React 在整个跑动过程中零次渲染。只有窗口尺寸变化、悬停暂停这类离散事件才会触发状态更新。
注意细节:willChange: "transform"是静态写死的,绝不能在动画开始回调里切换——这正是下面"坑一"的由来。跑动速度用dt(帧间隔)乘以速率计算,所以不同刷新率下速度一致。
上图是 components/interior/lightbox.tsx 灯箱演示所用的河谷图片。它的拖拽、双指缩放等手势全部在 React 渲染路径之外完成,滚轮与拖动手感正是这类 60fps 交互的标准样本。
通道二:MotionValue 事件里回写 textContent
适用场景:数字滚动、百分比读数这类"值连续变化但只需显示取整结果"的场景。
components/interior/poll-results.tsx 的投票结果揭晓就是例子:进度由motionValue驱动,动画每帧触发useMotionValueEvent回调,回调里把取整后的百分比直接写进node.textContent,并带一个同值守卫——
if (node.textContent !== next) node.textContent = next;帧率再高,DOM 文字只在"取整值真的变了"时才被写一次;React 从始至终不参与逐帧更新,动画结束后才有一次setLanded(true)收尾渲染。
通道三:MotionValue 全链路,只在阈值处渲染一次
适用场景:滚动驱动组件,比如吸顶收缩页头。
components/interior/sticky-header.tsx 是另一条路:所有逐帧数值(高度缩放、位移、透明度)全部由useScroll+useTransform派生出的 MotionValue 表达,浏览器在滚动发生的当帧就把它们解析掉,不需要任何 JS 每帧跑。React 唯一的参与点是阈值开关:进度越过 1 时把condensed置为true,同样带同值检查setCondensed((prev) => (prev === done ? prev : done))——整段滚动只多渲染一次。
这个写法还有个附赠好处:滚动驱动的值本来就"各帧各算各的",所以它天然兼容prefers-reduced-motion,不需要额外的降级分支。
第四招:把 60fps 的值量化成少数几档
前三条通道完全绕开 React;还有一种折中方案值得记住——量化(quantisation)是渲染预算,不只是视觉风格。
按住确认、阅读进度这类组件需要 React 感知进度(要改变文案、切换状态),那就把连续的进度折算成少数离散档位:
- components/interior/hold-to-confirm.tsx:550ms 的按住进度折算成20 档格子;
- components/interior/reading-progress.tsx:滚动进度折算成24 档,rAF 节流读取滚动位置后取整。
两者共享同一行关键代码:
setStep((prev) => (prev === s ? prev : s));rAF 照样以 60fps 跑,但这个同值检查让 React 只在档位真正变化时才渲染——24 档的进度条整段滚动只渲染 24 次,而不是几百次。interior 规范里说得直接:漏掉这行身份检查,一个 1800ms 的按住确认就会白白付出上百次渲染。
components/interior/blur-up-image.tsx 模糊渐显演示用的城堡图片。模糊到高清的淡入只动画opacity,同样走的是合成器通道,不产生额外重渲染。
三个真实的卡顿坑,interior 都踩过
DESIGN.md 里专门记录了"让它变卡的三种方式",都是这个项目真实付过学费的错误,按排查耗时排序:
- 动画过程中设置 React 状态❌ 在
onAnimationStart里切换willChange标志位,会让子树在飞行途中重渲染、直接掉帧。性能提示要静态声明,或者干脆不写。 - 动画
mask-image❌ mask 每帧重绘且无法被合成。要做边缘淡入淡出,改为动画一个渐变遮罩层的opacity——那完全跑在合成器上。mask-image用可以有,但必须保持静态。 - 用布局属性做动画❌ 列表行离场应该动画
opacity和x,兄弟节点用layout="position"补位;把height动画到 0 会让整条列表每帧重新布局。
一句话记忆法:transform / opacity 随便动,layout 和 mask 别动。
如何把这套思路搬进你的项目
不需要引入任何新库,四条检查清单就够了:
- ✅ 每帧变化的值(位移、透明度、读数)放
ref或 MotionValue,别放 state; - ✅ 逐帧写入走三条通道之一:rAF 循环写
style.transform、事件回写textContent、或useTransform全链路; - ✅ 必须让 React 感知时,量化成离散档位 + 同值检查
prev === next ? prev : next; - ✅ 只动画
transform和opacity;需要布局变化时用layout/layoutId交给 motion,而不是手动动画height。
想直接抄作业的话,每个组件都是"headless hook(全部行为)+ 示例组件(样式)"两件套:行为逻辑在useXhook 里,与类名零耦合,改皮零成本。文档站的每个组件页(Preview、Install、Source)都由 app/docs/[slug]/page.tsx 在构建时从磁盘读取真实源码生成,演示则注册在 lib/demos/ 目录。挑一个和你场景最像的组件通读一遍,比读十篇性能文章更有效。
🎬 总结:React 负责"发生了什么",浏览器负责"每一帧"。interior 的全部 60fps 技巧,就是守住这条分工线。
【免费下载链接】interior
micro-interactions for react, built for the half-second after a click
相关推荐
WebGL 渲染时间测量:为什么 gl.finish() 不可行,以及正确的性能测量方案
WebGL 渲染时间测量:为什么 gl.finish 不可行,以及正确的性能测量方案 导读 :本文基于 webgl fundamentals 仓库的 Q&A 文
图形学Cherry Studio 渲染层性能实践:为什么不能在组件内定义组件,以及如何避免整树重挂载
Cherry Studio 渲染层性能实践:为什么不能在组件内定义组件,以及如何避免整树重挂载 本文围绕 Cherry Studio 仓库内置的 React 最
AI应用大模型本地部署桌面应用AI AgentDoctrine Collections 序列化指南:为什么不要对集合直接 serialize(),以及 toArray() 重建的正确姿势
Doctrine Collections 序列化指南:为什么不要对集合直接 serialize ,以及 toArray 重建的正确姿势 导读 本文聚焦 Doct
后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考