Web 动效最常见的误区,是从“要不要用 GSAP”或“这个属性走不走 GPU”开始。库与属性只是实现细节;产品真正需要的是状态变化、空间连续性、操作反馈或注意力引导。若意图没有定义,开发者只能不断增加位移、弹性和光效;若渲染成本没有被测量,transformwill-change 与“60 FPS”又会变成无法证伪的口号。

本文面向已经会写 CSS transition、keyframes 和 requestAnimationFrame,但希望建立可复用动效系统的开发者。我们从“用户为什么需要看到变化”出发,经过时间模型、属性选择、浏览器渲染管线、生命周期和无障碍,最终落到可重复的性能验证。三段精确代码已进入 examples/ai-web:CSS 通过静态契约测试,指针运行时通过 TypeScript 类型检查与 Vitest。本站没有对应页面的设备 trace,因此帧率、用户研究与提升百分比结论仍是 source-reviewed

先定义运动承担的任务

动效可以分为四类工程任务。状态反馈回答“刚才的操作成功了吗”,例如保存按钮从处理中转为完成;空间连续性回答“内容去了哪里”,例如列表项进入详情;关系解释展示“这些对象怎样关联”,例如筛选后列表重排;注意力引导提示“下一步看哪里”,例如错误摘要短暂强调。纯装饰运动也可以存在,但必须能在不影响信息与操作的情况下关闭。

每个效果在实现前写一行合同:触发事件、起点状态、终点状态、可中断方式、Reduce Motion 替代和失败时的静态内容。例如“抽屉在按钮激活后从 inline-end 进入;再次激活或按 Escape 反向退出;焦点在完成前后保持可预测;减弱动态时直接显示并淡入”。这比“做一个丝滑抽屉”更容易实现、测试和复查。

运动不是延迟业务状态的理由。按钮点击后应立即进入可理解的 pending 状态,动画只解释变化,不应等 800 毫秒才真正提交。关闭弹窗、切换路由和删除数据也要区分可撤销视觉与不可逆操作;不能因为退出动画尚未播放完,就让用户误以为数据仍安全存在。

一个帧不只有绘制

浏览器收到 DOM、样式或动画值变化后,可能经历 style、layout、paint 和 composition。并非每次都完整走四步:若几何未变,可以跳过 layout;若现有像素层只需要变换或改变透明度,某些实现可以主要在 composition 阶段完成。MDN 的动画性能指南 将布局、绘制和合成明确区分,web.dev 渲染性能说明 也展示了不同属性可能触发的管线路径。

“可能”很重要。transformopacity 通常是优先候选,因为浏览器经常能对它们做合成优化;但属性名不保证独立图层,也不保证低成本。巨大半透明层、复杂滤镜、超大纹理、多个混合模式或高像素密度仍会增加合成与内存压力。相反,一个小面积、低频的 background-color 绘制未必构成真实瓶颈。性能建议必须和元素面积、层数量、设备与交互场景一起判断。

尺寸、颜色、滤镜、变换和透明度可能经过的渲染阶段

图 1:属性只是选择候选路径,最终是否产生布局、绘制或独立合成需要 DevTools trace 证明;本站原创 1400×800 程序化 SVG。

多层网页画面沿精确缓动轨迹移动而高成本属性绕入复杂渲染机械路径

图 2(1600 × 900):合成层与属性选择的编辑式视觉隐喻,不作为性能 trace。OpenAI Image Gen × WEB/SUN,2026-07-16;项目内原创生成资产。

按视觉结果选择属性

移动元素时,若周围布局不需要跟随,优先用 transform: translate();若其他内容必须为它腾出空间,布局变化本身就是需求,此时不能用视觉位移假装几何已经更新。缩放同理:scale() 不会重新排版内部文本,放大很多可能产生模糊或溢出;真正的响应式尺寸变化需要布局,只是应该降低频率并缩小影响范围。

