在日常开发动效时,很多前端习惯性地随手甩一个 CSS 过渡:transition: transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1)。
这种基于固定时长的贝塞尔曲线在静态展示时看着很规整,但在真实的高频人机交互中,它存在一个致命硬伤:无法自然处理“中途打断”与“动量继承”。
设想一个抽屉弹窗或下拉卡片:当用户手指以极快的速度向上滑脱时,卡片本应携带较高的初速度飞出视口;又或者在卡片尚未完全弹开的半路,用户突然反向滑动。如果用 CSS 贝塞尔曲线,原动画会被生硬掐断,然后从零速度开始重新播放一段长达 300ms 的反向动画,视觉上会有极其突兀的“卡顿感”和“顿挫感”。
真实物理世界中的物体从来不是按照“固定时间时长”运动的,而是遵循牛顿力学规律:有质量、受弹性拉力、受介质阻尼,并且在任何时刻都严格保持速度连续性。
今天我们手写一个轻量级(不到百行代码)、高性能且能够与现代 CSS 自定义属性无缝咬合的微型弹簧动画控制器(Micro Spring Controller)。
弹簧物理学的二阶微分方程解析
弹簧振子的运动由胡克定律(Hooke's Law)与牛顿第二定律共同定义:
$$F_{\text{net}} = F_{\text{spring}} + F_{\text{damper}}$$
$$m \cdot a = -k \cdot (x - x_{\text{target}}) - c \cdot v$$
其中:
- $x$ 为当前位移,$x_{\text{target}}$ 为目标平衡位置;
- $v = \frac{dx}{dt}$ 为当前速度;
- $a = \frac{d^2x}{dt^2}$ 为加速度;
- $m$ 为质量(Mass),决定物体的惯性;
- $k$ 为刚度(Stiffness / Tension),弹簧拉力系数,越大则回弹越迅猛;
- $c$ 为阻尼系数(Damping / Friction),介质摩擦阻力,越大则能量衰减越快。
变形后得到加速度公式:
$$a = \frac{-k (x - x_{\text{target}}) - c \cdot v}{m}$$
求解这个二阶常微分方程,通常有解析解(Analytic Solution)和数值积分(Numerical Integration)两种途径。在需要频繁动态改变目标位置、中途注入手势离手速度(Release Velocity)的交互场景下,**数值积分法(特别是半隐式欧拉法 Semi-implicit Euler)**兼具极高的鲁棒性与计算极简性。
半隐式欧拉积分算法
普通的显式欧拉法在时间步长 $\Delta t$ 略大时极易发生能量发散(弹簧越晃幅度越大,最终数值溢出);而半隐式欧拉法(又称速度 Verlet 简易版)优先用更新后的加速度更新速度,再用最新速度推进位置:
$$v_{t + \Delta t} = v_t + a_t \cdot \Delta t$$
$$x_{t + \Delta t} = x_t + v_{t + \Delta t} \cdot \Delta t$$
这种辛几何积分(Symplectic Integrator)能天然保证系统的能量守恒与收敛稳定性。
export interface SpringPhysicsParams { mass: number; // 质量,默认 1.0 stiffness: number; // 刚度,默认 170 damping: number; // 阻尼,默认 26 precision: number; // 静止休眠阈值,默认 0.001 } export class SpringSimulator { private current: number; private target: number; private velocity: number; private params: SpringPhysicsParams; constructor( initialValue = 0, params?: Partial<SpringPhysicsParams> ) { this.current = initialValue; this.target = initialValue; this.velocity = 0; this.params = { mass: 1.0, stiffness: 170, damping: 26, precision: 0.001, ...params, }; } public setTarget(newTarget: number): void { this.target = newTarget; } /** * 中途注入手势初速度(如滑动抛掷瞬间的像素/秒) */ public setVelocity(initialVelocity: number): void { this.velocity = initialVelocity; } public getValue(): number { return this.current; } public isAtRest(): boolean { const isVelocityZero = Math.abs(this.velocity) < this.params.precision; const isDisplacementZero = Math.abs(this.current - this.target) < this.params.precision; return isVelocityZero && isDisplacementZero; } /** * 半隐式欧拉步进计算 * @param dt 距离上一帧的时间差(秒) */ public step(dt: number): number { if (this.isAtRest()) { this.current = this.target; this.velocity = 0; return this.current; } // 限制单帧最大积分步长,防止浏览器后台休眠后突然恢复导致的数值爆炸 const clampedDt = Math.min(dt, 0.032); const displacement = this.current - this.target; const springForce = -this.params.stiffness * displacement; const dampingForce = -this.params.damping * this.velocity; const acceleration = (springForce + dampingForce) / this.params.mass; // 半隐式更新:先更新速度,再用新速度更新位移 this.velocity += acceleration * clampedDt; this.current += this.velocity * clampedDt; if (this.isAtRest()) { this.current = this.target; this.velocity = 0; } return this.current; } }与 CSS 自定义属性无缝咬合的动画控制器
有了物理内核,如何驱动 DOM 界面?
传统的物理库(如 react-spring 或 framer-motion)往往深度侵入组件框架,造成大量的虚拟 DOM Diff 开销。
最轻量且高性能的方式是:控制器内部通过requestAnimationFrame驱动积分,算出的物理位移直接写入宿主 DOM 的 CSS 自定义属性。CSS 负责将其转化为 GPU 硬件加速的transform:
export class SpringMotionController { private simulator: SpringSimulator; private targetElement: HTMLElement; private cssVarName: string; private unit: string; private rafId: number | null = null; private lastTime = 0; private onRestCallback?: () => void; constructor( element: HTMLElement, cssVarName: string, initialValue = 0, unit = 'px', params?: Partial<SpringPhysicsParams> ) { this.targetElement = element; this.cssVarName = cssVarName; this.unit = unit; this.simulator = new SpringSimulator(initialValue, params); this.tick = this.tick.bind(this); // 初始化 CSS 变量 this.updateCssVariable(initialValue); } private updateCssVariable(val: number): void { this.targetElement.style.setProperty(this.cssVarName, `${val.toFixed(3)}${this.unit}`); } /** * 触发新的运动目标,可随时中断打断 * @param targetValue 目标终点 * @param initialVelocity 可选的初速度(如用户滑动甩出速度) * @param onRest 运动彻底停止时的完成回调 */ public animateTo(targetValue: number, initialVelocity?: number, onRest?: () => void): void { this.simulator.setTarget(targetValue); if (initialVelocity !== undefined) { this.simulator.setVelocity(initialVelocity); } this.onRestCallback = onRest; if (this.rafId === null) { this.lastTime = performance.now(); this.rafId = requestAnimationFrame(this.tick); } } private tick(now: number): void { const dt = (now - this.lastTime) / 1000; this.lastTime = now; const value = this.simulator.step(dt); this.updateCssVariable(value); if (this.simulator.isAtRest()) { this.rafId = null; if (this.onRestCallback) { this.onRestCallback(); } } else { this.rafId = requestAnimationFrame(this.tick); } } public stop(): void { if (this.rafId !== null) { cancelAnimationFrame(this.rafId); this.rafId = null; } } }CSS 配合与硬件加速图层
在 CSS 侧,我们只需将自定义属性直接装配进合成属性(Transform 与 Opacity)中:
.interactive-sheet { /* 默认变量初值 */ --sheet-y: 100%; --sheet-scale: 0.92; width: 100%; max-width: 480px; position: fixed; bottom: 0; left: 50%; /* 仅触发布局引擎的合成器线程,零重排,满帧丝滑 */ transform: translate(-50%, var(--sheet-y)) scale(var(--sheet-scale)); will-change: transform; touch-action: none; }在交互代码中使用它:
const sheetEl = document.querySelector('.interactive-sheet') as HTMLElement; // 分别绑定 Y 轴弹簧和缩放弹簧 const yMotion = new SpringMotionController(sheetEl, '--sheet-y', 100, '%', { stiffness: 220, damping: 24, // 微小回弹,手感硬朗扎实 }); const scaleMotion = new SpringMotionController(sheetEl, '--sheet-scale', 0.92, '', { stiffness: 180, damping: 20, }); // 手势拖拽释放:将手指甩动速度物理注入弹簧 function onPanEnd(velocityPixelsPerSec: number, isSwipeUp: boolean) { if (isSwipeUp) { // 展开目标:0%,注入向上的物理初速度(折算为百分比单位) yMotion.animateTo(0, -velocityPixelsPerSec / window.innerHeight * 100); scaleMotion.animateTo(1.0); } else { // 收起目标:100% yMotion.animateTo(100, velocityPixelsPerSec / window.innerHeight * 100); scaleMotion.animateTo(0.92); } }动效的物理真实感与 ROI
与体积臃肿的第三方动效库相比,这套纯手写的微型弹簧控制器带来了三大质的飞跃:
- 体积仅 1.8KB:零任何框架依赖,不管是在 Vanilla JS、React 还是 Svelte 中都能即插即用;
- 零布局重排(Zero-Reflow):所有的属性更新被收敛在 CSS 变量与 Compositor 线程中,无论在百元安卓机还是 120Hz ProMotion 屏幕上,都能全程维持极限帧率;
- 彻底终结中途截断的顿挫感:由于随时保留当前位置与瞬时速度,任何连续的手势滑动、反向拖拽都能自然平滑地完成动力学过渡。
代码世界的动效不该是死板的数学插值,而应当具有实体乐器琴弦般的张力与回响。把物理方程装进 CSS 变量,交互的指尖手感瞬间就活了过来。