ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写JS动画封装:用requestAnimationFrame与transform打造流畅Tweener

手写JS动画封装:用requestAnimationFrame与transform打造流畅Tweener 做移动动画封装这件事最早我是在一个拖拽购物车项目里被逼出来的。需求本身不复杂商品卡片要飞进购物车弹窗要带点阻尼感地弹出来列表项要按顺序入场。用CSS动画能搞定一部分但一旦牵扯到运行时坐标计算、连招动画、以及动画做到一半被拖拽打断这种交互状态CSS就管不住了。我当时的第一反应是用setInterval去改left和top结果动起来卡得离谱。后来我把原理捋清楚、把动画逻辑抽成一个独立的Tweener类之后所有需求全部收进同一个封装里。这篇文章就聊聊我踩过坑之后的完整实现从渲染原理、API设计、完整代码到进阶用法和避坑经验适合正在做H5、活动页、后台交互或者单纯想搞懂JS动画底层逻辑的朋友参考。1. 先从为什么会卡说起移动动画背后那点渲染真相很多人做动画的第一直觉就是每秒改很多次位置于是setInterval(16.7ms)改left/top结果一跑就掉帧。要搞明白为什么卡得先看清楚浏览器从JS到屏幕像素之间的完整流水线。1.1 一次动画背后浏览器到底在做什么浏览器把页面画出来大致要经历这么几步JavaScript执行-样式计算Style-布局Layout-绘制Paint-合成Composite。你写的每一行会触发视觉变化的代码最后都会落到这条流水线上但不同代码造成的开销差别非常大。举个例子el.style.left 100px浏览器拿到这个新值之后必须先把元素的新位置算出来其他元素的位置也可能跟着变这一层叫Layout也叫回流。回流完之后浏览器要重新绘制受影响的区域也就是Paint。这两步都是CPU/GPU的重活如果在一个复杂页面上每帧都做必然卡。而el.style.transform translateX(100px)走的是另一条路元素在布局层里的位置根本没变变的只是这个元素最终显示在哪个位置。浏览器可以把元素单独提到一个合成层上只做一次移动计算和最终的合成。这块GPU处理得非常快。所以我后来做移动动画一律用transform不再碰left/top。1.2 requestAnimationFrame比setInterval强在哪setInterval的问题是它只保证时间间隔不保证执行时机。浏览器在跑长任务时setInterval的回调会堆积在队列里上一帧的回调还没处理完下一帧的回调又来了结果就是动画节奏忽快忽慢甚至同时执行好几个回调取同一个值页面自然抖。requestAnimationFrame完全不同。浏览器会在每次刷新屏幕前专门留一个时间片来调用你的回调保证回调跟屏幕刷新频率完全同步。屏幕60Hz时就是每16.7ms一次120Hz时就是每8.3ms一次不需要手动去探测刷新率。另外页面切到后台时setInterval还在后台傻跑RAF会直接暂停省电不说回来之后你还没法用累加帧数的方式算进度这点后面踩坑部分细说。1.3 transform与left/top的实测对比同样是让一个包含200个子元素的模块在屏幕上横向移动我做了个简单对比测试实现方式平均帧率掉帧情况CPU占用setInterval left38-47fps频繁高requestAnimationFrame left48-52fps中等中requestAnimationFrame transform58-60fps基本无低这个并不是多严谨的benchmark但趋势非常明显RAF解决了调用时机的问题transform解决了渲染开销的问题两个一起上移动动画的性能才谈得上合格。2. 封装前想清楚的三件事边界、API和目标场景很多人一上来就写类、写方法写了一堆之后发现换个场景就用不了。我的经验是先想清楚这个动画类到底要为哪些场景服务再动手。这样就避免把接口设计得过宽或过窄。2.1 先问自己这个动画类要为谁服务我当时梳理了一下业务里所有需要移动动画的场景弹窗从页面中心平滑弹出带一点回弹拖拽松手后元素吸附到某个位置商品卡片以抛物线轨迹飞入购物车列表项按顺序依次入场按钮点击后做了个震动一下的反馈这些场景千差万别但抽象一下本质都是同一件事在一段时间内把一个元素从状态A平滑过渡到状态B。A可能是当前坐标B是目标坐标也可能涉及缩放、旋转甚至一个纯粹的数值变化。所以我决定不做一个从哪移到哪的专用方法而是做一个基于时间轴的数值插值器由它统一驱动transform更新同时允许调用方拿到每一帧的插值结果自己组合逻辑。2.2 配置项怎么设计才不显得臃肿我的Tweener类构造函数只接收一个对象所有可配项都通过对象传入el要操作的元素props目标值对象例如{x: 300, y: 200, scale: 1.2}支持x/y/scale/rotate也支持任意自定义数字字段duration动画时长单位毫秒easing缓动函数名也可以直接传函数from可选手动指定起点onStart/onUpdate/onComplete开始、每帧更新、结束三个回调我把可配项控制在7个以内没有给单个属性分别配duration和easing。原因很简单我用到的场景里没有x轴走800ms、y轴走1200ms这种需求真遇到的话可以开两个Tweener实例并行跑没必要把类的配置复杂度堆上去。接口设计原则是YAGNI你现在不用的别急着加。2.3 选类而不是纯函数的原因有的库喜欢暴露一个tween(el, target, duration, easing)这样的纯函数我之前也这么干过但很快发现一个问题动画一旦需要中途取消或者串联这些状态放哪放模块级变量里两个动画同时跑就打架返回一个对象让调用方自己管理那调用方其实就是在维护一个半成品的动画实例。所以直接用class。每个动画实例天然拥有自己的startTime、rafId、running状态。stop()、then()这些方法也都有明确的调用主体。封装不是炫技只是把该独立的状态放进一个独立的对象里而已。3. 手写一个可复用的Tweener代码与用法下面是我在项目里实际使用、改动过几轮的Tweener实现。我先讲核心设计再给完整代码最后放实际用法。3.1 骨架状态、时间戳、主循环动画主循环的核心逻辑非常简单拿当前时间减去开始时间得到已经过去的时间除以总时长得到一个0到1之间的进度。这个进度喂给缓动函数映射成一个看起来更自然的进度最后两两插值算出每个属性当前帧的数值。这里有个细节起点时间一定要用performance.now()不要用Date.now()。一方面performance.now()精度更高是毫秒级浮点数另一方面RAF回调拿到的时间戳本身就是高精度时间直接用这个差值算进度逻辑最干净。loop(now) { if (!this.running) return; const elapsed now - this.startTime; let ratio Math.min(elapsed / this.duration, 1); const eased this.easingFn(ratio); const values {}; for (const key in this.props) { values[key] this.from[key] (this.props[key] - this.from[key]) * eased; } this.applyTransform(values); if (this.onUpdate) this.onUpdate(values, ratio, eased); if (ratio 1) { this.rafId requestAnimationFrame(this.loop.bind(this)); } else { this.running false; this.rafId 0; if (this.onComplete) this.onComplete(this); } }注意ratio用Math.min(..., 1)钳制到1避免因为performance.now()精度或RAF提前回调导致终点值没落到目标上。这个错误我在早期版本里踩过动画时长设成100ms结果结束帧的x值是299.8视觉上倒看不出来但如果是循环动画累积误差就会越来越大。3.2 解析起点值从getComputedStyle到数值动画需要知道起点。最省事的方式是从getComputedStyle读当前transform然后从matrix字符串里抠出位移和缩放值。但这里有个常见的坑如果元素之前没有任何transformgetComputedStyle返回的是字符串none你要单独处理如果元素既有rotate又有translatematrix的6个数值分别对应不同的变换参数解析起来很容易写错。我的处理方式是这样readCurrentValues() { const transform getComputedStyle(this.el).transform; if (!transform || transform none) { return { x: 0, y: 0, scale: 1 }; } const match transform.match(/matrix\(([^)])\)/); if (match) { const parts match[1].split(,).map(Number); return { x: parts[4] || 0, y: parts[5] || 0, scale: (parts[0] parts[3]) / 2 || 1 }; } return { x: 0, y: 0, scale: 1 }; }这套代码能应付多数简单场景但对元素已经设置了rotate或者matrix3d的情况还是有局限。我在项目里的做法是如果这个元素会被Tweener连续操作就始终让Tweener自己维护一份最新的坐标值下次动画的from直接用旧值不经过DOM反解析。这样既省去了字符串解析的性能损耗也避免了精度问题。如果你确实要读一些复杂的transform可以直接用new DOMMatrix(getComputedStyle(el).transform)浏览器会帮你把matrix3d都解出来不推荐自己手写正则硬抠。另外我在start()里会做一件事遍历props的每个key如果from里没有对应值就默认用0补齐。这是为了兼容props里传了一个自定义数值字段的场景比如后面4.3小节的抛物线例子那个p字段就不是transform属性也不会被applyTransform处理但插值逻辑依然有效。3.3 缓动函数让动画有手感缓动函数的本质很简单接受一个0到1的线性进度返回一个变换后的进度。比如easeOutQuadt t * (2 - t)它让动画开头快、结尾慢看起来像物体在衰减运动easeOutBack则会让进度超过1再回到1产生一种冲过头再弹回来的效果。我的EASING表里常驻这几个linear匀速easeInQuad加速easeOutQuad减速easeInOutCubic先加速后减速easeOutBack回弹easeOutElastic弹性用法上有几个经验入场动画适合用easeOutQuad或easeOutCubic因为人眼对快速冲入更敏感减速结束比较自然强调动画偶尔用easeOutBack比如弹窗出现抖一下easeOutElastic则要慎用实际项目中太多弹性动画会显得廉价而且一旦元素多容易给用户整个页面在蹦迪的感觉。3.4 完整代码我把上面这些设计整合成一个类直接可以复制到项目里用const EASING { linear: t t, easeInQuad: t t * t, easeOutQuad: t t * (2 - t), easeInOutCubic: t t 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t 2, 3) / 2, easeOutBack: t { const c1 1.70158; const c3 c1 1; return 1 c3 * Math.pow(t - 1, 3) c1 * Math.pow(t - 1, 2); }, easeOutElastic: t { const c4 (2 * Math.PI) / 3; return t 0 ? 0 : t 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) 1; } }; class Tweener { constructor({ el, props, duration 400, easing easeOutQuad, from null, onStart null, onUpdate null, onComplete null }) { this.el el; this.props props; this.duration duration; this.easingFn typeof easing function ? easing : EASING[easing] || EASING.linear; this.explicitFrom from; this.onStart onStart; this.onUpdate onUpdate; this.onComplete onComplete; this.rafId 0; this.running false; this.startTime 0; this.from {}; } start() { this.stop(); this.running true; this.startTime performance.now(); this.from this.explicitFrom || this.readCurrentValues(); Object.keys(this.props).forEach(key { if (this.from[key] undefined) this.from[key] 0; }); if (this.onStart) this.onStart(this); this.loop(this.startTime); return this; } readCurrentValues() { const transform getComputedStyle(this.el).transform; if (!transform || transform none) return { x: 0, y: 0, scale: 1 }; const match transform.match(/matrix\(([^)])\)/); if (match) { const parts match[1].split(,).map(Number); return { x: parts[4] || 0, y: parts[5] || 0, scale: (parts[0] parts[3]) / 2 || 1 }; } return { x: 0, y: 0, scale: 1 }; } loop(now) { if (!this.running) return; const elapsed now - this.startTime; const ratio Math.min(elapsed / this.duration, 1); const eased this.easingFn(ratio); const values {}; for (const key in this.props) { values[key] this.from[key] (this.props[key] - this.from[key]) * eased; } this.applyTransform(values); if (this.onUpdate) this.onUpdate(values, ratio, eased); if (ratio 1) { this.rafId requestAnimationFrame(this.loop.bind(this)); } else { this.running false; this.rafId 0; if (this.onComplete) this.onComplete(this); } } applyTransform(values) { const transforms []; if (values.x ! undefined) transforms.push(translateX(${values.x}px)); if (values.y ! undefined) transforms.push(translateY(${values.y}px)); if (values.scale ! undefined) transforms.push(scale(${values.scale})); if (values.rotate ! undefined) transforms.push(rotate(${values.rotate}deg)); if (transforms.length) this.el.style.transform transforms.join( ); } stop() { this.running false; if (this.rafId) cancelAnimationFrame(this.rafId); this.rafId 0; } then(callback) { const prevComplete this.onComplete; this.onComplete anim { if (prevComplete) prevComplete(anim); callback(anim); }; return this; } }3.5 实战让一个div平滑移动基础用法非常简单const box document.querySelector(.box); const animator new Tweener({ el: box, props: { x: 320, y: 200, scale: 1.2 }, duration: 800, easing: easeOutBack, onComplete() { console.log(动画结束); } }); animator.start();这样一个纯JS写的移动动画在桌面和移动端跑起来都很流畅。如果你想做一个拖拽松手后吸到边缘的效果只需要在松手事件里改一下props和duration就行function snapToEdge() { const edgeX window.innerWidth - box.offsetWidth; new Tweener({ el: box, props: { x: edgeX, y: 0 }, duration: 400, easing: easeOutCubic, }).start(); }4. 进阶链式动画、并行动画与手工缓动曲线基础版能解决单段动画但真实交互经常是一连串动作。这里讲几个高频进阶场景以及我实现的扩展方式。4.1 链式把动画串成队列链式动画就是上一段结束之后再执行下一段。我给Tweener加了一个then()方法它会在当前动画的onComplete回调链上追加新的回调。这样写起来很顺new Tweener({ el: box, props: { x: 300, scale: 1.3 }, duration: 500, easing: easeOutQuad }) .then(() new Tweener({ el: box, props: { x: 0, scale: 1 }, duration: 300, easing: easeInQuad }).start()) .start();这个then()实现不复杂本质就是把上一个动画的complete回调串起来。有一点要注意如果你在链式动画已经开始之后再去调用then()回调会追加到当前动画的末尾逻辑上也是自洽的。我在做弹窗提示的时候就这么用先弹进来再抖一下然后放大缩小做强调最后暂不关闭等用户点击。4.2 并行同一时间轴上的多属性并行动画有两层含义一层是一个元素的多属性同时变化另一层是多个元素同时动画。前者Tweener天然支持只要在props里同时传x、y、scale它们就共享同一个时间轴和缓动曲线不会出现x到了y还没到的情况。多个元素同时动画国内前端最容易想到的就是New一个实例跑两个loop。我一开始也这么干但后来发现一个问题同时有十几个元素各自开RAF性能虽然不至于崩但调试时事件顺序很乱。后来我维护了一个全局的Tweener管理器所有实例都在一个RAF里统一更新。不过在大多数业务场景里几十个实例各自跑RAF并不会造成可感知的性能问题所以如果你的页面不是那种一屏几十个动画同时跑的重度场景直接每个动画一个RAF完全没问题不要过度设计。4.3 自定义贝塞尔路径与抛物线效果购物车抛物线的经典需求我是在Tweener基础上做的。思路是用一个比例p从0线性走到1x方向也线性插值y方向手动套一个二次函数。因为Tweener支持任意数字字段的插值我会把p当作一个虚拟属性传进去const startX 0, startY 0; const targetX 300, targetY 400; new Tweener({ el: ball, props: { p: 1, x: targetX }, duration: 600, easing: linear, onUpdate(values) { const y targetY * values.p * values.p; ball.style.transform translate(${values.x}px, ${y}px); } }).start();这里的p不会写入transform因为applyTransform里没有对应分支但onUpdate每帧都能拿到它的插值。手动控制y值的可玩性非常高你可以任意改成三次方、贝塞尔、甚至带一点噪声的曲线动画的表现力比预设的缓动函数更丰富。4.4 手感这件事缓动曲线怎么选如果第一次做动画我给你一版直接照抄的选型场景推荐缓动元素从边缘进入easeOutCubic弹窗居中弹出easeOutBack列表项依次入场easeOutQuad拖拽松手吸附easeOutCubic页面整体滚动到锚点easeInOutCubic点击震动反馈自定义sin衰减这里有个容易忽视的细节多个列表项依次入场时如果每个元素都是完整跑完再开始下一个感觉会非常拖沓。正确做法是每个元素用同一个缓动但各自延迟start。我习惯把入场持续时间除以总数作为每项的间隔比如10个元素总动画时长800ms那每个元素延迟80ms开始。这样整体节奏更紧凑。5. 踩坑记录从帧率、内存到生命周期封装做得再漂亮上线后出问题的往往是一些你以为控制住但没有的细节。我按真实踩坑顺序列一遍有些问题是肉眼可见的卡有些问题藏得极深。5.1 帧率实测RAF vs setInterval对比我在开发模式里给Tweener加了一个FPS统计埋点用Chrome Performance面板对比过同样一段移动动画。setInterval方案掉帧集中在动画后期因为长时间运行后事件队列里堆积的回调会忽然连续执行RAF方案全程稳定在刷新率附近。最直观的感受是setInterval方案在快速滚动页面时动画元素会明显抖动RAF方案几乎纹丝不动。如果你也遇到动画在简单页面正常、在复杂页面掉帧的问题先不要怀疑JS逻辑先打开Performance面板录一段看看是不是每帧都在做Layout。是的话检查一下是不是有人还在用left/top或者动画循环里读了offsetWidth。5.2 忘记cancelAnimationFrame的后果Tweener早期版本有一个bug连续对同一个元素调用两次start()第一次的RAF没有取消结果两个RAF循环同时在跑都往同一个元素的transform上写值元素就像得了帕金森一样抽搐。后来我在start()开头统一调用了this.stop()把上一次的RAF先清掉这个问题才根治。这也提醒我封装动画类stop()语义一定要清晰所有重新开始的入口都必须先停掉旧的。不然用户快速点击触发同一个动画你就会看到各种稀碎的效果。5.3 后台标签页归来动画突然结束有一种进度计算方式是用帧数累加每执行一次RAF就把frameCount加1然后用frameCount乘以16.7估算时间。这种写法在页面正常前台运行时没问题但一旦用户切到别的标签页RAF会被暂停回来的那一刻RAF恢复frameCount却不会为休眠期补帧动画就会瞬间跳到结束状态。你甚至可能看到元素忽然闪现到终点。我的实现从一开始就用时间戳差值算进度所以天然规避了这个问题。performance.now()在后台是会继续走的回来后elapsed一下子变得很大ratio被钳制到1动画直接收尾。如果你希望后台回来之后接着跑而不是结束那就需要额外判断elapsed超过了duration就不结束只把ratio限制在0.999这属于特殊需求自己按需改。5.4 动画循环里读取布局每帧的隐性重排这个坑是性能排查时最隐蔽的。动画主循环本身只用transform是很快的但如果你在onUpdate回调里写了el.offsetWidth或者el.getBoundingClientRect()浏览器为了给你一个准确数值会强制中断当前的渲染管线做一次完整的Layout。哪怕你原本只改transform也会因为这次读取重新走一遍布局计算性能直接倒退。我的建议是动画循环里坚决不读布局属性所有需要的位置、尺寸都提前缓存好。如果实在要读取动画进行到一半时元素的实际位置可以用getBoundingClientRect()但也尽量只在onComplete里读不要每帧读。这条经验同样适用于所有requestAnimationFrame驱动的场景不只是移动动画。5.5 元素复用与组件卸载时的清理策略前端框架下用Tweener最容易出问题的不是动画本身而是生命周期。React或者Vue里组件卸载了但Tweener的RAF还在跑于是回调里访问了一个已经被移除的DOM节点轻则警告重则报错。我的做法是在组件卸载的时候统一调用stop()。// React useEffect 示例 useEffect(() { const animator new Tweener({ el: boxRef.current, props: { x: 200 }, duration: 600, }).start(); return () animator.stop(); }, []);如果你有多个动画实例建议用一个Set或者数组统一收集起来卸载时统一清理。还有一个细节如果元素在动画过程中被父节点remove掉RAF其实不会自动停止你仍然要显式调用cancelAnimationFrame不然回调会一直空转。6. 什么时候用自己的封装什么时候用CSS/WAAPI自己封装了一个动画类之后反而要提醒一句不要什么都用JS动画。能用CSS解决的问题尽量别用JS。6.1 CSS transition能解决90%的问题如果你的动画触发条件是明确的hover、focus或者某个类名的增减而且不需要在动画过程中做复杂交互CSS transition是首选。它天然支持GPU合成性能不差还不需要你手动管理生命周期。曾经有一个弹窗需求我非要用Tweener做写了一大堆回调最后发现一个带transition和transform的class切换就搞定了瞬间觉得自己做了白工。CSS动画适合做的事hover反馈、选项卡切换、Modal入场离场、页面内简单的位移过渡。不适合做的事需要根据运行时数据计算目标位置的动画、需要中途打断并且状态能保持的动画、需要在动画过程中做额外逻辑的动画。这些才是JS动画的领域。6.2 WAAPI原生动画API能替代什么Element.animate()是浏览器原生的Web Animations API它支持关键帧、缓动、播放控制、暂停、反转、持续时间自定义已经覆盖了很多自研动画类的功能。我后来检查代码时发现一部分原本用Tweener实现的动画其实改用WAAPI会更简洁el.animate([ { transform: translateX(0px) }, { transform: translateX(300px) } ], { duration: 600, easing: ease-out });WAAPI的局限在于它把动画状态托管给浏览器你没法很方便地在每一帧拿到插值结果去做别的逻辑。它没有内置的拖到一半松手继续跑这种交互状态机。而且如果需要兼容老版本浏览器WAAPI要额外加polyfill这又增加了维护成本。6.3 我的最终选型清单场景推荐方案理由hover反馈、class切换动画CSS transition简单可靠列表入场、Modal显隐CSS animation / transition声明式无代码负担基于运行时坐标的移动自研Tweener灵活控制每帧拖拽吸附、缓动物理自研Tweener需要中途接管大量复杂编排动画GSAP等动画库性能、API、兼容性都成熟我个人在实际项目里的体会是自己封装不是为了让团队少引一个库而是当你亲手把一个动画拆成基于时间的插值器之后再遇到任何动画需求你都能很快判断出它该属于哪一层。这个过程带来的排查能力提升比那几行代码值钱得多。如果你团队里已经大规模引入GSAP就别重复造轮子了直接用现成的但如果你也想像我一样彻底吃透移动动画的细节花一个下午从头写一遍Tweener绝对值。
返回列表