前端交互中的数学函数

一、背景

前端交互中的状态变化,很多都可以归结为同一个问题:一个值如何随时间演进。

动画中的位置、透明度、缩放比例,进度条中的百分比,滚动吸附时的位移,按钮点击后的反馈,都可以抽象成:

value = f(time)

动效并不只是让元素移动,而是在设计从时间到状态的映射关系。不同曲线会影响用户对系统状态的判断:是稳定推进、自然过渡、明确反馈,还是持续接近但尚未完成。

二、为什么动画需要函数

一个元素从 0px 移动到 100px,最直接的方式是匀速变化:

x(t) = 100 * t

这里的 t 表示 0 到 1 之间的时间比例。

匀速变化在数学上足够直接,但在视觉上容易生硬。现实运动通常包含加速和减速:物体启动时不会瞬间达到最高速度,停止时也不会突然静止。

因此,前端动画通常会引入 easing function。它不改变起点和终点,只改变中间过程。

x(t) = start + (end - start) * easing(t)

easing(t) 的输出仍然位于 0 到 1 之间,却决定了动画是匀速、先快后慢、先慢后快,还是带有越界回弹。

三、前端动画中的常见函数

前端动画中的函数类型较多,大致可以归为线性、多项式、三角函数、指数函数、回弹函数、弹性函数、弹簧函数、阶跃函数和分段函数。它们的差异不只体现在公式形式上,更体现在运动节奏和交互语义上。

1. Linear:线性函数

linear(t) = t

线性函数表示匀速变化,时间比例与状态比例始终保持一致。真实进度、倒计时、音视频播放进度需要稳定比例,扫光、骨架屏高光、进度条光带需要均匀移动,都适合使用这类曲线。

2. Ease In:缓入函数

easeInQuad(t) = t^2
easeInCubic(t) = t^3
easeInQuart(t) = t^4
easeInQuint(t) = t^5

ease-in 前段缓慢、后段加速,适合表达离开、消失和收起,例如元素离开屏幕、页面切走或弹窗关闭。进入动画通常不优先使用它,因为前段变化过慢会削弱即时反馈。

3. Ease Out:缓出函数

easeOutQuad(t) = 1 - (1 - t)^2
easeOutCubic(t) = 1 - (1 - t)^3
easeOutQuart(t) = 1 - (1 - t)^4
easeOutQuint(t) = 1 - (1 - t)^5

ease-out 前段快速、后段减速,能够在开始时提供明确响应,并在结束时自然停稳。弹窗进入、Toast 出现、抽屉展开、卡片滑入等场景,通常使用 easeOutCubic 就能获得稳定、克制的过渡效果。

4. Ease In Out:缓入缓出函数

easeInOutQuad(t) =
  t < 0.5
    ? 2 * t^2
    : 1 - (-2 * t + 2)^2 / 2

ease-in-out 两端缓慢、中段快速,形成完整的启动、加速、减速和停止过程。页面切换、Tab 指示器移动、轮播切换等场景,需要在两个稳定状态之间过渡,通常会使用这类曲线。

5. Sine:正弦函数

easeInSine(t) = 1 - cos((t * PI) / 2)
easeOutSine(t) = sin((t * PI) / 2)
easeInOutSine(t) = -(cos(PI * t) - 1) / 2

正弦函数比多项式缓动更柔和,起止阶段的速度变化更细腻。淡入淡出、轻量位移、背景浮动和呼吸效果,都可以使用 sine 类曲线。

6. Expo:指数函数

easeOutExpo(t) = t === 1 ? 1 : 1 - 2^(-10 * t)
easeInExpo(t) = t === 0 ? 0 : 2^(10 * t - 10)

指数函数变化强度较高。easeOutExpo 会在前段快速接近终点,并在后段进行长尾收敛,常用于强反馈、大距离位移、快速展开,以及模拟进度中的后段趋近。小元素、小距离或短时长动画中,过强的指数变化容易造成突兀感。

7. Circ:圆形函数

easeOutCirc(t) = sqrt(1 - (t - 1)^2)
easeInCirc(t) = 1 - sqrt(1 - t^2)

