很多前端同学第一次听到"贝塞尔曲线"这四个字,脑子里浮现的都是数学课本里那堆控制点、参数方程,觉得离日常写页面很远。但只要你用过 CSS 的cubic-bezier()缓动函数,或者调过动画曲线,其实你已经在用它了。区别在于,CSS 给的是"黑盒"——你只能填四个数字,剩下的交给浏览器。而当我们想做一个真正属于自己的动画库,想让动画能暂停、能反向、能中途改速度、能串联成时间线,就必须把这层黑盒拆开,自己用 JavaScript 把三次贝塞尔曲线算出来。
这篇内容就是围绕"用 JavaScript 实现一个三次贝塞尔动画库,并且做成前端组件化"这件事展开的。我会从最底层的数学原理讲起,一路讲到怎么把它封装成一个能复用的组件、怎么处理时间与进度的映射、怎么避开浮点误差和性能陷阱。适合已经会写 JS、做过一些动画,但想搞清楚"动画库内部到底在干什么"的开发者。看完之后,你不仅能自己撸一个能用的缓动引擎,还能理解市面上那些动画库的设计取舍。
1. 为什么值得自己动手写一个贝塞尔动画引擎
1.1 从 CSS 缓动的三个天花板说起
先说清楚动机。CSS 的transition和animation已经很强了,为什么还要自己写?我总结下来有三个绕不过去的天花板。
第一个是控制粒度。CSS 动画一旦启动,你很难在运行过程中精确地拿到"当前进度是多少"。getComputedStyle能读到当前transform的值,但那是字符串,解析成本高,而且拿不到归一化的进度值。你想做一个"进度条跟着动画走"的效果,用纯 CSS 就很别扭。
第二个是组合能力。CSS 的animation-delay可以做出简单的串联,但一旦涉及"动画 A 播到 60% 时触发动画 B,B 播到一半时 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_{n+1} = 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-catch:
try { 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,能快速定位是曲线的问题、时间轴的问题还是渲染的问题。这种"知道底层在干什么"的底气,是看文档看不来的。