CSS 动画的性能极限测试:同时驱动 1000 个元素的方法论
一、写了一个 Demo:屏幕上 1000 个彩色圆点各自以不同频率缩放跳动——FPS 从 60 掉到 8
动画的构建过程很有仪式感:用for循环生成 1000 个<div>,给每个<div>分配随机的animation-delay和animation-duration,让它们不完全同步,营造一种"有机呼吸感"。在 Chrome DevTools 里打开 FPS Meter,绿色的 60 稳稳的——前提是窗口大小 800×600 且没有别的 DOM。当把窗口拉到 2560×1440(日常外接显示器尺寸)、页面上还有 200 个文字节点时,绿线瞬间断崖式下坠到 8fps。
Performance 面板里,一帧的主线程耗时 187ms(16.67ms 的预算花掉了 11 倍)。热点分布:Recalculate Style 72ms、Layout 64ms、Paint 38ms、Composite 13ms。瓶颈不是 GPU 也不是 JavaScript,而是 1000 个元素的animation属性每帧都在触发 CSS 样式的重新计算——每个元素的transform值变化都在样式计算阶段被浏览器遍历一遍匹配树。堆成 1000 倍,72 毫秒就没了。要让 1000 个元素同时在屏幕上动而且 60fps,需要走一条"绕过样式计算"的路径。
二、为什么 1000 个 CSS Animation 比 100 个卡了不止 10 倍——非线性的瓶颈在样式计算
flowchart TD subgraph "单个 CSS Animation 的帧流程" A1["animation 帧触发"] --> B1["Recalculate Style<br/>样式计算"] B1 --> C1["Layout<br/>布局"] C1 --> D1["Paint<br/>绘制"] D1 --> E1["Composite<br/>合成"] end subgraph "1000 个 CSS Animation" A2["1000 个 animation<br/>同时触发帧"] --> B2["Recalculate Style<br/>≈ 72ms"] B2 --> C2["Layout<br/>≈ 64ms"] C2 --> D2["Paint<br/>≈ 38ms"] D2 --> E2["Composite<br/>≈ 13ms"] E2 --> F2["帧总耗时 ≈ 187ms<br/>FPS ≈ 5"] end subgraph "优化后:GPU 合成器驱动" A3["CSS @property 注册<br/>+ transform<br/>+ will-change"] --> B3["跳过 Style<br/>跳过 Layout<br/>跳过 Paint"] B3 --> C3["Composite only<br/>≈ 2-4ms"] C3 --> D3["帧总耗时 ≈ 4ms<br/>FPS ≈ 60"] end1000 个 CSS Animation 和 100 个不是线性关系——CSS 样式计算引擎在处理大量具有animation属性的元素时,需要为每个动画帧重新计算匹配规则、检查继承链、更新计算值。这个过程的时间复杂度接近 O(N × M),N 是动画元素数,M 是规则表的复杂度。当 N = 1000 时,即使 M 很小也是沉重的开销。
三、突破方案一:CSS Paint API(Houdini)——在合成器线程生成视觉
/** * 方案对比矩阵(1000 个元素 + 2560×1440 分辨率) * * ┌──────────────────────┬────────┬──────────┬────────────┐ * │ 方案 │ 平均FPS│ 帧耗时(ms)│ 主线程占用 │ * ├──────────────────────┼────────┼──────────┼────────────┤ * │ CSS animation×1000 │ 5 fps │ 187 │ 90% │ * │ CSS animation + GPU │ 35 fps │ 28 │ 45% │ * │ RequestAnimationFrame│ 42 fps │ 23 │ 38% │ * │ CSS Paint API │ 58 fps │ 16 │ 8% │ * │ Canvas + Offscreen │ 60 fps │ 12 │ 5% │ * └──────────────────────┴────────┴──────────┴────────────┘ */ /** * CSS Paint API 方案:在 Paint Worklet 中绘制 1000 个圆点 * * 核心优势: * - Paint Worklet 运行在独立的 Worklet 线程(不在主线程) * - 绘制到离屏位图后直接交给合成器——跳过主线程的 Style/Layout/Paint * - 每个元素的样式计算不再需要,主线程解放 */ // 1. 注册 Paint Worklet // paint-worker.js class BouncingDotsPainter { /** * CSS Paint API 要求实现 static get inputProperties() * 声明需要哪些 CSS 属性作为输入 */ static get inputProperties() { return [ '--dot-count', // 元素数量 '--dot-base-size', // 基础尺寸 '--dot-colors', // 颜色列表(逗号分隔的 HEX 值) '--animation-time', // 动画时间(秒),由 CSS animation 通过 @property 传入 ]; } /** * 核心绘制函数 * @param ctx Canvas 2D 上下文(注意:是 Worklet 的受限 Canvas) * @param size 绘制区域尺寸 * @param props inputProperties 对应的 CSS 属性值 Map */ paint( ctx: PaintRenderingContext2D, size: { width: number; height: number }, props: Map<string, CSSUnparsedValue>, ) { const count = parseInt(props.get('--dot-count')?.toString() || '100'); const baseSize = parseInt(props.get('--dot-base-size')?.toString() || '20'); const colorsStr = props.get('--dot-colors')?.toString() || '#3B82F6,#EF4444,#10B981'; const colors = colorsStr.split(','); // 从 CSS 变量获取动画时间(由 CSS animation 驱动每帧更新) const timeStr = props.get('--animation-time')?.toString() || '0'; const time = parseFloat(timeStr); // 绘制 1000 个圆点,每个根据 time + seed 计算独立的位置和大小 for (let i = 0; i < count; i++) { // 用素数种子生成伪随机(确保每次渲染一致性) const seed = (i * 2654435761) >>> 0; // Knuth 乘法哈希 // 位置:在画布内循环移动 const x = ((seed % size.width) + Math.sin(time * 1.3 + i * 0.7) * 50 + size.width) % size.width; const y = ((seed % size.height) + Math.cos(time * 0.9 + i * 1.1) * 50 + size.height) % size.height; // 大小:随 time 缩放 const scale = 0.5 + 0.5 * Math.sin(time * 2.0 + i * 0.3); const radius = (baseSize * scale) / 2; // 颜色:循环使用颜色数组 const color = colors[i % colors.length]; ctx.fillStyle = color; ctx.globalAlpha = 0.8; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill(); } } } // 注册到全局 Paint Worklet 注册表 // @ts-ignore registerPaint('bouncing-dots', BouncingDotsPainter); // 2. HTML 中使用(只需要 1 个 div 元素!) // <style> // @property --animation-time { // syntax: '<number>'; // initial-value: 0; // inherits: false; // } // // .dots-canvas { // width: 100%; // height: 100vh; // background: paint(bouncing-dots); // --dot-count: 1000; // --dot-base-size: 20; // --dot-colors: #3B82F6,#EF4444,#10B981,#F59E0B,#8B5CF6; // /* 只有一个 animation,不在元素层面重复 */ // animation: tick 10s linear infinite; // } // // @keyframes tick { // to { --animation-time: 10; } // } // </style> // <div class="dots-canvas"></div> // // 关键:页面上只有 1 个元素!1000 个圆点在 Worklet 里绘制。 // 主线程不需要计算 1000 个元素的样式→帧耗时从 187ms → 16ms。 // 3. 加载 Paint Worklet async function loadPaintWorklet() { if ('paintWorklet' in CSS) { // @ts-ignore await CSS.paintWorklet.addModule('/paint-worker.js'); } else { console.warn('CSS Paint API 不可用,降级到 Canvas 方案'); } }三(续)、突破方案二:OffscreenCanvas + Worker——纯 JavaScript 的 60fps 方案
/** * OffscreenCanvas + Web Worker 方案 * * 适用于 CSS Paint API 不支持的浏览器(Firefox 直到 2025 年仍不支持) * 或者需要更精细的交互控制(点击检测、拖拽等) */ // ─── 主线程代码 ────────────────────────────────────── class OffscreenDotsRenderer { private canvas: HTMLCanvasElement; private worker: Worker; private rafId: number = 0; constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; // 1. 将 Canvas 控制权转移给 Worker const offscreen = canvas.transferControlToOffscreen(); // 2. 创建 Worker(注意:vite/webpack 需要特殊配置导入 worker) this.worker = new Worker( new URL('./dots-worker.ts', import.meta.url), { type: 'module' }, ); // 3. 把 OffscreenCanvas 传给 Worker(零拷贝转移所有权) this.worker.postMessage( { type: 'init', canvas: offscreen, width: canvas.clientWidth * devicePixelRatio, height: canvas.clientHeight * devicePixelRatio, dpr: devicePixelRatio, }, [offscreen], // 转移所有权 ); // 4. 监听 resize 变化,通知 Worker 重设尺寸 const resizeObserver = new ResizeObserver(entries => { for (const entry of entries) { const { width, height } = entry.contentRect; this.worker.postMessage({ type: 'resize', width: width * devicePixelRatio, height: height * devicePixelRatio, }); } }); resizeObserver.observe(canvas); // 5. 主线程的渲染循环——只发消息,不画图 const loop = (timestamp: number) => { this.worker.postMessage({ type: 'frame', timestamp }); this.rafId = requestAnimationFrame(loop); }; this.rafId = requestAnimationFrame(loop); } destroy() { cancelAnimationFrame(this.rafId); this.worker.terminate(); } } // ─── Worker 线程代码 (dots-worker.ts) ───────────────── interface Dot { x: number; y: number; radius: number; color: string; phase: number; // 动画相位(0-2π),产生错落感 speed: number; // 动画速度(0.5-2.0) vx: number; // 每帧 x 速度 vy: number; // 每帧 y 速度 } let canvas: OffscreenCanvas | null = null; let ctx: OffscreenCanvasRenderingContext2D | null = null; let dots: Dot[] = []; let width = 0; let height = 0; let lastTimestamp = 0; self.onmessage = (e: MessageEvent) => { const { type } = e.data; switch (type) { case 'init': { canvas = e.data.canvas; width = e.data.width; height = e.data.height; ctx = canvas!.getContext('2d')!; // 初始化 1000 个圆点(只做一次) dots = createDots(1000, width, height); break; } case 'resize': { width = e.data.width; height = e.data.height; if (canvas) { canvas.width = width; canvas.height = height; } // resize 后重新初始化圆点(让它们适配新画布) dots = createDots(1000, width, height); break; } case 'frame': { const dt = (e.data.timestamp - lastTimestamp) / 1000; // 秒 lastTimestamp = e.data.timestamp; renderFrame(dt); break; } } }; /** * 一次性创建 1000 个圆点,每个有独立的动画参数 * 不在每帧中创建数组→零 GC 压力 */ function createDots(count: number, w: number, h: number): Dot[] { const colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6']; const result: Dot[] = []; for (let i = 0; i < count; i++) { result.push({ x: Math.random() * w, y: Math.random() * h, radius: 5 + Math.random() * 15, color: colors[Math.floor(Math.random() * colors.length)], phase: Math.random() * Math.PI * 2, speed: 0.5 + Math.random() * 1.5, vx: (Math.random() - 0.5) * 2, // 每帧移动速度 vy: (Math.random() - 0.5) * 2, }); } return result; } /** * 每帧渲染(在 Worker 线程中,不影响主线程) * * 核心优化: * 1. 所有计算在 Worker 线程→主线程解放 * 2. 用 for 循环 + 预计算(sin/cos 提前算好 phase*t) * 3. 透明度用 ctx.globalAlpha 做乘法(比修改 fillStyle 快) */ function renderFrame(dt: number) { if (!ctx || !canvas) return; // 清除画布(不清除会有残影,但清除也是昂贵的——考虑 dirty rect) ctx.clearRect(0, 0, width, height); const t = performance.now() / 1000; // 预计算当前帧的全局缩放(batch 相同颜色的 dot 一起画可进一步优化) for (let i = 0; i < dots.length; i++) { const dot = dots[i]; // 位置:循环移动 + 正弦波动 dot.x += dot.vx; dot.y += dot.vy; if (dot.x < 0) dot.x = width; if (dot.x > width) dot.x = 0; if (dot.y < 0) dot.y = height; if (dot.y > height) dot.y = 0; // 缩放:基于 phase + speed 的独立波浪 const scale = 0.6 + 0.4 * Math.sin(t * dot.speed + dot.phase); const r = dot.radius * scale; ctx.beginPath(); ctx.arc(dot.x, dot.y, r, 0, Math.PI * 2); ctx.fillStyle = dot.color; ctx.globalAlpha = 0.7 + 0.3 * Math.sin(t * 1.5 + dot.phase); ctx.fill(); } // 重置 alpha ctx.globalAlpha = 1; // ── 可选优化:如果 dot 数量达到 5000+,在 Worker 中做 dirty rect ── // 只重绘有变化的区域而非全画布 clearRect }四、边界分析:极限测试中的非预期性能瓶颈
4.1 屏幕分辨率的指数增长效应
2560×1440 面积是 1920×1080 的 1.78 倍——但 Canvas 渲染的性能损耗不是线性 1.78 倍而是接近 2.5-3 倍。原因:Canvas 内部使用 4 字节/像素的 RGBA Buffer,2560×1440 = 14.75MB 的帧缓冲。clearRect 在这个尺度的帧缓冲上每秒执行 60 次 = 885MB/s 的带宽。如果用户还开了 2x 缩放(常见 Mac),实际是 5120×2880 = 59MB 帧缓冲——这个带宽要求超过了部分集成显卡的吞吐能力。极限测试的硬件环境非常重要:在 M1 Pro 上 60fps 的方案,在 Intel UHD 620 可能只有 25fps。
4.2 CSS Paint API 的可用性困境
paintWorklet目前只在 Chromium 浏览器(Chrome/Edge/Opera)中可用,Safari 不支持,Firefox 也不支持。覆盖率约 75%。如果你用@supports (background: paint(id))做渐进增强,不支持的话你仍然需要一个降级方案(Canvas + Worker)——结果是你要维护两套实现。很多团队的理性选择是直接用 Canvas 方案跳过 Paint API。
4.3 Worker 线程的调试地狱
OffscreenCanvas 在 Worker 中运行时,Chrome DevTools 的 Performance 面板可以捕获 Worker 的帧时间线(2024 年已支持),但 Elements、Styles 面板完全看不到 Worker 中的内容。调试 1000 个圆点的绘制逻辑时,你不能像 inspect 一个 div 那样看它的计算样式。只能靠console.log和画 debug 色块——调试效率低了一个数量级。
4.4 交互能力的严重退化
paint(worklet)和 OffscreenCanvas 画出来的都是"像素"——不是 DOM 节点。800 个 div 每个都可以绑定 onClick、onHover、CSS 过渡;Canvas 里的 800 个圆点,交互需要手动做 hit-testing(判断鼠标坐标落在哪个圆形内)。如果你需要丰富的交互——hover 高亮、点击选中、右键菜单——用 Canvas 的开发成本远高于用 CSS + DOM。
适用边界:CSS Paint API 适合纯视觉装饰性元素的批量绘制(如背景粒子、水波纹、星空效果),不需要交互,不需要支持非 Chromium 浏览器。OffscreenCanvas + Worker 适合有复杂绘图计算但不交互或简单交互的场景,需要在所有现代浏览器运行。当需要富交互时,用 requestAnimationFrame + 分批渲染(每帧只计算可见区域的 50 个元素)比 Worker + Canvas 的性价比更高。
五、总结
1000 个 CSS animation 同时运行时瓶颈不在 GPU 也不在 JS——在样式重计算(Recalculate Style ≈ 72ms,占帧耗时 40%)| 帧流程瓶颈分布:Recalculate Style(72ms)> Layout(64ms)> Paint(38ms)> Composite(13ms) | CSS Paint API 在 Paint Worklet 线程绘制,用 1 个元素承载 1000 个"虚拟元素",主线程只发一个 @property 帧信号 | CSS @property 注册--animation-time为<number>类型→CSS animation 驱动属性变化→paint() 回调读取新值 | OffscreenCanvas + Worker:transferControlToOffscreen() 转移 Canvas 所有权→主线程只 postMessage 时间戳→Worker 中每帧绘制 1000 个圆点 | Worker 中预创建 Dot[] 数组(1000 个对象),每帧只更新位置和缩放→零 GC 压力 | 2560×1440 的 Canvas 帧缓冲 14.75MB,@2x = 59MB——clearRect 每秒 60 次 = 3.5GB/s 带宽,对集成显卡构成压力 | Paint API 只 Chromium 可用(≈75% 覆盖率),需要 fallback 到 Canvas 方案→维护两套代码 | Canvas 方案的交互能力退化:800 个"圆点"需要手动 hit-testing(数学距离判断),不像 div 能直接绑 onClick | 富交互场景(hover/click/drag)用 DOM + rAF + 分批渲染(每帧计算可见区域而不是全局)比 Canvas 更划算。