圆形函数的速度变化具有弧线感,前后段的加速度变化较明显。它比 cubic 更有方向性,又比 elastic 更克制,常用于圆形菜单、径向动效、大面积遮罩切换,以及存在弧形空间关系的位移动画。

8. Back:回退函数

easeOutBack(t) = 1 + c3 * (t - 1)^3 + c1 * (t - 1)^2

back 函数会短暂越过终点,再回到目标位置,适合点赞反馈、徽章出现、小型弹层、成功状态强调等轻量回弹。越界幅度需要克制,否则会削弱界面的稳定感。

9. Elastic:弹性函数

easeOutElastic(t) =
  t === 0 ? 0 :
  t === 1 ? 1 :
  2^(-10 * t) * sin((t * 10 - 0.75) * c4) + 1

elastic 会多次越过终点,模拟弹性物体释放后逐渐稳定的过程。游戏界面、活动页、空状态插画和拖拽释放反馈可以使用这类曲线;由于风格特征较强,不宜在强调稳定性的后台界面中大面积使用。

10. Bounce:弹跳函数

easeOutBounce(t) =
  分段模拟多次落地反弹

bounce 通常不是单个简单公式,而是由多个区间组成的分段函数,用来模拟物体落地后的多次反弹。拖拽落点、游戏化元素、空状态或引导动画可以使用弹跳反馈;常规页面转场中应谨慎使用。

11. Spring:弹簧函数

x(t) = target + A * e^(-damping * t) * cos(frequency * t)

弹簧函数比 backelastic 更接近物理模型,通常由刚度、阻尼、质量等参数控制,而不是仅由时间比例 t 决定。拖拽释放、手势交互、卡片吸附、底部面板跟手运动更适合 spring,因为它可以根据当前位置和速度接管后续运动。

12. Step:阶跃函数

step(t) = t < threshold ? 0 : 1

阶跃函数没有连续过渡,而是在指定时间点直接切换状态。雪碧图帧动画、打字机效果、离散状态切换,以及不希望中间状态被看到的场景,通常会使用这类函数;CSS 中的 steps(12) 就是将动画拆分为 12 个离散帧。

13. Clamp:截断函数

clamp(value, min, max) = min(max(value, min), max)

clamp 不是 easing,但在动画计算中很常见,用于限制数值范围,避免输出越界。滚动进度、拖拽边界、透明度限制、缩放比例限制和进度条保护,都依赖这类边界控制。

14. Lerp:线性插值

lerp(start, end, t) = start + (end - start) * t

lerp 是前端动画中最基础的插值函数,用于根据比例 t 在起点和终点之间取值,常见于位移、缩放、颜色插值、数字滚动、Canvas 和 WebGL 动画。许多动画会先通过 easing 计算进度比例,再通过 lerp 计算具体值。

progress = easeOutCubic(t)
x = lerp(0, 100, progress)

15. Smoothstep:平滑阶跃函数

smoothstep(t) = t * t * (3 - 2 * t)

smoothstep 可以看作平滑版的 ease-in-out,在 0 和 1 附近变化更自然。它常见于图形学、Shader、Canvas 和 WebGL,用于渐变混合、遮罩过渡、光效变化、粒子效果和材质动画。

16. 分段函数

0 <= t < 0.2:
  p = fastStart(t)
 
0.2 <= t < 0.8:
  p = steadyMove(t)
 
0.8 <= t <= 1:
  p = slowEnd(t)

分段函数不是固定曲线,而是将不同阶段组合为完整过程。模拟进度、复杂页面转场、多阶段 loading、引导动画都可以使用这种方式,例如把进度拆成快速启动、持续推进、趋近上限和完成补齐几个阶段。

17. 噪声函数

value = base + noise(t) * amplitude

噪声函数用于制造连续但不完全规则的变化,常见于粒子漂浮、光影闪烁、水波扰动、手绘感抖动和背景装饰动效。可以使用 Perlin Noise、Simplex Noise,或通过随机插值模拟噪声;逐帧调用 Math.random() 容易产生抖动,通常不适合作为动画输入。

18. 贝塞尔曲线

cubic-bezier(x1, y1, x2, y2)

CSS 中的 cubic-bezier 是最常用的工程化动画曲线。它不直接暴露 easeOutCubic(t) 这类公式,而是通过两个控制点描述曲线。

常见写法:

transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);

CSS transition、CSS animation 和设计系统统一动效通常会使用 cubic-bezier,避免在业务代码中维护复杂函数。许多设计系统会将动效抽象为 token,例如 ease-standardease-emphasizedease-decelerate

从工程实践角度看,可以概括为:

函数类型运动特点常见场景
linear匀速、精确真实进度、倒计时、播放进度
ease-in前慢后快退出、关闭、离开屏幕
ease-out前快后慢进入、展开、出现
ease-in-out两端慢、中间快页面转场、Tab 切换、轮播
sine柔和、轻量淡入淡出、呼吸、轻量浮动
expo变化强烈、快速接近强反馈、大距离位移、模拟进度趋近
circ弧线感、空间感径向动效、遮罩、圆形菜单
back轻微越界后回到终点点赞、徽章、小型弹层
elastic多次越界、弹性强游戏化反馈、活动页、拖拽释放
bounce多次反弹拖拽落点、趣味反馈
spring物理感、跟手手势、拖拽、吸附、底部面板
steps离散跳变帧动画、打字机、雪碧图
clamp限制范围滚动、拖拽、进度保护
lerp插值取值位置、颜色、数字、Canvas
smoothstep平滑过渡Shader、Canvas、遮罩混合
分段函数多阶段变化loading、模拟进度、复杂转场
噪声函数不规则但连续粒子、光影、水波、手绘抖动
cubic-bezier工程化曲线描述CSS 动效、设计系统 token

业务实现中不需要优先追求复杂函数。多数 UI 动画可以从三个默认选择开始:

进入:ease-out
退出:ease-in
状态切换:ease-in-out

当交互需要更明确的反馈特征时,再考虑 backelasticspringbounce 或分段函数。选择曲线时,关键问题不只是“是否需要动画”,而是“这条曲线要传达什么状态”。

四、案例:长任务中的模拟进度

模拟进度,即业务中常说的假进度,是函数曲线在等待反馈中的典型应用。它并不代表后端真实完成比例,而是在后端无法提供精确进度时,减少无信息等待带来的不确定性。

AI 生成内容、报表导出、视频转码、大文件解析、批量任务创建、复杂表单提交,都可能产生数秒到数十秒的等待。当前端只能感知“开始”和“结束”时,一个无限旋转的 loading 很难说明任务是否仍在推进,用户也更容易重复点击、刷新页面或关闭弹窗。

模拟进度的价值在于,将不可感知的等待转化为相对可信的过程反馈。它通常不应匀速增长,而应先快后慢:开始阶段快速增长,中段持续推进,后段逐渐放缓,任务完成后再补齐到 100%。

可以设计为分段函数:

0s - 1s:快速到 20%
1s - 5s:逐步到 70%
5s 以后:慢慢逼近 95%
完成后:补到 100%

对应的简化模型如下:

0 <= t < 1:
  p = 20 * easeOutCubic(t)
 
1 <= t < 5:
  p = 20 + 50 * easeOutCubic((t - 1) / 4)
 
t >= 5:
  p = 95 - 25 * e^(-0.25 * (t - 5))

前两段使用缓动函数形成自然增长,最后一段使用指数趋近,让进度持续推进但不轻易到达上限。

这种设计比 progress += 1 更稳定。它依赖真实经过时间,而不是定时器执行次数;即使页面短暂卡顿,下一帧也能根据 elapsed time 计算出合理进度。

一个基于函数的实现

下面是一个不依赖框架的模拟进度控制器,根据已经过时间计算进度。

type SimulatedProgressOptions = {
  limit?: number
  tick?: number
  onChange?: (value: number) => void
}
 
type SimulatedProgressStatus = 'idle' | 'running' | 'done' | 'error'
 
export class SimulatedProgress {
  private value = 0
  private limit: number
  private tick: number
  private timer: number | null = null
  private status: SimulatedProgressStatus = 'idle'
  private startedAt = 0
  private onChange?: (value: number) => void
 
  constructor(options: SimulatedProgressOptions = {}) {
    this.limit = options.limit ?? 95
    this.tick = options.tick ?? 100
    this.onChange = options.onChange
  }
 
