1. 浏览器渲染流水线概览
浏览器每帧的渲染遵循以下流水线(Pixel Pipeline):
JavaScript → Style → Layout → Paint → Composite
| 阶段 | 说明 |
|---|---|
| JavaScript | 执行 JS,触发视觉变化(DOM 操作、样式修改等) |
| Style | 计算哪些 CSS 规则应用到哪些元素(样式重算) |
| Layout | 计算元素的几何信息(位置、尺寸) |
| Paint | 填充像素:文字、颜色、图片、边框、阴影等 |
| Composite | 将多个绘制层合并送给屏幕 |
关键原则 :跳过越多阶段,性能越好。使用
transform/opacity只触发 Composite,是最高效的动画属性。
2. requestAnimationFrame 深度解析
2.1 基本概念
requestAnimationFrame(简称 rAF)是浏览器提供的 API,用于在下一帧绘制之前执行回调,与屏幕刷新率同步(通常 60fps = 每帧约 16.67ms)。
与 setTimeout/setInterval 的核心区别:
scss
setTimeout(fn, 16) ❌ 不精确,受事件循环延迟影响,可能跳帧或过早触发
requestAnimationFrame(fn) ✅ 由浏览器调度,精确对齐屏幕刷新时机
2.2 每帧的生命周期(Frame Lifecycle)
一帧内浏览器的执行顺序(规范定义):
arduino
┌──────────────────────────────────────────────────────┐
│ 一帧 (~16.67ms) │
│ │
│ 1. 处理用户输入事件(input, click, keydown...) │
│ 2. 执行 requestAnimationFrame 回调 ← 你的动画代码 │
│ 3. 执行 ResizeObserver 回调 │
│ 4. 执行 IntersectionObserver 回调 │
│ 5. Style(样式计算) │
│ 6. Layout(布局) │
│ 7. Paint(绘制) │
│ 8. Composite(合成) │
│ 9. 空闲期:requestIdleCallback 回调(如果有空闲) │
└──────────────────────────────────────────────────────┘
rAF 回调在 Style 之前执行,因此在回调中修改样式,当帧内立即生效,避免了额外的强制同步布局。
2.3 API 详解
基本用法
scss
// 请求一帧回调,返回一个 id
const id = requestAnimationFrame(callback);
// callback 接收一个 DOMHighResTimeStamp 参数(高精度时间戳)
function callback(timestamp) {
// timestamp: 从页面加载开始计算的毫秒数(精度可达微秒级)
console.log(timestamp); // e.g. 1523.456
}
取消回调
scss
const id = requestAnimationFrame(callback);
cancelAnimationFrame(id); // 取消尚未执行的回调
动画循环模式(Animation Loop)
ini
let animationId = null;
let startTime = null;
const duration = 1000; // 动画持续 1 秒
function animate(timestamp) {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
const progress = Math.min(elapsed / duration, 1); // 0 → 1
// 应用动画
element.style.transform = `translateX(${progress * 300}px)`;
if (progress < 1) {
// 继续下一帧
animationId = requestAnimationFrame(animate);
} else {
// 动画结束
animationId = null;
}
}
// 启动
animationId = requestAnimationFrame(animate);
// 停止
function stop() {
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
}
使用缓动函数(Easing)
ini
// 缓动函数集合
const easing = {
linear: t => t,
easeInOut: t => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
easeOut: t => t * (2 - t),
easeIn: t => t * t,
bounce: t => {
if (t < 1 / 2.75) return 7.5625 * t * t;
if (t < 2 / 2.75) return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
if (t < 2.5 / 2.75) return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
}
};
function animateWithEasing(timestamp) {
if (!startTime) startTime = timestamp;
const rawProgress = Math.min((timestamp - startTime) / duration, 1);
const easedProgress = easing.easeInOut(rawProgress);
element.style.opacity = easedProgress;
if (rawProgress < 1) requestAnimationFrame(animateWithEasing);
}
requestAnimationFrame(animateWithEasing);
帧率控制(节流到指定 FPS)
ini
// 将动画限制为 30fps
const targetFPS = 30;
const frameInterval = 1000 / targetFPS;
let lastFrameTime = 0;
function throttledAnimate(timestamp) {
requestAnimationFrame(throttledAnimate);
if (timestamp - lastFrameTime < frameInterval) return; // 跳过此帧
lastFrameTime = timestamp;
// 执行动画逻辑...
}
requestAnimationFrame(throttledAnimate);
2.4 最佳实践
✅ 使用 transform 和 opacity 做动画
ini
// ✅ 好 - 只触发 Composite,不触发 Layout
element.style.transform = 'translateX(100px)';
element.style.opacity = '0.5';
// ❌ 差 - 触发 Layout(导致其他元素重排)
element.style.left = '100px';
element.style.width = '200px';
✅ 批量读写 DOM,避免强制同步布局(Layout Thrashing)
ini
// ❌ 强制同步布局(每次写后立即读,迫使浏览器重新 Layout)
function badLoop() {
for (let i = 0; i < elements.length; i++) {
elements[i].style.width = elements[i].offsetWidth + 10 + 'px'; // 读触发 Layout,写再触发
}
}
// ✅ 先批量读,再批量写
function goodLoop() {
// 先全部读
const widths = elements.map(el => el.offsetWidth);
// 再全部写(在 rAF 中)
requestAnimationFrame(() => {
elements.forEach((el, i) => {
el.style.width = widths[i] + 10 + 'px';
});
});
}
✅ 页面不可见时暂停动画
scss
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stop(); // 页面进入后台,停止动画(浏览器也会自动暂停 rAF)
} else {
start(); // 页面重新可见,恢复动画
}
});
浏览器在页面不可见时会自动暂停 rAF 回调,但手动管理更安全,可避免恢复时状态错乱。
✅ 封装成可复用的动画工具
ini
/**
* 通用动画函数
* @param {number} duration - 持续时间(毫秒)
* @param {Function} onUpdate - 每帧回调,接收 progress(0~1)
* @param {Function} easingFn - 缓动函数
* @returns {{ cancel: Function }} - 返回取消函数
*/
function animate({ duration, onUpdate, easingFn = t => t, onComplete }) {
let startTime = null;
let id = null;
function frame(timestamp) {
if (!startTime) startTime = timestamp;
const raw = Math.min((timestamp - startTime) / duration, 1);
onUpdate(easingFn(raw));
if (raw < 1) {
id = requestAnimationFrame(frame);
} else {
onComplete?.();
}
}
id = requestAnimationFrame(frame);
return { cancel: () => cancelAnimationFrame(id) };
}
// 使用示例
const { cancel } = animate({
duration: 500,
easingFn: t => t * (2 - t),
onUpdate: progress => {
box.style.transform = `translateY(${(1 - progress) * -50}px)`;
box.style.opacity = progress;
},
onComplete: () => console.log('动画完成'),
});
2.5 常见陷阱
| 陷阱 | 问题 | 解决方案 |
|---|---|---|
| 忘记取消 rAF | 组件卸载后仍在执行,内存泄漏 | 在清理逻辑中调用 cancelAnimationFrame |
| 在 rAF 外读取 DOM | 触发强制同步布局 | 在 rAF 回调内统一读写 |
每帧都调用 new |
垃圾回收压力大,导致卡顿 | 复用对象,避免在热路径分配内存 |
| 在 rAF 中执行大量计算 | 超出 16ms 帧预算,掉帧 | 拆分任务,耗时操作用 requestIdleCallback |
| 多个独立 rAF 循环 | 难以协调,浪费 | 合并到单一主循环中 |
3. DOMContentLoaded 深度解析
3.1 基本概念
DOMContentLoaded 事件在 HTML 文档被完全解析、DOM 树构建完毕后触发,无需等待样式表、图片、子框架等外部资源加载完成。
ini
document.addEventListener('DOMContentLoaded', () => {
// DOM 已就绪,可以安全操作元素
const title = document.getElementById('title');
title.textContent = '页面已准备好!';
});
3.2 与 load 事件的区别
css
HTML 开始解析
│
▼
DOM 树构建完毕 ──► 🔥 DOMContentLoaded 触发
│
▼ (继续加载外部资源:CSS, 图片, 字体, iframe...)
│
▼
所有资源加载完毕 ──► 🔥 load 触发(window.onload)
| 事件 | 触发时机 | 适用场景 |
|---|---|---|
DOMContentLoaded |
DOM 解析完成 | 操作 DOM、绑定事件、初始化 JS 逻辑 |
load |
所有资源(图片等)加载完毕 | 需要获取图片尺寸、完全渲染后的操作 |
readystatechange |
loading → interactive → complete |
更细粒度的加载状态监听 |
3.3 触发时机与阻塞因素
HTML 解析流程
xml
下载 HTML
│
▼
解析 HTML 逐行构建 DOM
│
├──► 遇到 <link rel="stylesheet">
│ │
│ ▼ 下载并解析 CSS(CSSOM 构建)
│ │ ⚠️ 如果 CSS 后面有 <script>,脚本等待 CSSOM 完成(阻塞)
│
├──► 遇到 <script>(无 async/defer)
│ │
│ ▼ 暂停 HTML 解析,下载并执行脚本
│ (⚠️ 这是传统的"渲染阻塞"根源)
│
├──► 遇到 <script async>
│ ▼ 异步下载,下载完立即执行(不保证顺序)
│
├──► 遇到 <script defer>
│ ▼ 异步下载,DOM 解析完成后、DOMContentLoaded 前执行
│
▼
DOM 解析完成
│
├──► 执行所有 defer 脚本(按顺序)
│
▼
🔥 DOMContentLoaded 触发
各类 script 加载方式对比
xml
<!-- ❌ 传统方式:阻塞解析,性能最差(除非有意为之) -->
<script src="app.js"></script>
<!-- ✅ defer:异步下载,DOM 解析完才执行,保证顺序,推荐 -->
<script src="app.js" defer></script>
<!-- ⚠️ async:异步下载,下载完立即执行,不保证顺序,适合独立脚本 -->
<script src="analytics.js" async></script>
<!-- ✅ 模块脚本默认 defer 行为 -->
<script type="module" src="app.js"></script>
最佳实践 :对大多数脚本使用
defer;独立的第三方统计脚本(如 GA)使用async。
3.4 最佳实践
✅ 基本用法:安全操作 DOM
javascript
// 方式一:标准事件监听(推荐)
document.addEventListener('DOMContentLoaded', init);
function init() {
// 此时可安全操作任何 DOM 元素
document.querySelectorAll('.btn').forEach(btn => {
btn.addEventListener('click', handleClick);
});
}
// 方式二:检查当前状态(适合脚本可能在 DOM 就绪后才执行的情况)
function domReady(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn);
} else {
// 已经解析完毕(脚本是 defer/async 或动态插入的)
fn();
}
}
domReady(() => {
console.log('DOM 已就绪');
});
✅ readyState 完整状态机
javascript
console.log(document.readyState);
// "loading" - HTML 正在解析
// "interactive" - DOM 解析完成(等同于 DOMContentLoaded 时机)
// "complete" - 所有资源加载完毕(等同于 load 时机)
document.addEventListener('readystatechange', () => {
if (document.readyState === 'interactive') {
console.log('DOM 就绪(interactive)');
}
if (document.readyState === 'complete') {
console.log('所有资源加载完毕(complete)');
}
});
✅ 结合 Promise 封装
javascript
// 将 DOMContentLoaded 封装为 Promise,方便 async/await 使用
function waitForDOM() {
return new Promise(resolve => {
if (document.readyState !== 'loading') {
resolve();
} else {
document.addEventListener('DOMContentLoaded', resolve, { once: true });
}
});
}
// 使用示例
async function main() {
await waitForDOM();
console.log('DOM 就绪,开始初始化...');
initApp();
}
main();
✅ 在现代模块化项目中的位置
xml
<!-- 现代推荐:脚本放 <head> 并加 defer,无需手动监听 DOMContentLoaded -->
<head>
<script type="module" src="main.js" defer></script>
</head>
<body>
<!-- 内容 -->
</body>
javascript
// main.js(defer 脚本在 DOM 就绪后执行,无需监听事件)
// 直接操作 DOM 即可
document.getElementById('app').textContent = 'Hello World';
4. 两者协同使用的场景
场景一:DOM 就绪后立即启动动画
javascript
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('hero');
// DOM 就绪后,用 rAF 启动入场动画
requestAnimationFrame(() => {
// 确保浏览器已完成初始渲染,再添加动画类
el.classList.add('animate-in');
});
});
场景二:首帧精确测量后再动画
javascript
document.addEventListener('DOMContentLoaded', () => {
const box = document.querySelector('.box');
// 第一个 rAF:让浏览器先完成初始布局
requestAnimationFrame(() => {
// 第二个 rAF:此时读取布局数据是安全且准确的
requestAnimationFrame(() => {
const rect = box.getBoundingClientRect();
console.log('元素位置:', rect);
startAnimation(rect);
});
});
});
双重 rAF 技巧:第一个 rAF 确保样式被应用,第二个 rAF 确保布局已经完成,常用于触发 CSS Transition。
场景三:懒加载 + 平滑动画
javascript
document.addEventListener('DOMContentLoaded', () => {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 元素进入视口时,用 rAF 开始动画
requestAnimationFrame(() => {
entry.target.classList.add('visible');
});
observer.unobserve(entry.target);
}
});
});
document.querySelectorAll('.fade-in').forEach(el => observer.observe(el));
});
场景四:页面加载进度条
ini
// 利用 readystatechange + rAF 实现加载进度条
const progressBar = document.createElement('div');
progressBar.id = 'progress-bar';
document.body.prepend(progressBar);
let progress = 0;
function updateProgress(target) {
const step = () => {
if (progress < target) {
progress = Math.min(progress + 2, target);
progressBar.style.width = progress + '%';
if (progress < target) requestAnimationFrame(step);
}
};
requestAnimationFrame(step);
}
document.addEventListener('readystatechange', () => {
if (document.readyState === 'interactive') updateProgress(70);
if (document.readyState === 'complete') updateProgress(100);
});
5. 性能优化总结
rAF 性能清单
- 使用
transform和opacity而非触发布局的属性 - 在回调内批量读取,批量写入,避免交错读写
- 使用
timestamp参数计算进度,而非帧计数(帧率可能变化) - 组件卸载时调用
cancelAnimationFrame - 监听
visibilitychange在后台暂停动画 - 耗时逻辑移到 Web Worker,rAF 只做渲染
- 开启
will-change: transform提示浏览器提升图层(慎用,有内存成本)
DOMContentLoaded 性能清单
- 主脚本使用
defer属性,避免阻塞解析 - 第三方独立脚本(统计、广告)使用
async - 内联关键 CSS,外部非关键 CSS 异步加载
- 避免在
<head>中放置无async/defer的脚本 - 检查
readyState以兼容脚本执行时机不确定的情况 - 使用
{ once: true }选项自动移除一次性事件监听器
csharp
// ✅ 好习惯:once 选项,避免手动 removeEventListener
document.addEventListener('DOMContentLoaded', init, { once: true });