一、背景
前端交互中的状态变化,很多都可以归结为同一个问题:一个值如何随时间演进。
动画中的位置、透明度、缩放比例,进度条中的百分比,滚动吸附时的位移,按钮点击后的反馈,都可以抽象成:
动效并不只是让元素移动,而是在设计从时间到状态的映射关系。不同曲线会影响用户对系统状态的判断:是稳定推进、自然过渡、明确反馈,还是持续接近但尚未完成。
二、为什么动画需要函数
一个元素从 0px 移动到 100px,最直接的方式是匀速变化:
这里的 t 表示 0 到 1 之间的时间比例。
匀速变化在数学上足够直接,但在视觉上容易生硬。现实运动通常包含加速和减速:物体启动时不会瞬间达到最高速度,停止时也不会突然静止。
因此,前端动画通常会引入 easing function。它不改变起点和终点,只改变中间过程。
easing(t) 的输出仍然位于 0 到 1 之间,却决定了动画是匀速、先快后慢、先慢后快,还是带有越界回弹。
三、前端动画中的常见函数
前端动画中的函数类型较多,大致可以归为线性、多项式、三角函数、指数函数、回弹函数、弹性函数、弹簧函数、阶跃函数和分段函数。它们的差异不只体现在公式形式上,更体现在运动节奏和交互语义上。
1. Linear:线性函数
线性函数表示匀速变化,时间比例与状态比例始终保持一致。真实进度、倒计时、音视频播放进度需要稳定比例,扫光、骨架屏高光、进度条光带需要均匀移动,都适合使用这类曲线。
2. Ease In:缓入函数
ease-in 前段缓慢、后段加速,适合表达离开、消失和收起,例如元素离开屏幕、页面切走或弹窗关闭。进入动画通常不优先使用它,因为前段变化过慢会削弱即时反馈。
3. Ease Out:缓出函数
ease-out 前段快速、后段减速,能够在开始时提供明确响应,并在结束时自然停稳。弹窗进入、Toast 出现、抽屉展开、卡片滑入等场景,通常使用 easeOutCubic 就能获得稳定、克制的过渡效果。
4. Ease In Out:缓入缓出函数
ease-in-out 两端缓慢、中段快速,形成完整的启动、加速、减速和停止过程。页面切换、Tab 指示器移动、轮播切换等场景,需要在两个稳定状态之间过渡,通常会使用这类曲线。
5. Sine:正弦函数
正弦函数比多项式缓动更柔和,起止阶段的速度变化更细腻。淡入淡出、轻量位移、背景浮动和呼吸效果,都可以使用 sine 类曲线。
6. Expo:指数函数
指数函数变化强度较高。easeOutExpo 会在前段快速接近终点,并在后段进行长尾收敛,常用于强反馈、大距离位移、快速展开,以及模拟进度中的后段趋近。小元素、小距离或短时长动画中,过强的指数变化容易造成突兀感。
7. Circ:圆形函数
圆形函数的速度变化具有弧线感,前后段的加速度变化较明显。它比 cubic 更有方向性,又比 elastic 更克制,常用于圆形菜单、径向动效、大面积遮罩切换,以及存在弧形空间关系的位移动画。
8. Back:回退函数
back 函数会短暂越过终点,再回到目标位置,适合点赞反馈、徽章出现、小型弹层、成功状态强调等轻量回弹。越界幅度需要克制,否则会削弱界面的稳定感。
9. Elastic:弹性函数
elastic 会多次越过终点,模拟弹性物体释放后逐渐稳定的过程。游戏界面、活动页、空状态插画和拖拽释放反馈可以使用这类曲线;由于风格特征较强,不宜在强调稳定性的后台界面中大面积使用。
10. Bounce:弹跳函数
bounce 通常不是单个简单公式,而是由多个区间组成的分段函数,用来模拟物体落地后的多次反弹。拖拽落点、游戏化元素、空状态或引导动画可以使用弹跳反馈;常规页面转场中应谨慎使用。
11. Spring:弹簧函数
弹簧函数比 back 和 elastic 更接近物理模型,通常由刚度、阻尼、质量等参数控制,而不是仅由时间比例 t 决定。拖拽释放、手势交互、卡片吸附、底部面板跟手运动更适合 spring,因为它可以根据当前位置和速度接管后续运动。
12. Step:阶跃函数
阶跃函数没有连续过渡,而是在指定时间点直接切换状态。雪碧图帧动画、打字机效果、离散状态切换,以及不希望中间状态被看到的场景,通常会使用这类函数;CSS 中的 steps(12) 就是将动画拆分为 12 个离散帧。
13. Clamp:截断函数
clamp 不是 easing,但在动画计算中很常见,用于限制数值范围,避免输出越界。滚动进度、拖拽边界、透明度限制、缩放比例限制和进度条保护,都依赖这类边界控制。
14. Lerp:线性插值
lerp 是前端动画中最基础的插值函数,用于根据比例 t 在起点和终点之间取值,常见于位移、缩放、颜色插值、数字滚动、Canvas 和 WebGL 动画。许多动画会先通过 easing 计算进度比例,再通过 lerp 计算具体值。
15. Smoothstep:平滑阶跃函数
smoothstep 可以看作平滑版的 ease-in-out,在 0 和 1 附近变化更自然。它常见于图形学、Shader、Canvas 和 WebGL,用于渐变混合、遮罩过渡、光效变化、粒子效果和材质动画。
16. 分段函数
分段函数不是固定曲线,而是将不同阶段组合为完整过程。模拟进度、复杂页面转场、多阶段 loading、引导动画都可以使用这种方式,例如把进度拆成快速启动、持续推进、趋近上限和完成补齐几个阶段。
17. 噪声函数
噪声函数用于制造连续但不完全规则的变化,常见于粒子漂浮、光影闪烁、水波扰动、手绘感抖动和背景装饰动效。可以使用 Perlin Noise、Simplex Noise,或通过随机插值模拟噪声;逐帧调用 Math.random() 容易产生抖动,通常不适合作为动画输入。
18. 贝塞尔曲线
CSS 中的 cubic-bezier 是最常用的工程化动画曲线。它不直接暴露 easeOutCubic(t) 这类公式,而是通过两个控制点描述曲线。
常见写法:
CSS transition、CSS animation 和设计系统统一动效通常会使用 cubic-bezier,避免在业务代码中维护复杂函数。许多设计系统会将动效抽象为 token,例如 ease-standard、ease-emphasized、ease-decelerate。
从工程实践角度看,可以概括为:
业务实现中不需要优先追求复杂函数。多数 UI 动画可以从三个默认选择开始:
当交互需要更明确的反馈特征时,再考虑 back、elastic、spring、bounce 或分段函数。选择曲线时,关键问题不只是“是否需要动画”,而是“这条曲线要传达什么状态”。
四、案例:长任务中的模拟进度
模拟进度,即业务中常说的假进度,是函数曲线在等待反馈中的典型应用。它并不代表后端真实完成比例,而是在后端无法提供精确进度时,减少无信息等待带来的不确定性。
AI 生成内容、报表导出、视频转码、大文件解析、批量任务创建、复杂表单提交,都可能产生数秒到数十秒的等待。当前端只能感知“开始”和“结束”时,一个无限旋转的 loading 很难说明任务是否仍在推进,用户也更容易重复点击、刷新页面或关闭弹窗。
模拟进度的价值在于,将不可感知的等待转化为相对可信的过程反馈。它通常不应匀速增长,而应先快后慢:开始阶段快速增长,中段持续推进,后段逐渐放缓,任务完成后再补齐到 100%。
可以设计为分段函数:
对应的简化模型如下:
前两段使用缓动函数形成自然增长,最后一段使用指数趋近,让进度持续推进但不轻易到达上限。
这种设计比 progress += 1 更稳定。它依赖真实经过时间,而不是定时器执行次数;即使页面短暂卡顿,下一帧也能根据 elapsed time 计算出合理进度。
一个基于函数的实现
下面是一个不依赖框架的模拟进度控制器,根据已经过时间计算进度。
使用方式如下:
这个版本只负责进度曲线,不耦合具体 UI,可以接入 React、Vue,也可以用于普通 DOM。
和真实进度结合
模拟进度不应替代真实进度。真实业务中经常出现混合场景:前半段有真实进度,后半段没有;或者后端只返回阶段状态,而不返回精确百分比。此时可以将两者组合使用。
例如导入文件时,上传阶段使用浏览器提供的真实 upload progress;进入服务端解析后,再切换为模拟进度:
也可以按后端状态映射:
这种方式既保留真实进度的准确性,也覆盖后端无法精确反馈的阶段。
边界问题
函数曲线解决的是体验表达问题,而不是业务事实本身。模拟进度最常见的问题是过早到达 99%。如果任务尚未完成,进度条却长时间停留在 99%,用户的不确定感会进一步增加。更合理的做法是将模拟上限控制在 90% 到 95%,任务完成后再补齐到 100%。
失败状态也需要及时处理。请求失败后应立即停止进度并切换到错误状态,避免错误提示和仍在推进的进度同时出现。
另一个边界是避免承诺真实耗时。模拟进度只能表达“任务仍在进行”,不能承诺“还剩几秒”。如果任务耗时不可控,就不应展示过于具体的剩余时间。
进度条应配合阶段文案。百分比表达“进行到哪里”,文案解释系统“正在做什么”:
阶段文案比百分比更能解释当前系统状态,也能降低模拟进度与真实进度不完全一致带来的违和感。
总结
前端动效的核心不是“让界面动起来”,而是用曲线表达状态变化。同样是从 0 到 1,线性、缓动、弹性、指数趋近和分段函数会传达不同语义:稳定推进、自然过渡、强调反馈、持续接近或阶段切换。
模拟进度也是同样的问题。它不是对真实进度的替代,而是在真实进度不可得时,用函数曲线提供可感知、可解释、不过度承诺的等待反馈。
因此,前端中的数学函数不只是动画实现工具,也是在定义状态如何变化、反馈如何推进。曲线设计得越准确,用户越容易判断系统是否已响应、任务是否仍在执行、当前等待是否处于可预期范围内。
value = f(time)x(t) = 100 * tx(t) = start + (end - start) * easing(t)linear(t) = teaseInQuad(t) = t^2easeInCubic(t) = t^3easeInQuart(t) = t^4easeInQuint(t) = t^5easeOutQuad(t) = 1 - (1 - t)^2easeOutCubic(t) = 1 - (1 - t)^3easeOutQuart(t) = 1 - (1 - t)^4easeOutQuint(t) = 1 - (1 - t)^5easeInOutQuad(t) = t < 0.5 ? 2 * t^2 : 1 - (-2 * t + 2)^2 / 2easeInSine(t) = 1 - cos((t * PI) / 2)easeOutSine(t) = sin((t * PI) / 2)easeInOutSine(t) = -(cos(PI * t) - 1) / 2easeOutExpo(t) = t === 1 ? 1 : 1 - 2^(-10 * t)easeInExpo(t) = t === 0 ? 0 : 2^(10 * t - 10)easeOutCirc(t) = sqrt(1 - (t - 1)^2)easeInCirc(t) = 1 - sqrt(1 - t^2)easeOutBack(t) = 1 + c3 * (t - 1)^3 + c1 * (t - 1)^2easeOutElastic(t) = t === 0 ? 0 : t === 1 ? 1 : 2^(-10 * t) * sin((t * 10 - 0.75) * c4) + 1easeOutBounce(t) = 分段模拟多次落地反弹x(t) = target + A * e^(-damping * t) * cos(frequency * t)step(t) = t < threshold ? 0 : 1clamp(value, min, max) = min(max(value, min), max)lerp(start, end, t) = start + (end - start) * tprogress = easeOutCubic(t)x = lerp(0, 100, progress)smoothstep(t) = t * t * (3 - 2 * t)0 <= t < 0.2: p = fastStart(t)
0.2 <= t < 0.8: p = steadyMove(t)
0.8 <= t <= 1: p = slowEnd(t)value = base + noise(t) * amplitudecubic-bezier(x1, y1, x2, y2)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);进入:ease-out退出:ease-in状态切换:ease-in-out0s - 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))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) }}上传文件:真实 upload progress,0% -> 60%服务端解析:模拟进度,60% -> 90%入库完成:补齐到 100%queued -> 10%processing -> 40% - 85% 模拟进度finalizing -> 90%success -> 100%failed -> error正在提交任务...正在生成内容...正在整理结果...即将完成...