  start() {
    if (this.status === 'running') return
 
    this.status = 'running'
    this.startedAt = Date.now()
    this.value = 0
    this.emit()
 
    this.timer = window.setInterval(() => {
      if (this.status !== 'running') return
 
      const elapsed = (Date.now() - this.startedAt) / 1000
      this.value = calculateSimulatedProgress(elapsed, this.limit)
      this.emit()
    }, this.tick)
  }
 
  done() {
    this.clear()
    this.status = 'done'
    this.value = 100
    this.emit()
  }
 
  fail() {
    this.clear()
    this.status = 'error'
    this.emit()
  }
 
  reset() {
    this.clear()
    this.status = 'idle'
    this.value = 0
    this.emit()
  }
 
  private emit() {
    this.onChange?.(Math.round(this.value))
  }
 
  private clear() {
    if (this.timer) {
      window.clearInterval(this.timer)
      this.timer = null
    }
  }
}
 
function calculateSimulatedProgress(elapsed: number, limit = 95) {
  if (elapsed < 1) {
    return 20 * easeOutCubic(elapsed)
  }
 
  if (elapsed < 5) {
    return 20 + 50 * easeOutCubic((elapsed - 1) / 4)
  }
 
  const value = limit - 25 * Math.exp(-0.25 * (elapsed - 5))
  return Math.min(limit, value)
}
 
function easeOutCubic(x: number) {
  const safeX = Math.max(0, Math.min(1, x))
  return 1 - Math.pow(1 - safeX, 3)
}

使用方式如下:

const progress = new SimulatedProgress({
  onChange(value) {
    renderProgress(value)
  }
})
 
async function submitTask() {
  progress.reset()
  progress.start()
 
  try {
    await requestCreateTask()
    progress.done()
  } catch (error) {
    progress.fail()
    showError(error)
  }
}

这个版本只负责进度曲线,不耦合具体 UI,可以接入 React、Vue,也可以用于普通 DOM。

和真实进度结合

模拟进度不应替代真实进度。真实业务中经常出现混合场景:前半段有真实进度,后半段没有;或者后端只返回阶段状态,而不返回精确百分比。此时可以将两者组合使用。

例如导入文件时,上传阶段使用浏览器提供的真实 upload progress;进入服务端解析后,再切换为模拟进度:

上传文件:真实 upload progress,0% -> 60%
服务端解析:模拟进度,60% -> 90%
入库完成:补齐到 100%

也可以按后端状态映射:

queued     -> 10%
processing -> 40% - 85% 模拟进度
finalizing -> 90%
success    -> 100%
failed     -> error

这种方式既保留真实进度的准确性,也覆盖后端无法精确反馈的阶段。

边界问题

函数曲线解决的是体验表达问题,而不是业务事实本身。模拟进度最常见的问题是过早到达 99%。如果任务尚未完成,进度条却长时间停留在 99%,用户的不确定感会进一步增加。更合理的做法是将模拟上限控制在 90% 到 95%,任务完成后再补齐到 100%。

失败状态也需要及时处理。请求失败后应立即停止进度并切换到错误状态,避免错误提示和仍在推进的进度同时出现。

另一个边界是避免承诺真实耗时。模拟进度只能表达“任务仍在进行”,不能承诺“还剩几秒”。如果任务耗时不可控,就不应展示过于具体的剩余时间。

进度条应配合阶段文案。百分比表达“进行到哪里”,文案解释系统“正在做什么”:

正在提交任务...
正在生成内容...
正在整理结果...
即将完成...

阶段文案比百分比更能解释当前系统状态,也能降低模拟进度与真实进度不完全一致带来的违和感。

总结

前端动效的核心不是“让界面动起来”,而是用曲线表达状态变化。同样是从 0 到 1,线性、缓动、弹性、指数趋近和分段函数会传达不同语义:稳定推进、自然过渡、强调反馈、持续接近或阶段切换。

模拟进度也是同样的问题。它不是对真实进度的替代,而是在真实进度不可得时,用函数曲线提供可感知、可解释、不过度承诺的等待反馈。

因此,前端中的数学函数不只是动画实现工具,也是在定义状态如何变化、反馈如何推进。曲线设计得越准确,用户越容易判断系统是否已响应、任务是否仍在执行、当前等待是否处于可预期范围内。

按下 K 进行搜索