显示与隐藏通常可以组合 opacityvisibility 和交互状态。只设 opacity: 0 的元素仍可能占布局、接收点击并出现在可访问树中;只在结束时设 display: none 又需要处理离场时序。一个可用组件要同时定义视觉、命中、焦点与语义,而不是让透明度承担所有职责。

高度从 0auto、网格列重排和文本换行属于布局问题。可以用 FLIP 思路先记录 First 与 Last 几何,再用 Invert transform 抵消差异并 Play 回零;但读取两次几何应与样式写入分批,避免在循环里交替读写造成强制同步布局。FLIP 是把已知的布局变化转成一段视觉过渡,不是消除布局本身。

颜色、阴影、滤镜、mask 和渐变往往涉及 paint。不要因为它们“不够 GPU”就全部禁止;品牌光感可能确实需要像素变化。更好的约束是限制作用面积、持续时间和同时运行数量,并在代表设备上记录 paint flashing 与时间线。若效果只是强调按钮,边框颜色或一个小伪元素通常比给整个视口做 blur 更容易控制。

缓动是进度映射,不是情绪标签

动画先生成输入进度,再由 easing 映射成输出进度。CSS Easing Functions Level 2 定义了 linear、cubic-bezier()steps() 与更复杂的 linear() 函数;其中 Level 2 是 Working Draft,使用新语法前仍要核对目标浏览器。稳定生产基线可以依赖成熟的 cubic-bezier 与 steps,再把高级曲线放进 @supports 或设计工具输出的可回退 token。

cubic-bezier(x1, y1, x2, y2) 的两个 x 控制点必须落在 0 到 1,y 可以超出区间形成 overshoot。曲线描述速度如何随时间变化,不直接描述弹簧质量。把所有进入动画统一叫 ease-out 也过于粗糙:用户拖拽释放需要继承手势速度;系统主动出现的提示需要快速可见;持续跟随指针的效果通常应接近线性并做轻微平滑,否则界面会落后于输入。

建立少量语义 token,而不是在组件中散落几十条贝塞尔值:

:root {
  --motion-duration-feedback: 140ms;
  --motion-duration-route: 320ms;
  --motion-ease-enter: cubic-bezier(0.2, 0.8, 0.2, 1);
  --motion-ease-exit: cubic-bezier(0.4, 0, 1, 1);
  --motion-distance-small: 0.5rem;
}

.notice[data-state='open'] {
  animation: notice-in var(--motion-duration-feedback) var(--motion-ease-enter) both;
}

数字只是系统示例,不是经过本站性能或用户研究验证的行业最佳值。真正的门禁是同一语义使用同一 token、组件能被中断、连续操作不会排队播放过时动画。若状态在进入过程中反转,动画应从当前视觉值自然返回,而不是先跳到起点再退出。

时长来自距离、任务与输入

同一个 duration 不适合所有距离。八像素的按钮反馈和整屏抽屉若都用 320ms,前者显得拖沓,后者可能显得突然;但也不能用“每像素固定毫秒”机械计算,因为大范围移动通常需要速度上限,重要状态还必须尽快可见。可以为 small、medium、route 等少量语义等级设基线,再根据实际距离、内容密度与设备输入验证。

直接操控阶段应尽量贴近输入。拖拽过程中额外叠加长 transition,会让对象落后于指针;释放后才进入惯性或吸附 easing。键盘触发没有连续手势速度,可以使用确定过渡。触摸、鼠标和键盘最终到达同一状态,但中间时间模型不必完全相同。

动画时长也不能成为交互锁。菜单在视觉进入期间,若主要选项已经清楚且命中稳定,可以允许操作;若快照或 transform 尚未对齐导致误触风险,则应缩短过渡并明确临时状态,而不是用全屏透明层拦截很久。用户连续触发时,新状态优先,旧动画应取消、反向或接续当前进度。

延迟要单独预算。Hover 提示可以有很短的意图延迟以避免路过触发,但点击反馈应立即开始;网络响应时间不能伪装成动画 delay。骨架、进度与最终内容之间的切换要对应真实资源状态,不能为了展示漂亮序列固定播放。

