弹簧物理模型与贝塞尔曲线:前端动效的数学内核与工程实现

一、线性动画的"机械感":为什么 ease-in-out 还不够

CSS transition-timing-function 提供的 easeease-in-out 等预设曲线,本质上是对匀速运动的加速-减速修饰。这种修饰遵循固定的数学曲线,无法模拟真实物理世界中物体因惯性产生的过冲(overshoot)和回弹(bounce)。当用户拖拽一个卡片松手后,卡片如果只是减速停止而非轻微弹回,视觉上会显得生硬而不自然。

这种"机械感"的根源在于:贝塞尔曲线是纯几何描述,它定义的是时间-位移的映射关系,而非力-加速度的物理过程。弹簧模型则不同——它从物理定律出发,通过质量、刚度、阻尼三个参数描述运动,天然具备过冲和振荡特性。

生产场景中的典型需求:聊天应用的消息气泡弹入、按钮按压回弹、列表项删除后的弹性归位,这些交互都需要弹簧物理模型才能实现自然的运动质感。

二、从胡克定律到阻尼振荡:弹簧动效的数学基础

flowchart TD
    A[胡克定律: F = -kx] --> B[牛顿第二定律: F = ma]
    B --> C[阻尼弹簧运动方程: mx'' + cx' + kx = 0]
    C --> D{判别式: c^2 - 4mk}
    D -->|c^2 < 4mk| E[欠阻尼: 有振荡回弹]
    D -->|c^2 = 4mk| F[临界阻尼: 最快无振荡归位]
    D -->|c^2 > 4mk| G[过阻尼: 缓慢无振荡归位]

    E --> H[前端动效最常用]
    F --> I[关闭弹窗等场景]
    G --> J[几乎不使用]

    style E fill:#bfb,stroke:#333,stroke-width:2px
    style H fill:#bfb,stroke:#333,stroke-width:2px

阻尼弹簧微分方程

弹簧系统的运动由以下二阶常微分方程描述:

m * x''(t) + c * x'(t) + k * x(t) = 0

其中:

  • m:质量(mass),影响惯性大小
  • c:阻尼系数(damping),控制能量耗散速率
  • k:刚度(stiffness),弹簧的弹性系数
  • x(t):位移函数,x' 是速度,x'' 是加速度

欠阻尼情况下的解析解

前端动效最常用的是欠阻尼状态(c^2 < 4mk),此时方程的解为:

x(t) = A * e^(-γt) * cos(ωd * t + φ)

其中:

  • γ = c / (2m):衰减率,决定振荡消失的速度
  • ωd = sqrt(k/m - γ^2):阻尼角频率,决定振荡频率
  • Aφ:由初始条件决定的振幅和相位

这个解析解揭示了弹簧动效的两个关键特征:指数衰减的包络线 e^(-γt) 控制回弹幅度逐次递减,余弦项 cos(ωd * t) 产生周期性振荡。

与贝塞尔曲线的本质区别

贝塞尔曲线 cubic-bezier(x1, y1, x2, y2) 定义的是时间到进度的参数化映射,四个控制点完全决定曲线形状。而弹簧模型的运动曲线由物理参数隐式决定,无法用四个控制点精确表达。这就是为什么 CSS cubic-bezier() 无法真正模拟弹簧效果——它缺少过冲后的衰减振荡。

三、生产级弹簧动效引擎实现

Step 1:弹簧参数的工程化封装

// spring-engine.ts
// 弹簧物理模型的数值求解器,采用半隐式欧拉法保证稳定性

interface SpringConfig {
  /** 刚度:值越大回弹越快,推荐范围 [100, 800] */
  stiffness: number;
  /** 阻尼:值越大振荡越少,推荐范围 [10, 40] */
  damping: number;
  /** 质量:值越大惯性越强,推荐范围 [0.5, 2] */
  mass: number;
  /** 初始速度,用于承接手势释放时的惯性 */
  initialVelocity?: number;
  /** 位移精度阈值,低于此值时停止计算 */
  precision?: number;
}

interface SpringState {
  position: number;
  velocity: number;
  done: boolean;
}

class SpringEngine {
  private config: Required<SpringConfig>;
  private position: number;
  private velocity: number;

  constructor(
    private fromValue: number,
    private toValue: number,
    config: SpringConfig
  ) {
    // 填充默认值,确保所有参数都有有效值
    this.config = {
      stiffness: config.stiffness,
      damping: config.damping,
      mass: config.mass,
      initialVelocity: config.initialVelocity ?? 0,
      precision: config.precision ?? 0.01
    };
    this.position = fromValue;
    this.velocity = this.config.initialVelocity;
  }

  /**
   * 单步推进:半隐式欧拉法
   * 为什么选择半隐式而非显式欧拉?
   * 显式欧拉先更新位置再更新速度,在高刚度场景下容易发散。
   * 半隐式欧拉先更新速度再更新位置,能量守恒性更好,
   * 即使在 stiffness=800 时仍保持稳定。
   */
  step(deltaTime: number): SpringState {
    // 限制单步时间步长,防止浏览器后台标签页恢复后 deltaTime 过大导致数值爆炸
    const dt = Math.min(deltaTime, 0.064);

    const displacement = this.position - this.toValue;
    const { stiffness, damping, mass } = this.config;

    // 胡克定律:弹簧恢复力 F_spring = -k * x
    const springForce = -stiffness * displacement;
    // 阻尼力:F_damping = -c * v,方向始终与速度相反
    const dampingForce = -damping * this.velocity;
    // 牛顿第二定律:a = F / m
    const acceleration = (springForce + dampingForce) / mass;

    // 半隐式欧拉:先更新速度,再用新速度更新位置
    this.velocity += acceleration * dt;
    this.position += this.velocity * dt;

    // 判断是否收敛:位移和速度都低于精度阈值
    const isSettled =
      Math.abs(this.velocity) < this.config.precision &&
      Math.abs(this.position - this.toValue) < this.config.precision;

    if (isSettled) {
      this.position = this.toValue;
      this.velocity = 0;
    }

    return {
      position: this.position,
      velocity: this.velocity,
      done: isSettled
    };
  }

  /**
   * 预计算弹簧持续时间(用于 CSS animation-duration 的降级方案)
   * 通过迭代模拟估算弹簧达到稳定状态所需时间
   */
  estimateDuration(): number {
    let simPosition = this.fromValue;
    let simVelocity = this.config.initialVelocity;
    let elapsed = 0;
    const dt = 0.016; // 模拟步长 16ms

    // 最大模拟 10 秒,防止无限循环
    while (elapsed < 10) {
      const displacement = simPosition - this.toValue;
      const springForce = -this.config.stiffness * displacement;
      const dampingForce = -this.config.damping * simVelocity;
      const acceleration = (springForce + dampingForce) / this.config.mass;

      simVelocity += acceleration * dt;
      simPosition += simVelocity * dt;
      elapsed += dt;

      if (
        Math.abs(simVelocity) < this.config.precision &&
        Math.abs(simPosition - this.toValue) < this.config.precision
      ) {
        break;
      }
    }

    return elapsed;
  }
}

export { SpringEngine, SpringConfig, SpringState };

Step 2:与 requestAnimationFrame 集成的动画驱动器

// spring-animator.ts
import { SpringEngine, SpringConfig } from './spring-engine';

type OnFrame = (value: number) => void;
type OnEnd = () => void;

class SpringAnimator {
  private engine: SpringEngine;
  private rafId: number | null = null;
  private lastTime: number | null = null;
  private onFrame: OnFrame;
  private onEnd: OnEnd;

  constructor(
    fromValue: number,
    toValue: number,
    config: SpringConfig,
    onFrame: OnFrame,
    onEnd: OnEnd = () => {}
  ) {
    this.engine = new SpringEngine(fromValue, toValue, config);
    this.onFrame = onFrame;
    this.onEnd = onEnd;
  }

  start(): void {
    if (this.rafId !== null) {
      console.warn('动画已在运行中,忽略重复 start 调用');
      return;
    }
    this.lastTime = null;
    this.rafId = requestAnimationFrame(this.tick);
  }

  stop(): void {
    if (this.rafId !== null) {
      cancelAnimationFrame(this.rafId);
      this.rafId = null;
      this.lastTime = null;
    }
  }

  private tick = (timestamp: number): void => {
    if (this.lastTime === null) {
      this.lastTime = timestamp;
      this.rafId = requestAnimationFrame(this.tick);
      return;
    }

    const deltaTime = (timestamp - this.lastTime) / 1000; // 转为秒
    this.lastTime = timestamp;

    const state = this.engine.step(deltaTime);
    this.onFrame(state.position);

    if (state.done) {
      this.rafId = null;
      this.onEnd();
    } else {
      this.rafId = requestAnimationFrame(this.tick);
    }
  };
}

// 使用示例:按钮按压回弹
function setupButtonSpring(element: HTMLElement) {
  const config: SpringConfig = {
    stiffness: 400,
    damping: 20,
    mass: 1,
    precision: 0.5
  };

  element.addEventListener('pointerdown', () => {
    // 按下时弹簧压缩到 0.92 倍
    const animator = new SpringAnimator(
      1,     // from: 正常尺寸
      0.92,  // to: 按压缩小
      config,
      (scale) => {
        element.style.transform = `scale(${scale})`;
      }
    );
    animator.start();
  });

  element.addEventListener('pointerup', () => {
    // 松手时弹簧回弹到 1.0 倍,带过冲效果
    const currentScale = parseFloat(
      element.style.transform.replace(/scale\(([^)]+)\)/, '$1')
    ) || 1;

    const animator = new SpringAnimator(
      currentScale,
      1,  // to: 恢复正常
      { ...config, initialVelocity: 8 }, // 给一个正速度模拟弹力释放
      (scale) => {
        element.style.transform = `scale(${scale})`;
      }
    );
    animator.start();
  });
}

