资讯动态

手写三次贝塞尔动画库:从数学原理到组件化封装

发布时间:2026/10/17 2:57:26 来源:尧图企业网站定制
1. 为什么我要手写一个三次贝塞尔动画库前端做动画绕不开缓动曲线。CSS 里写个transition: all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1)就能让元素动得很舒服但一旦遇到需要动态计算、需要中途打断、需要多个属性按不同节奏联动或者需要在 Canvas、SVG、WebGL 里驱动非 DOM 对象时CSS 那套就不够用了。市面上的动画库不少但要么体积大、要么依赖重、要么缓动函数写死了不好扩展。我最近在一个组件化项目里就碰到了这个痛点一个可拖拽的面板组件展开收起要带弹性拖拽释放要带惯性同时还要支持外部传入自定义缓动曲线。试了几个现成方案要么 API 不顺手要么打包后体积超标最后干脆自己撸了一个基于三次贝塞尔曲线的动画库核心代码不到 200 行压缩后 3KB 出头还顺手做成了可复用的前端组件。这篇文章就把整个实现过程拆开讲清楚。从三次贝塞尔曲线的数学原理到牛顿迭代求解再到 requestAnimationFrame 驱动、时间轴管理、组件化封装最后到实际项目里的踩坑记录我都会一一展开。适合有 JavaScript 基础、想深入理解动画底层原理的前端同学也适合正在做组件库、需要轻量动画方案的开发者。看完你至少能搞明白三件事缓动曲线到底是怎么算出来的、一个动画库的核心骨架长什么样、以及怎么把它包装成一个好用的组件。2. 三次贝塞尔曲线的数学底子与缓动映射2.1 从 CSS 的 cubic-bezier 说起CSS 里的cubic-bezier(x1, y1, x2, y2)接收四个参数本质上是定义了一条从 (0,0) 到 (1,1) 的三次贝塞尔曲线两个控制点分别是 (x1, y1) 和 (x2, y2)。这条曲线描述的是时间进度到动画进度的映射关系横轴是时间归一化后的比例 t0 到 1纵轴是动画完成的比例 p0 到 1。当曲线是直线时就是线性动画当曲线先陡后缓就是 ease-out先缓后陡就是 ease-in。三次贝塞尔曲线的参数方程长这样B(t) (1-t)^3 * P0 3(1-t)^2 * t * P1 3(1-t) * t^2 * P2 t^3 * P3其中 P0 (0,0)P3 (1,1)P1 (x1, y1)P2 (x2, y2)。展开到 x 和 y 两个维度x(t) 3(1-t)^2 * t * x1 3(1-t) * t^2 * x2 t^3 y(t) 3(1-t)^2 * t * y1 3(1-t) * t^2 * y2 t^3注意这里 x(t) 和 y(t) 都是关于参数 t 的函数但我们需要的是给定时间进度 x求动画进度 y。也就是说已知 x要先反解出参数 t再代入 y(t)。这个反解没有解析解只能用数值方法逼近。这就是整个动画库最核心的计算难点。2.2 为什么不用现成的 easing 函数有人会问直接写几个easeInOutQuad、easeOutBack之类的函数不就行了为什么要搞贝塞尔原因在于灵活性和统一性。预设的缓动函数是离散的、有限的而贝塞尔曲线是连续的、参数化的。用户传任意四个参数就能得到一条新曲线不需要你预先实现。而且贝塞尔曲线天然支持过冲效果——当 y1 或 y2 大于 1 或小于 0 时动画会超出目标值再回弹这就是弹性效果的来源。用预设函数实现这个要单独写一套逻辑用贝塞尔就是改个参数的事。另外从组件化的角度看统一用贝塞尔曲线作为缓动接口对外只需要暴露四个数字API 极其简洁。内部实现一套求解器所有缓动都走同一条路径代码复用率高维护成本低。2.3 牛顿迭代求解参数 t给定 x 求 t标准做法是牛顿迭代法Newton-Raphson。设 f(t) x(t) - x我们要找 f(t) 0 的根。牛顿迭代的公式是t_{n1} t_n - f(t_n) / f(t_n)其中 f(t) 是 x(t) 对 t 的导数x(t) 3(1-t)^2 * x1 6(1-t) * t * (x2 - x1) 3t^2 * (1 - x2)迭代的初始值一般取 t x因为 x 和 t 在 [0,1] 区间内大致接近迭代 4 到 8 次就能收敛到足够精度。实测下来对于绝大多数缓动曲线6 次迭代的误差已经在 1e-6 以下肉眼完全看不出区别。不过牛顿迭代有个坑当导数接近 0 时除法会爆炸。所以工程上一般会加一个保护当 |f(t)| 小于某个阈值比如 1e-6时改用二分法兜底。二分法虽然慢但绝对稳定作为 fallback 很合适。function solveTForX(x, x1, x2) { let t x; for (let i 0; i 8; i) { const currentX bezierX(t, x1, x2); const dx currentX - x; if (Math.abs(dx) 1e-6) return t; const derivative bezierXDerivative(t, x1, x2); if (Math.abs(derivative) 1e-6) break; t - dx / derivative; } // 二分法兜底 let lo 0, hi 1; t x; while (lo hi) { const currentX bezierX(t, x1, x2); if (Math.abs(currentX - x) 1e-6) return t; if (currentX x) lo t; else hi t; t (lo hi) / 2; if (hi - lo 1e-7) break; } return t; }这段代码是整个库的心脏后面所有动画的流畅度都依赖它的精度和速度。我做过压测单次求解在普通笔记本上大约 0.5 微秒一帧内算几十个属性完全无压力。3. 动画引擎的核心骨架设计3.1 时间轴与进度归一化一个动画库最基础的能力是给定起始时间、持续时长算出当前进度。听起来简单但细节不少。首先时间源要用performance.now()而不是Date.now()前者精度更高且不受系统时间调整影响。其次要处理暂停、恢复、反向播放这些状态进度计算不能只靠(now - start) / duration得维护一个已播放时长的累加器。我的做法是每个动画实例维护这几个字段startTime本次播放段的起始时间戳、elapsed之前播放段累计的时长、duration、delay、direction、paused。每次 tick 时如果没暂停就用now - startTime elapsed得到总已播放时长再除以 duration 得到线性进度最后经过缓动函数映射成动画进度。tick(now) { if (this.paused) return; const raw now - this.startTime this.elapsed; const linear Math.min(raw / this.duration, 1); const eased this.easing(linear); const progress this.direction reverse ? 1 - eased : eased; this.onUpdate(progress); if (linear 1) this.finish(); }这里有个容易忽略的点Math.min(raw / duration, 1)的截断很重要。因为 rAF 的回调时间戳可能略微超过预期结束时间不截断的话进度会超过 1导致动画过冲。虽然缓动函数内部也会处理但在外层截断更稳妥。3.2 requestAnimationFrame 驱动与帧同步所有动画共享一个全局的 rAF 循环而不是每个动画各开一个。这是性能优化的关键。浏览器对 rAF 的调用频率是跟着屏幕刷新率走的60Hz 屏幕大约 16.67ms 一帧120Hz 就是 8.33ms。如果每个动画实例都自己调 rAF几十个动画同时跑就会造成大量重复调度浪费性能。我的方案是维护一个activeAnimations集合全局只跑一个循环const activeAnimations new Set(); let rafId null; function loop(now) { activeAnimations.forEach(anim anim.tick(now)); if (activeAnimations.size 0) { rafId requestAnimationFrame(loop); } else { rafId null; } } function schedule(anim) { activeAnimations.add(anim); if (rafId null) { rafId requestAnimationFrame(loop); } }动画结束时从集合里移除自己集合空了就停掉循环。这样即使页面上有上百个动画也只有一个 rAF 在跑CPU 占用可控。注意在 tick 过程中修改 activeAnimations 集合比如动画结束时删除自己会导致 forEach 行为异常。稳妥做法是先复制一份数组再遍历或者用标记删除、循环后清理的策略。我一开始就踩了这个坑动画结束时会偶发跳帧排查了半天才发现是集合在遍历中被修改了。3.3 属性插值与单位处理动画最终要落到具体的属性值上。数值型属性直接线性插值from (to - from) * progress。但实际项目里属性五花八门有带单位的100px、50%、1.5rem有颜色的#ff0000、rgba(0,0,0,0.5)有变换矩阵的。一个通用动画库不可能全部内置支持我的策略是提供插值器注册机制。默认内置数值和颜色两种插值器。数值插值器用正则提取数字和单位分别插值后再拼回去。颜色插值器把 hex 或 rgb 转成 RGB 三元组分别插值后转回 rgb 字符串。其他类型让用户自己注册Animation.registerInterpolator(shadow, (from, to, p) { // 自定义阴影插值逻辑 });这样库本身保持轻量扩展性又足够。组件化封装时这些插值器可以按需引入不会一股脑打包进去。4. 组件化封装把动画库变成可复用组件4.1 组件接口设计光有动画引擎还不够实际项目里我们用的是组件。我希望这个动画库能以一种声明式的方式被使用同时保留命令式的控制能力。参考了一些主流库的设计我定了这么一套接口const anim createAnimation({ from: { x: 0, opacity: 0 }, to: { x: 100, opacity: 1 }, duration: 600, easing: [0.25, 0.1, 0.25, 1], delay: 100, onUpdate: (values) { /* 应用到 DOM 或对象 */ }, onComplete: () { /* 结束回调 */ } }); anim.play(); anim.pause(); anim.reverse(); anim.seek(0.5); // 跳到 50% 进度 anim.cancel();from和to是属性对象key 对应属性名value 是目标值。easing接收四个数字的数组内部转成贝塞尔求解器。onUpdate每帧调用把当前插值结果传出去具体怎么用由调用方决定。这种设计把算什么和怎么用解耦了同一个动画可以驱动 DOM、Canvas 对象、甚至纯数据。4.2 与前端框架的集成方式在组件化项目里动画库通常要跟框架的生命周期绑定。以常见的组件写法为例我一般会在组件挂载时创建动画实例卸载时取消属性变化时更新目标值并重播。关键是要处理好动画进行中目标值又变了这种情况。我的处理策略是如果新目标和当前目标一致忽略如果不一致从当前实际值作为新的起点重新计算动画。这样即使用户快速连续触发动画也不会跳变而是平滑过渡到新目标。实现上就是在updateTo方法里把当前插值结果快照成新的from然后重置时间轴。updateTo(newTo) { const currentValues this.getCurrentValues(); this.from currentValues; this.to newTo; this.elapsed 0; this.startTime performance.now(); this.paused false; schedule(this); }这个从当前值续接的逻辑是组件化体验的关键。很多粗糙的动画实现直接重置到初始值再动视觉上会有明显跳变用户一眼就能看出廉价感。4.3 组合动画与时间轴编排单个动画往往不够用实际场景经常需要多个动画按顺序或并行执行。我实现了一个简易的时间轴Timeline可以把多个动画实例编排在一起const tl createTimeline(); tl.add(animA, 0); // 0ms 开始 tl.add(animB, 200); // 200ms 开始 tl.add(animC, 300); // 上一个结束后 300ms 开始 tl.play();时间轴的实现思路是维护一个动画列表每个动画记录相对起始时间和时长播放时统一 tick根据全局时间决定每个子动画是否该启动、该处于什么进度。这比用 setTimeout 串联靠谱得多因为 setTimeout 不精确而且暂停恢复时很难同步。实操心得时间轴的暂停要递归暂停所有子动画恢复时也要递归恢复并且要重新校准每个子动画的 startTime否则恢复后会集体跳帧。我最初偷懒只暂停了主时间轴结果子动画还在后台跑恢复时全乱套了。5. 性能优化与常见问题排查5.1 减少每帧计算量动画库的性能瓶颈通常在每帧的插值计算和 DOM 写入上。插值计算本身很轻真正贵的是 DOM 操作。所以核心优化原则是每帧只写一次 DOM且尽量只写 transform 和 opacity 这两个不触发重排的属性。如果动画涉及多个属性我会在 onUpdate 里先收集所有值最后统一应用。另外对于 transform尽量用translate3d而不是left/top前者走 GPU 合成后者触发重排。这个道理大家都懂但在组件里很容易写着写着就忘了尤其是多个动画叠加的时候。还有一个细节避免在 onUpdate 里创建新对象。每帧创建对象会增加 GC 压力动画跑久了会卡顿。我的做法是复用同一个 values 对象只改里面的值// 不好 onUpdate: (v) el.style.transform translateX(${v.x}px); // 好 const cache { x: 0 }; onUpdate: (v) { cache.x v.x; el.style.transform translateX(${cache.x}px); }5.2 缓动曲线的边界情况贝塞尔缓动有几个边界情况必须处理。第一x1 和 x2 必须在 [0,1] 范围内否则 x(t) 不是单调的牛顿迭代会失效。CSS 规范也是这么限制的。第二y1 和 y2 可以超出 [0,1]这是弹性效果的来源但要注意过冲幅度别太大否则视觉上会很突兀。第三当 x1 x2 且 y1 y2 时曲线退化成直线求解器要能正确处理。我在求解器入口加了一层参数校验x 超出范围直接 clamp 到 [0,1]并给出警告。y 不限制但超过 [-2, 3] 范围会提示可能视觉效果异常。这些校验在开发环境很有用能帮使用者快速定位问题。5.3 常见问题速查表问题现象可能原因排查方向解决方案动画卡顿、掉帧每帧触发重排检查是否动画了 left/top/width改用 transform 和 opacity动画结束瞬间跳变进度超过 1 未截断检查 tick 里的进度计算加 Math.min 截断暂停恢复后跳帧startTime 未校准检查暂停恢复逻辑恢复时重置 startTime缓动曲线不生效参数顺序传错确认是 [x1,y1,x2,y2]对照 CSS cubic-bezier 顺序多个动画互相干扰共享了 values 对象检查是否复用了同一对象每个动画独立缓存移动端动画抖动rAF 时间戳精度问题检查是否用了 Date.now改用 performance.now动画不执行元素被 display:none检查元素可见性确保动画期间元素可见这张表是我在实际项目中遇到问题后整理的基本覆盖了 90% 的常见故障。新手遇到问题先对照这张表排查能省不少时间。5.4 内存泄漏与实例管理动画实例如果没被正确回收会一直挂在 activeAnimations 集合里导致 rAF 循环永远不停内存持续增长。常见泄漏场景有两个一是组件卸载时忘了 cancel 动画二是动画的 onComplete 回调里引用了外部大对象导致无法回收。我的做法是在组件卸载的生命周期里强制调用anim.cancel()cancel 会把实例从集合里移除并清空所有回调引用。另外提供一个全局的cancelAll()方法在路由切换等场景下兜底清理。实测下来加上这层保护后长时间运行的单页应用内存曲线平稳多了。6. 实际项目中的落地经验6.1 一个可拖拽面板的完整案例回到开头提到的可拖拽面板。这个组件的需求是点击标题栏展开收起带弹性缓动拖拽时跟手释放时根据速度决定是回弹还是继续展开。用我的动画库实现核心逻辑大概是这样展开收起用一条cubic-bezier(0.34, 1.56, 0.64, 1)的曲线这个曲线的 y2 大于 1会产生轻微过冲视觉上很有弹性。拖拽跟手阶段不用动画直接根据鼠标位移设置高度。释放时计算速度如果速度超过阈值就朝速度方向继续动画到端点否则回弹到最近的端点。回弹动画的时长根据剩余距离动态计算距离越短时长越短这样手感更自然。function onRelease(velocity, currentHeight) { const target velocity 0.5 ? maxHeight : minHeight; const distance Math.abs(target - currentHeight); const duration Math.min(Math.max(distance * 2, 150), 500); createAnimation({ from: { h: currentHeight }, to: { h: target }, duration, easing: [0.25, 0.1, 0.25, 1], onUpdate: (v) panel.style.height v.h px }).play(); }这段代码里 duration 的动态计算是关键。固定时长会导致近距离回弹太慢、远距离回弹太快手感很怪。按距离比例算时长再 clamp 到合理区间体验就自然多了。6.2 与 CSS 动画的取舍有了这个库是不是所有动画都要用它不是。简单的 hover 效果、加载旋转、颜色渐变直接用 CSS transition 和 animation 更省事性能也更好因为浏览器可以对 CSS 动画做额外优化。我的原则是能用 CSS 搞定的静态动画就用 CSS需要动态计算、需要 JS 控制、需要驱动非 DOM 对象的才用这个库。具体来说以下几种情况我会毫不犹豫用 JS 动画库需要根据运行时数据动态生成缓动曲线的需要在动画中途改变目标值的需要精确控制暂停、恢复、跳转的需要驱动 Canvas 或 WebGL 对象的需要多个动画精确同步的。其他情况优先 CSS。6.3 打包体积与按需引入这个库最终打包出来核心引擎加贝塞尔求解器大约 3KBgzip 后加上时间轴和插值器大约 5KB。对于组件库来说这个体积完全可以接受。如果项目只需要基础功能可以只引入核心模块时间轴和额外插值器按需引入。我用的是 ES Module 的 tree-shaking 友好写法每个功能独立导出没有副作用。打包工具能自动摇掉没用到的部分。实测在一个只用了基础动画的项目里最终打包进去的只有 2.8KB。提示写库的时候尽量避免在模块顶层执行有副作用的代码比如自动注册全局变量、自动启动 rAF 循环等。这些都会破坏 tree-shaking导致没用的代码也被打进去。rAF 循环要懒启动第一次 schedule 时才开启。6.4 跨浏览器兼容性处理现代浏览器对 rAF 和 performance.now 的支持都很好基本不用操心。需要注意的主要是两点一是 Safari 早期版本对performance.now()的精度限制二是某些 WebView 里 rAF 在页面不可见时的行为差异。对于第一点如果发现动画在 Safari 上不够平滑可以检查一下时间戳精度。对于第二点页面切到后台时 rAF 会暂停这是符合预期的但恢复时时间戳会跳变导致动画瞬间跳到很后面的进度。我的处理是在页面可见性变化时重新校准所有活跃动画的 startTime避免恢复时跳帧。document.addEventListener(visibilitychange, () { if (!document.hidden) { const now performance.now(); activeAnimations.forEach(anim { anim.startTime now - anim.elapsed; }); } });这段代码看着简单但解决了一个很影响体验的问题。用户切走再切回来动画不会突然跳到结尾而是从暂停的地方继续。7. 一些踩坑之后的个人体会这个库从最初几十行的原型到现在能在多个项目里稳定使用前后迭代了七八个版本。最大的体会是动画库的难点不在数学而在边界情况和状态管理。贝塞尔求解的代码网上一搜一大把但真正让动画在各种极端场景下都不出问题靠的是一遍遍踩坑和补漏。比如暂停恢复的校准、页面可见性变化的处理、动画中途改目标的续接、集合遍历时的修改保护这些都是文档里不会写、只有实际跑起来才会暴露的问题。我建议如果你也要写类似的库先把状态机理清楚把每个状态转换的边界都考虑到再动手写代码。否则后期打补丁会越打越乱。另外一点是不要过度设计。我一开始想支持各种花哨的功能什么弹簧物理、关键帧序列、路径动画结果代码膨胀到几千行维护起来很痛苦。后来砍掉了一大半只保留最核心的贝塞尔缓动加时间轴反而更好用。工具的价值在于解决具体问题不在于功能多。最后分享一个调试小技巧在开发阶段给动画库加一个慢放开关把所有动画的时长乘以 10这样肉眼能清楚看到每一帧的变化排查跳变、卡顿、缓动异常特别有效。上线前关掉就行。这个技巧帮我定位了好几个隐蔽的 bug比盯着正常速度的动画看半天管用多了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