CSS、WAAPI 与 requestAnimationFrame 的边界

CSS transition 适合由状态选择器驱动的两端变化;keyframes 适合声明式多阶段序列;Web Animations API 适合需要暂停、反向、改变 playback rate、等待 finished 或集中管理实例的 DOM 动画。Web Animations 规范 把 timeline、animation effect 与组合模型分开,CSS Animations 和脚本 API 可以共享同一套时间概念。

requestAnimationFrame 适合每帧必须读取输入、运行模拟或绘制 Canvas 的情况,不应该只是为了手写一个 CSS transition。它的 callback timestamp 必须参与计算;若每帧固定加一个像素,高刷新率屏幕会跑得更快。MDN 的 requestAnimationFrame 文档 说明回调通常跟显示刷新率同步,并且多数浏览器会在后台标签或隐藏 iframe 中暂停回调。暂停是节能行为,不代表页面返回时业务时间也自动正确。

一个指针运行时应合并高频事件,每帧最多写一次,并返回 cleanup:

export function initPointerMotion(target: HTMLElement) {
  const reduced = matchMedia('(prefers-reduced-motion: reduce)');
  if (reduced.matches) return () => {};

  let frame = 0;
  let x = 0;
  let y = 0;

  const render = () => {
    frame = 0;
    target.style.setProperty('--pointer-x', x.toFixed(3));
    target.style.setProperty('--pointer-y', y.toFixed(3));
  };

  const onPointerMove = (event: PointerEvent) => {
    const rect = target.getBoundingClientRect();
    x = (event.clientX - rect.left) / rect.width;
    y = (event.clientY - rect.top) / rect.height;
    if (!frame) frame = requestAnimationFrame(render);
  };

  target.addEventListener('pointermove', onPointerMove, { passive: true });

  return () => {
    target.removeEventListener('pointermove', onPointerMove);
    if (frame) cancelAnimationFrame(frame);
    target.style.removeProperty('--pointer-x');
    target.style.removeProperty('--pointer-y');
  };
}

代码只示范所有权,不代表已经做过本机 trace。getBoundingClientRect() 不必在每个 pointer event 重读;稳定布局可以在进入、resize 或 ResizeObserver 通知时更新缓存。写 CSS custom property 也可能引发继承范围内的样式计算,应把变量放在最小容器,并检查它最终驱动什么属性。

合成层不是越多越好

浏览器会根据动画、滚动、视频和其他条件决定分层。will-change 只是“未来可能变化”的提示,不是创建 GPU 加速的标准开关。MDN 对 will-change 的警告 明确要求谨慎使用,过多元素长期保留提示会消耗资源并可能让渲染更复杂;web.dev 动画指南 也建议在发现图形问题并有测量依据后再尝试。

若 trace 证明某个即将展开的大面板在首帧有 promotion 或绘制抖动,可以在交互前短暂设置 will-change: transform,动画结束后移除。不要给所有 .card、所有图片或整个页面永久加 translateZ(0)。层占用纹理内存,层间混合有成本,过多层还会让调试与 stacking context 更难理解。

containcontent-visibility 与 layer promotion 也不是同一概念。前者约束布局、样式或绘制影响,后者可能跳过离屏内容工作;它们会改变尺寸估算、焦点和查找行为,需要按内容模型评估,不能作为动效样板中的默认三件套。

中断、并发与生命周期

真实用户会连点、快速返回、切换标签和旋转屏幕。动效系统必须规定同一元素的新动画是 replace、reverse 还是与旧效果组合;多个模块不能同时写完整 transform 字符串,否则最后一次写入会覆盖其他模块。可以让布局位移、指针倾斜和强调缩放写入不同 CSS 自定义变量,再在一个组件样式中统一 compose transform。

每个页面初始化函数返回 cleanup,拥有自己创建的 RAF、Animation、Observer、timer 和 listener。页面离开或组件卸载时只清理自身资源,不调用“杀死全部动画”的全局命令。document.hidden 时暂停持续模拟,恢复后根据当前时间重建;不要补播用户没有看到的每一帧。