export { SpringAnimator, setupButtonSpring };

四、弹簧模型 vs 贝塞尔曲线:工程选型的边界

1. 可中断性与方向反转

弹簧模型天然支持中断和方向反转——只需更新目标值 toValue,引擎会从当前状态自然过渡到新目标。而 CSS transition 在动画进行中改变目标值时,浏览器会从当前中间状态重新计算贝塞尔曲线,导致运动轨迹出现不自然的拐点。

这是弹簧模型在拖拽交互场景下的核心优势:用户拖拽过程中频繁改变目标位置,弹簧模型能平滑响应每次变化,贝塞尔曲线则会产生"跳帧"感。

2. 性能开销

弹簧模型的每帧需要执行浮点运算(力计算、速度积分),而 CSS transition 的贝塞尔插值由浏览器原生实现,几乎零开销。在同时运行 50 个以上弹簧动画时(如列表项批量删除),帧耗时可能从 2ms 上升到 8ms。应对策略:对批量动画使用共享的弹簧引擎实例,通过单次 requestAnimationFrame 回调驱动所有动画,避免多个 RAF 调度的开销。

3. 可预测性

贝塞尔曲线的时长是确定的(由 transition-duration 决定),而弹簧动画的持续时间取决于物理参数和初始条件,无法精确预知。estimateDuration() 方法只是近似值。在需要精确同步多个动画时长的场景(如页面转场动画),贝塞尔曲线的可预测性是优势。

