22.4 动画性能:60fps / 120fps、VSync、输入响应性
深入解析动画渲染机制、VSync、requestAnimationFrame、输入响应性与高刷新率屏幕优化
原理
流畅的动画是用户体验质量的重要指标。人类视觉系统对运动的不连续性非常敏感——当帧率低于一定阈值时,动画会被感知为"卡顿"或"跳跃"。理解显示系统的物理限制和浏览器的渲染时序,是创建高性能动画的基础。
刷新率与帧时间的物理约束
显示器的刷新率(Refresh Rate)决定了屏幕像素的更新频率:
- 60Hz:每 16.67ms 更新一次,这是桌面显示器和大多数移动设备的标准
- 90Hz:每 11.11ms 更新一次,常见于中高端安卓手机
- 120Hz:每 8.33ms 更新一次,iPhone Pro、iPad Pro 和高端安卓旗舰支持
- 144Hz+:电竞显示器,Web 场景较少见
VSync(垂直同步)
VSync 是显示系统的同步机制,确保帧缓冲区(Frame Buffer)的交换发生在显示器刷新周期的起始点。若浏览器在 16ms 周期的中间完成渲染,必须等待下一个 VSync 信号才能显示,导致该帧实际上延迟了一整个周期。
这意味着:动画的帧时间预算必须严格小于显示器的刷新间隔。对于 60Hz,预算为 16.67ms;对于 120Hz,预算仅为 8.33ms。
浏览器渲染时序与 requestAnimationFrame
requestAnimationFrame(rAF)是浏览器提供的与刷新率同步的回调机制。它的核心特性:
- 与 VSync 对齐:rAF 回调在每次显示器刷新前触发,确保动画更新与屏幕刷新同步
- 自动节流:在后台标签页中自动暂停,节省电量
- 合并回调:同一帧中的多个 rAF 调用被合并为单次执行
rAF 回调中的时间预算:
VSync 信号
|
v
rAF 回调执行(JavaScript)
|
v
Style 计算
|
v
Layout(若触发布局属性)
|
v
Paint(若触发绘制属性)
|
v
Composite(合成器线程,通常与主线程并行)
|
v
下一个 VSync 信号(必须在此之前完成)
对于 60fps,整个流水线必须在 16.67ms 内完成。RAIL 模型建议将 JavaScript 执行控制在 10ms 以内,为浏览器内部工作预留 6ms 缓冲。
高刷新率屏幕的挑战
120Hz 屏幕的普及带来了新的性能挑战:
- 帧时间减半:从 16.67ms 降至 8.33ms,JavaScript 预算从 10ms 降至约 5ms
- 主线程压力倍增:相同的动画逻辑在 120Hz 下需要两倍的主线程时间
- 合成器线程瓶颈:虽然合成阶段在独立线程,但过多的合成层更新也可能压垮 GPU
matchMedia 检测刷新率:
// 检测是否支持 120Hz
const isHighRefreshRate = window.matchMedia(
'(refresh-rate: 120hz)'
).matches;
// 更通用的方法:通过 rAF 间隔推断
function detectRefreshRate(callback) {
let lastTime = performance.now();
let frames = 0;
const times = [];
function measure() {
const now = performance.now();
times.push(now - lastTime);
lastTime = now;
frames++;
if (frames < 30) {
requestAnimationFrame(measure);
} else {
// 取中位数作为刷新间隔
times.sort((a, b) => a - b);
const median = times[Math.floor(times.length / 2)];
const fps = Math.round(1000 / median);
callback(fps);
}
}
requestAnimationFrame(measure);
}
输入响应性与动画的交互
当用户在与动画交互时(如拖拽滑块、缩放地图),输入事件和动画更新需要在同一帧内完成,才能产生"跟手"的感觉。
输入到显示的完整链路:
用户触摸/移动鼠标
|
v
操作系统接收硬件中断(~1ms)
|
v
浏览器合成器线程接收输入事件(~2-4ms)
|
v
若事件需要主线程处理:排队等待主线程空闲
|
v
主线程执行事件处理程序(JavaScript)
|
v
更新动画状态,触发 rAF
|
v
渲染流水线(Style -> Layout -> Paint -> Composite)
|
v
下一个 VSync 显示更新
"跟手感"的关键在于输入事件到像素更新的延迟(Motion-to-Photon Latency)。理想情况下,这一延迟应 < 20ms。主线程的长任务、复杂的渲染流水线都会增加这一延迟。
用法
使用 requestAnimationFrame 实现流畅动画
// 基于 rAF 的动画循环
function animateElement(element, targetX, duration) {
const startX = parseFloat(element.style.transform.replace(/[^0-9.-]/g, '')) || 0;
const startTime = performance.now();
function step(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// 使用 ease-out 缓动
const eased = 1 - Math.pow(1 - progress, 3);
const currentX = startX + (targetX - startX) * eased;
// 只使用合成友好的属性
element.style.transform = `translate3d(${currentX}px, 0, 0)`;
if (progress < 1) {
requestAnimationFrame(step);
}
}
requestAnimationFrame(step);
}
使用 CSS 动画替代 JavaScript 动画
/* CSS 动画在合成器线程运行,不阻塞主线程 */
.smooth-slide {
transform: translateX(0);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.smooth-slide.active {
transform: translateX(200px);
}
/* 使用 will-change 提前优化 */
.animated-card {
will-change: transform, opacity;
transition: transform 0.3s, opacity 0.3s;
}
.animated-card:hover {
transform: scale(1.05) translateY(-5px);
opacity: 0.9;
}
使用 Web Animations API(WAAPI)
// WAAPI 提供 JavaScript 控制能力,同时享受浏览器优化
const animation = element.animate([
{ transform: 'translateX(0)', opacity: 0 },
{ transform: 'translateX(200px)', opacity: 1 }
], {
duration: 300,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fill: 'forwards',
});
// 控制动画
animation.pause();
animation.play();
animation.reverse();
animation.cancel();
// 监听完成
animation.onfinish = () => {
element.style.willChange = 'auto';
};
WAAPI 的优势在于动画在浏览器的动画引擎中执行,可以:
- 自动与 VSync 同步
- 在合成器线程运行(对于 transform/opacity)
- 支持硬件加速的插值
- 自动处理动画的暂停/恢复
拖拽交互的输入响应优化
// 高响应性拖拽实现
class SmoothDraggable {
constructor(element) {
this.element = element;
this.position = { x: 0, y: 0 };
this.velocity = { x: 0, y: 0 };
this.isDragging = false;
this.lastTime = 0;
// 使用 Pointer Events 统一鼠标和触摸输入
element.addEventListener('pointerdown', this.onPointerDown.bind(this));
}
onPointerDown(e) {
this.isDragging = true;
this.lastX = e.clientX;
this.lastY = e.clientY;
this.lastTime = performance.now();
// 捕获指针,确保即使移出元素也能接收事件
this.element.setPointerCapture(e.pointerId);
this.element.addEventListener('pointermove', this.onPointerMove.bind(this));
this.element.addEventListener('pointerup', this.onPointerUp.bind(this));
// 立即响应:取消当前动画,直接跟随指针
this.cancelAnimation();
}
onPointerMove(e) {
if (!this.isDragging) return;
const now = performance.now();
const dt = now - this.lastTime;
// 直接更新位置(最低延迟)
this.position.x += e.clientX - this.lastX;
this.position.y += e.clientY - this.lastY;
// 计算速度用于惯性
if (dt > 0) {
this.velocity.x = (e.clientX - this.lastX) / dt;
this.velocity.y = (e.clientY - this.lastY) / dt;
}
// 使用 rAF 批量更新渲染
if (!this.rafId) {
this.rafId = requestAnimationFrame(() => this.render());
}
this.lastX = e.clientX;
this.lastY = e.clientY;
this.lastTime = now;
}
render() {
this.rafId = null;
// 只使用 transform,避免布局
this.element.style.transform =
`translate3d(${this.position.x}px, ${this.position.y}px, 0)`;
}
onPointerUp() {
this.isDragging = false;
this.element.releasePointerCapture(e.pointerId);
// 启动惯性动画
this.startInertia();
}
startInertia() {
const decay = 0.95;
const minVelocity = 0.1;
const step = () => {
this.velocity.x *= decay;
this.velocity.y *= decay;
this.position.x += this.velocity.x * 16; // 假设 60fps
this.position.y += this.velocity.y * 16;
this.render();
if (Math.abs(this.velocity.x) > minVelocity ||
Math.abs(this.velocity.y) > minVelocity) {
this.rafId = requestAnimationFrame(step);
}
};
this.rafId = requestAnimationFrame(step);
}
cancelAnimation() {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
}
使用 CSS content-visibility 优化滚动动画
/* 大量卡片滚动时的性能优化 */
.card-list {
/* 确保每个卡片是独立的渲染层 */
contain: layout paint;
}
.card {
content-visibility: auto;
contain-intrinsic-size: auto 200px;
/* 视口外的卡片跳过布局和绘制 */
}
.card.visible {
/* 进入视口的卡片正常渲染 */
content-visibility: visible;
}
实践
案例:120Hz 下的地图缩放动画优化
某地图应用在 120Hz iPad Pro 上缩放时帧率不稳定,从 120fps 骤降至 40fps。
诊断:
- Performance 面板显示每帧 JavaScript 执行 6ms,Style 3ms,Paint 2ms,合计 11ms
- 11ms > 8.33ms 预算,导致每隔一帧才能显示,实际帧率约 60fps
- Paint 阶段耗时异常,因为缩放时大量地图标记(Marker)的
box-shadow被重绘
优化方案:
| 优化项 | 措施 | 效果 |
|--------|------|------|
| 移除昂贵样式 | 缩放期间临时移除 box-shadow 和 border-radius | Paint 2ms -> 0.5ms |
| 标记批量更新 | 使用 transform: scale() 替代 width/height | 消除 Layout |
| 视口裁剪 | 只渲染视口内及边缘 20% 的标记 | JS 6ms -> 2ms |
| 降级策略 | 快速缩放时切换为低分辨率瓦片 | Style 3ms -> 1ms |
结果: 每帧总时间从 11ms 降至 3.5ms,稳定达到 120fps。
动画性能决策矩阵
| 场景 | 推荐方案 | 原因 |
|------|----------|------|
| 简单 CSS 过渡 | CSS transition | 合成器线程运行,零主线程开销 |
| 复杂序列动画 | WAAPI | JavaScript 控制 + 浏览器优化 |
| 物理/游戏动画 | requestAnimationFrame + Canvas/WebGL | 每帧完全控制 |
| 滚动联动动画 | CSS animation-timeline / ScrollTimeline | 浏览器原生优化滚动同步 |
| 输入跟随(拖拽) | pointermove + rAF 批量渲染 | 最低输入延迟 |
| 大量元素动画 | CSS content-visibility + 虚拟化 | 减少渲染元素数量 |
陷阱
| 陷阱 | 描述 | 后果 |
|------|------|------|
| 使用 setInterval 做动画 | setInterval 不与 VSync 同步,且可能堆积回调 | 动画不流畅,帧率不稳定,主线程阻塞 |
| 在 rAF 中修改布局属性 | 每帧修改 width/height/top/left | 每帧触发回流,无法达到 60fps |
| 忽略 120Hz 的预算减半 | 在 120Hz 设备上使用 60Hz 的优化策略 | 帧时间超预算,实际帧率远低于 120fps |
| 过度绘制(Overdraw) | 多个半透明层叠加,GPU 每像素多次绘制 | GPU 负载过高,发热和掉帧 |
| 动画结束后不清理 | rAF 循环在动画结束后继续运行 | 不必要的 CPU/GPU 消耗,电池耗尽 |
| 触摸事件不 preventDefault | 浏览器默认滚动与自定义动画冲突 | 动画卡顿,用户体验差 |
| 忽略 prefers-reduced-motion | 对有前庭功能障碍的用户播放剧烈动画 | 可访问性问题,用户不适 |
120Hz 动画的降级策略
在 120Hz 设备上,若动画逻辑无法在 8.33ms 内完成,不应强行追求 120fps。更好的策略是:1) 检测刷新率,根据预算调整动画复杂度;2) 快速交互时简化渲染(如隐藏阴影、降低细节);3) 使用 CSS transition 和 WAAPI 让浏览器自动优化;4) 对于无法优化的场景,稳定 60fps 比波动 120fps 体验更好。可以使用 matchMedia('(update: fast)') 检测设备是否支持高刷新率。