动画完成 Promise 也要处理取消。WAAPI 的 finished 在动画被 cancel 时可能 reject,业务逻辑不能因此留下未捕获异常。视觉完成与数据提交分离,路由离开后异步回调不得继续修改已经不存在的节点。

组件并发还需要写入所有权表。Transform 最终由哪个样式组装,opacity 是否同时被 reveal 与 disabled 状态使用,路由 transition 能否覆盖组件 animation,都应在组件边界明确。CSS cascade layer、data-state 和自定义变量可以减少抢写,但不能自动解决两个系统语义冲突。若 disabled 必须保持完全可见,就不应让离场模块继续把 opacity 写到零。

调试时给每个运行时可追踪名称,而不是在控制台每帧打印。WAAPI Animation、GSAP context 与页面 cleanup 可带组件 ID;Performance mark 标记“菜单请求打开”和“可操作完成”。这些标记帮助把用户任务与 trace 对齐,但只有低频、明确边界的测量才有价值。

Reduce Motion 与等价交互

prefers-reduced-motion: reduce 不是把所有 duration 乘以 0.5。大范围位移、视差、景深、持续旋转和闪烁应停止或替换;状态反馈可使用即时变化、短 opacity 或静态强调。信息不能只在动画中存在,用户跳过运动后仍要知道对象从什么状态变成什么状态。

@media (prefers-reduced-motion: reduce) {
  .parallax,
  .ambient-loop {
    animation: none;
    transform: none;
  }

  .notice[data-state='open'] {
    animation: none;
    opacity: 1;
  }
}

Hover 触发的信息必须有 focus 与 tap 等价路径。键盘焦点不能跟着纯视觉 transform 跑到屏幕外;透明或缩小的交互元素要同步管理 inert、visibility 或组件状态。自定义光标只能在 fine pointer 且脚本成功时增强,原生 cursor 与可点击语义始终保留。

用证据关闭性能问题

先写可重复动作:固定数据冷加载,打开同一菜单,连续切换三次,滚过同一段,再切到后台返回。记录浏览器、设备、视口、构建模式、网络和缓存状态。DevTools Performance trace 用来识别长任务、layout、paint、composite 与掉帧区间;Layers 或 paint flashing 用来验证分层与重绘假设。平均 FPS 不能解释一次输入延迟,也不能证明离屏没有持续工作。

一次只改变一个变量:把 width 动画替换为 transform;缩小 blur 面积;移除 will-change;把每个 pointer event 写样式改为每帧一次。用相同场景复测,并同时检查视觉、键盘、Reduce Motion 和布局稳定性。若没有记录设备与方法,就不要发布“提升 40%”之类无法复现的数字。

性能之外还要记录行为证据:快速反向时是否跳帧,连续点击后最终状态是否正确,字体失败时文本是否可见,图片晚到是否改变 transform 原点,200% 缩放和 320px 视口是否溢出。动效 QA 不是只截一张“最漂亮的中间帧”,而是检查起点、进行中、终点、取消和回退五种状态。

线上监测也只能告诉你真实用户遇到了长任务或 Web Vitals 退化,不能自动定位某条 easing。发布前 trace 建立因果,发布后分布数据发现未覆盖设备;两类证据互补。采样时避免收集敏感指针轨迹,动效性能不需要记录用户具体输入内容。

常见失败包括:用 duration 掩盖慢响应;同时动画太多属性;把 transform 当免费;永久 will-change;RAF 不用 timestamp;每次输入读写布局;路由离开不 cleanup;透明元素仍拦截操作;Reduced Motion 仍持续视差;只在高性能桌面浏览器主观验收。

成熟的 Web Motion 运行时不是效果仓库,而是一组可验证合同:动效有任务,时间有语义,属性与渲染成本匹配,实例可中断,页面可清理,静态路径始终可用。先建立这层基础,再选择 CSS、WAAPI、GSAP、Canvas 或 WebGL,炫技才不会以输入延迟和维护失控为代价。

Sources