4. 无障碍适配

弹簧动画的振荡可能对前庭功能障碍用户造成不适。必须配合 prefers-reduced-motion 媒体查询,在用户开启减弱动效时降级为简单的 transition 或直接跳转。

选型决策矩阵

场景 推荐方案 理由
拖拽释放回弹 弹簧模型 需要过冲和可中断性
按钮按压反馈 弹簧模型 物理质感更自然
页面转场 贝塞尔曲线 时长可预测,便于同步
数据面板展开收起 临界阻尼弹簧 快速无振荡归位
简单的 hover 过渡 贝塞尔曲线 零额外开销

五、总结

前端动效的物理真实感,源于对运动数学模型的正确选择。贝塞尔曲线是时间-位移的几何映射,适合时长确定的过渡场景;弹簧模型是力-加速度的物理仿真,适合需要过冲回弹和可中断性的交互场景。

工程落地的核心步骤:首先用半隐式欧拉法实现弹簧数值求解器,保证高刚度参数下的数值稳定性;然后封装 requestAnimationFrame 驱动器,处理 deltaTime 限制和动画生命周期管理;最后根据交互场景的物理特性选择弹簧或贝塞尔方案,并始终为 prefers-reduced-motion 用户提供降级路径。弹簧参数的推荐起点:stiffness: 400, damping: 20, mass: 1,在此基础上根据视觉反馈微调。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