很多前端同学第一次听到贝塞尔曲线这四个字脑子里浮现的都是数学课本里那堆控制点、参数方程觉得离日常写页面很远。但只要你用过 CSS 的cubic-bezier()缓动函数或者调过动画曲线其实你已经在用它了。区别在于CSS 给的是黑盒——你只能填四个数字剩下的交给浏览器。而当我们想做一个真正属于自己的动画库想让动画能暂停、能反向、能中途改速度、能串联成时间线就必须把这层黑盒拆开自己用 JavaScript 把三次贝塞尔曲线算出来。这篇内容就是围绕用 JavaScript 实现一个三次贝塞尔动画库并且做成前端组件化这件事展开的。我会从最底层的数学原理讲起一路讲到怎么把它封装成一个能复用的组件、怎么处理时间与进度的映射、怎么避开浮点误差和性能陷阱。适合已经会写 JS、做过一些动画但想搞清楚动画库内部到底在干什么的开发者。看完之后你不仅能自己撸一个能用的缓动引擎还能理解市面上那些动画库的设计取舍。1. 为什么值得自己动手写一个贝塞尔动画引擎1.1 从 CSS 缓动的三个天花板说起先说清楚动机。CSS 的transition和animation已经很强了为什么还要自己写我总结下来有三个绕不过去的天花板。第一个是控制粒度。CSS 动画一旦启动你很难在运行过程中精确地拿到当前进度是多少。getComputedStyle能读到当前transform的值但那是字符串解析成本高而且拿不到归一化的进度值。你想做一个进度条跟着动画走的效果用纯 CSS 就很别扭。第二个是组合能力。CSS 的animation-delay可以做出简单的串联但一旦涉及动画 A 播到 60% 时触发动画 BB 播到一半时 A 反向这种逻辑CSS 就力不从心了。这类需求在交互动效里非常常见比如拖拽回弹、手势跟随。第三个是自定义缓动的自由度。CSS 的cubic-bezier()只接受两个控制点P1 和 P2起点固定在 (0,0)终点固定在 (1,1)。这在绝大多数场景够用但如果你想做多段拼接的复合曲线或者想让曲线在某个区间内做特殊处理CSS 就做不到了。自己写引擎本质上就是把时间到进度的映射函数握在自己手里。一旦这个函数是你的上面三个天花板就都不存在了。1.2 三次贝塞尔曲线到底在描述什么先把概念理清楚。三次贝塞尔曲线Cubic Bezier由四个点定义起点 P0、两个控制点 P1 和 P2、终点 P3。它的参数方程是B(t) (1-t)³·P0 3(1-t)²t·P1 3(1-t)t²·P2 t³·P3其中 t 的取值范围是 [0, 1]。在动画场景里我们通常把 P0 固定在 (0,0)P3 固定在 (1,1)只让 P1 和 P2 自由。这样曲线就变成了一个从 (0,0) 到 (1,1) 的函数图像横轴是时间进度纵轴是动画进度。P1 和 P2 的坐标决定了曲线的形状也就决定了动画的手感。这里有个关键点很多人会忽略这条曲线描述的是 x 和 y 的关系但动画需要的是给定时间 x求进度 y。而贝塞尔曲线的参数方程给的是给定参数 t求 x 和 y。也就是说我们已知 x要求 y中间隔着一个 t。这就是为什么所有正经的贝塞尔动画库核心都在做一件事——根据 x 反解 t再代入求 y。1.3 一个生活化的类比你可以把这条曲线想象成一辆车从 A 点到 B 点的行驶过程。横轴是时间纵轴是走过的路程。如果曲线一开始很陡说明车起步就冲出去了ease-out 的感觉如果一开始很平说明车慢慢加速ease-in 的感觉。控制点 P1 和 P2 就像是两个引力源把曲线往它们的方向拽。P1 拽的是起步阶段P2 拽的是收尾阶段。理解了这一点你就能明白为什么cubic-bezier(0.25, 0.1, 0.25, 1)是默认的 ease——它的 P1 偏下、P2 偏上形成一条先慢后快再慢的 S 形曲线符合大多数人对自然运动的直觉。2. 核心算法从 x 反解 t 的三种思路2.1 直接解方程为什么行不通有人会想既然有参数方程那我把 x 的表达式写出来解出 t再代入 y 不就行了理论上可以但三次方程求根公式卡尔达诺公式涉及复数运算和开立方数值稳定性差而且每次求值都要算一遍性能上不划算。所以实际工程里没人这么干。主流做法是数值方法也就是用迭代逼近的方式找到满足精度要求的 t。下面介绍三种常见思路从简单到高效。2.2 二分查找最稳但最慢二分查找的思路很朴素t 在 [0,1] 之间我每次取中点算一下 x(t) 和目标的 x 比大小然后收缩区间。重复若干次直到区间足够小。function solveTForX(x, x1, x2, epsilon 1e-6) { let lo 0, hi 1, t x; for (let i 0; i 30; i) { const currentX cubicBezier(t, 0, x1, x2, 1); if (Math.abs(currentX - x) epsilon) return t; if (currentX x) lo t; else hi t; t (lo hi) / 2; } return t; }优点是绝对不会发散缺点是每次求值要迭代 20 到 30 次如果一帧要算几十个元素的动画开销就上来了。适合作为保底方案或者调试时的参考实现。2.3 牛顿迭代快但需要好的初值牛顿迭代法Newton-Raphson利用导数快速逼近。它的迭代公式是t_{n1} t_n - f(t_n) / f(t_n)其中 f(t) x(t) - targetX。因为贝塞尔曲线的导数很好求就是三个差分项所以牛顿法收敛非常快通常 3 到 4 次迭代就能达到 1e-7 的精度。function cubicBezierDerivative(t, p0, p1, p2, p3) { const mt 1 - t; return 3 * mt * mt * (p1 - p0) 6 * mt * t * (p2 - p1) 3 * t * t * (p3 - p2); }但牛顿法有个坑如果初值选得不好或者曲线在某段导数接近 0迭代可能震荡甚至发散。所以工程上通常用牛顿法为主二分法兜底的混合策略。2.4 采样表 线性插值浏览器引擎的做法这是性能最好、也是实际浏览器内部采用的做法。思路是预先在 t 的 [0,1] 区间上均匀采样比如取 11 个点算出对应的 x 值存成一张表。运行时给定 x先在表里找到它落在哪个区间然后用线性插值估算 t。const SAMPLE_COUNT 11; const sampleTable new Float32Array(SAMPLE_COUNT); for (let i 0; i SAMPLE_COUNT; i) { sampleTable[i] cubicBezier(i / (SAMPLE_COUNT - 1), 0, x1, x2, 1); }采样表法的精度取决于采样密度。11 个点对于大多数缓动曲线够用误差在千分之一量级人眼完全看不出来。如果对精度要求更高可以加密到 21 个点或者用二分法在插值结果附近再精修一次。提示采样表是在创建缓动函数时一次性算好的运行时只做查表和插值所以性能极佳。这也是为什么很多动画库在初始化时会有一次预热开销。2.5 三种方案的取舍对比方案精度单次求值开销实现复杂度适用场景二分查找高高20 次迭代低调试、保底牛顿迭代高低3-4 次迭代中通用引擎采样表插值中高极低O(1)中高频调用、多元素我个人的选择是默认用采样表遇到精度敏感的场景比如需要严格对齐的动画再叠加一次牛顿精修。这样既保证了性能又不会在极端情况下翻车。3. 把缓动函数封装成可复用的组件3.1 组件化的边界在哪里组件化这个词在前端被用得很泛。放到动画库这个语境里我理解的组件化是把缓动曲线和动画驱动这两件事解耦各自成为独立可替换的模块。缓动曲线负责给定进度 p返回缓动后的进度 p动画驱动负责在时间轴上推进进度调用缓动函数把结果应用到目标属性上。两者通过一个约定好的接口通信。这样做的好处是你可以随时换一条曲线而不动驱动逻辑也可以换一套驱动比如从 requestAnimationFrame 换成 Web Animations API而不动曲线。3.2 缓动函数的统一接口设计我设计的接口是这样的// 创建一个缓动函数 // 返回一个 (progress: number) number 的纯函数 function createEasing(x1, y1, x2, y2) { // 预计算采样表 const samples buildSampleTable(x1, x2); return function ease(progress) { if (progress 0) return 0; if (progress 1) return 1; const t solveTFromSamples(progress, samples); return cubicBezier(t, 0, y1, y2, 1); }; }这个设计有几个讲究。第一返回的是纯函数没有副作用方便缓存和复用。第二边界值直接短路progress 为 0 或 1 时直接返回避免浮点误差导致动画首尾抖动。第三采样表在闭包里预计算每次调用不重复算。3.3 内置曲线的预设集合一个能用的库光有createEasing不够还得有一批开箱即用的预设。我参考了常见的缓动命名整理了一套const Easings { linear: createEasing(0, 0, 1, 1), ease: createEasing(0.25, 0.1, 0.25, 1), easeIn: createEasing(0.42, 0, 1, 1), easeOut: createEasing(0, 0, 0.58, 1), easeInOut: createEasing(0.42, 0, 0.58, 1), // 一些更有性格的曲线 backOut: createEasing(0.34, 1.56, 0.64, 1), elasticOut: createEasing(0.22, 1.61, 0.36, 1), };注意backOut和elasticOut的 y 坐标超过了 1这意味着动画会冲过头再回来产生回弹效果。这是贝塞尔曲线相比 CSS 关键字缓动的一个优势——y 值可以超出 [0,1] 范围做出更夸张的动效。3.4 动画驱动器的骨架驱动器负责把缓动函数用起来。核心是一个基于requestAnimationFrame的循环class Tween { constructor({ from, to, duration, easing, onUpdate, onComplete }) { this.from from; this.to to; this.duration duration; this.easing easing || Easings.linear; this.onUpdate onUpdate; this.onComplete onComplete; this.startTime null; this.paused false; this.pausedAt 0; } tick(now) { if (this.startTime null) this.startTime now; const elapsed now - this.startTime - this.pausedAt; let progress Math.min(elapsed / this.duration, 1); const eased this.easing(progress); const value this.from (this.to - this.from) * eased; this.onUpdate(value, progress); if (progress 1) { this.rafId requestAnimationFrame(this.tick.bind(this)); } else { this.onComplete this.onComplete(); } } }这段代码里有几个容易踩坑的地方我在下一节展开讲。4. 时间轴、暂停与反向那些文档不会告诉你的细节4.1 为什么不能用 Date.now() 累加新手写动画循环很容易写成这样let last Date.now(); function loop() { const now Date.now(); const delta now - last; last now; elapsed delta; // ... }这种写法在大多数情况下没问题但有两个隐患。第一标签页切到后台时requestAnimationFrame 会暂停但 Date.now() 不会。等你切回来delta 会是一个巨大的值动画直接跳到结尾。第二累加会累积浮点误差长时间运行的动画会慢慢漂移。正确做法是记录起始时间戳每次用当前时间减去起始时间而不是累加 delta。这样即使中间有跳帧进度也是准确的。至于后台切换的问题可以在visibilitychange事件里做处理或者干脆接受切回来动画直接结束这个行为——大多数场景下这是可接受的。4.2 暂停与恢复的正确姿势暂停不是简单地停掉 rAF因为那样会丢失已经播了多久的信息。我的做法是记录一个pausedAt偏移量pause() { if (this.paused) return; this.paused true; this.pauseStart performance.now(); cancelAnimationFrame(this.rafId); } resume() { if (!this.paused) return; this.paused false; this.pausedAt performance.now() - this.pauseStart; this.rafId requestAnimationFrame(this.tick.bind(this)); }关键在pausedAt这个累加值。它记录的是总共暂停了多长时间在计算 elapsed 时减掉它就能保证恢复后动画从暂停的位置继续而不是跳变。4.3 反向播放的两种实现反向播放有两种思路。一种是把 from 和 to 对调重新跑一遍简单但会丢失当前进度。另一种是保持时间轴不变把进度映射反过来const eased this.easing(1 - progress);第二种更好因为它可以在任意时刻切换方向动画不会跳。实现上就是给 Tween 加一个direction字段在计算 eased 时根据方向决定用 progress 还是 1-progress。注意反向播放时缓动曲线也会跟着反向。如果你用的是非对称曲线比如 easeIn反向播放会变成 easeOut 的效果。这在某些场景下是符合预期的但在另一些场景下不是。如果需要反向时保持同样的手感就得单独指定反向曲线。4.4 时间线串联的调度逻辑单个 Tween 好办难的是把多个 Tween 串成时间线。我的做法是引入一个Timeline类内部维护一个 Tween 数组每个 Tween 有一个startTime偏移量。Timeline 自己跑一个 rAF 循环根据当前时间决定哪些 Tween 该启动、哪些该结束。class Timeline { constructor() { this.tweens []; this.currentTime 0; } add(tween, offset 0) { this.tweens.push({ tween, offset, started: false }); return this; } tick(now) { // 更新 currentTime遍历 tweens启动到时间的推进进行中的 } }这里有个细节Tween 的 startTime 不应该由它自己记录而应该由 Timeline 统一管理。否则多个 Tween 各自记时间容易出现第一个 Tween 因为初始化慢而晚启动的问题导致串联出现缝隙。5. 组件化落地从函数到可复用模块5.1 用类还是用工厂函数JS 里封装组件类class和工厂函数factory各有拥趸。我的经验是如果组件有明确的状态和生命周期用类如果只是无状态的转换逻辑用工厂函数。缓动函数用工厂函数因为它就是纯转换没有状态。Tween 和 Timeline 用类因为它们有 start、pause、resume、cancel 这些生命周期方法用类表达更自然。混用不是问题关键是别为了统一风格而强行二选一。5.2 事件回调的设计动画库的回调设计我踩过的坑是回调里抛异常会中断整个动画循环。比如onUpdate里访问了一个 undefined 的属性异常抛出来rAF 循环就断了后续动画全停。解决办法是在调用回调时包一层 try-catchtry { this.onUpdate(value, progress); } catch (e) { console.error(Tween onUpdate error:, e); }这样单个回调出错不会影响整个引擎。代价是错误被吞掉了所以一定要打日志别静默失败。5.3 与框架的集成方式如果你在 React 或 Vue 项目里用这个库集成方式要讲究。不要在每个组件里各起一个 rAF 循环那样元素一多rAF 回调会爆炸。正确做法是全局维护一个 rAF 循环所有 Tween 注册到这个循环里循环每帧遍历所有活跃 Tween统一推进。const activeTweens new Set(); let rafId null; function globalTick(now) { activeTweens.forEach(tween tween.tick(now)); if (activeTweens.size 0) { rafId requestAnimationFrame(globalTick); } else { rafId null; } } function schedule(tween) { activeTweens.add(tween); if (rafId null) { rafId requestAnimationFrame(globalTick); } }这个模式叫单循环多任务是所有成熟动画库的标配。它把 rAF 的调用次数从 O(n) 降到 O(1)性能提升非常明显。5.4 销毁与内存泄漏防范组件化最容易被忽视的是销毁。一个 Tween 播完了如果不从 activeTweens 里移除它会一直占着内存而且每帧还会被遍历一次。所以onComplete里必须做清理onComplete: () { activeTweens.delete(this); // 其他清理逻辑 }同样如果组件在动画播放中被卸载比如 React 组件 unmount也要主动 cancel 掉 Tween否则回调里访问已卸载组件的状态会报错。6. 实测中的性能陷阱与优化手段6.1 每帧创建对象导致的 GC 压力我最早写的版本每帧都会创建一个新的对象来传参this.onUpdate({ value, progress, elapsed });跑起来发现动画一多GC垃圾回收频繁触发帧率掉得厉害。后来改成传基本类型参数或者复用一个对象问题就解决了。// 复用一个对象 const state { value: 0, progress: 0, elapsed: 0 }; // 每帧只改字段不新建 state.value value; state.progress progress; this.onUpdate(state);这个优化在动画数量少的时候看不出来一旦上百个元素同时动差距就出来了。6.2 属性写入的批处理另一个坑是在 rAF 回调里直接读写 DOM 样式会触发强制同步布局layout thrashing。比如// 坏例子 tweens.forEach(t { const current el.offsetWidth; // 读触发 layout el.style.width current 1 px; // 写触发 layout });正确做法是读写分离先统一读再统一写。或者干脆用transform和opacity这类不触发 layout 的属性做动画。6.3 用 will-change 和 transform 提升合成层如果动画涉及位移、缩放、旋转尽量用transform而不是left/top/width。transform可以走 GPU 合成不触发重排重绘。配合will-change: transform提前告诉浏览器这个元素要动浏览器会提前把它提升为合成层。但will-change不能滥用用多了会占用大量显存。我的经验是动画开始前加上动画结束后移除。el.style.willChange transform; // 动画结束后 el.style.willChange auto;6.4 帧率与精度的平衡最后说一个取舍。理论上 rAF 是 60fps但实际设备可能更高120Hz 屏幕或更低低端机。如果你的动画逻辑里假设了固定帧率就会出现高刷屏上动画变快的问题。解决办法是所有时间计算都基于真实时间戳而不是帧数。这也是为什么前面强调要用performance.now()而不是累加固定值。只要时间基准是对的帧率高低只影响流畅度不影响动画的总时长和进度。7. 一些实战中的经验与建议写到这里核心的东西基本讲完了。最后分享几个我在实际项目里总结的小经验。第一缓动曲线不要凭感觉调要对着参考调。网上有很多缓动曲线的可视化工具输入四个参数就能看到曲线形状和对应的动画效果。我习惯先把曲线调好再写进代码比在代码里反复改参数试快得多。第二动画时长别设太长。UI 动效的黄金时长是 200ms 到 400ms超过 500ms 用户就会觉得卡。除非是刻意的仪式感动画比如加载完成的大动画否则别超过 600ms。第三给动画留一个降级开关。有些用户开了系统的减少动态效果prefers-reduced-motion这时候应该把动画时长设为 0 或者直接跳过。这是无障碍设计的基本要求也是很多动画库会忽略的点。const prefersReduced window.matchMedia((prefers-reduced-motion: reduce)).matches; const duration prefersReduced ? 0 : 400;第四测试要在低端设备上做。开发机上跑得飞起的动画到了千元机上可能就掉帧。我一般会用浏览器的性能面板录一段看有没有长任务和掉帧。如果掉帧优先检查是不是每帧都在创建对象或者读写 DOM。第五别重复造轮子但要知道轮子怎么造。市面上成熟的动画库已经很多了生产环境直接用它们没问题。但自己实现一遍的价值在于你以后再遇到动画相关的 bug能快速定位是曲线的问题、时间轴的问题还是渲染的问题。这种知道底层在干什么的底气是看文档看不来的。