Web 帧渲染与 DOM 准备

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 loadinginteractivecomplete 更细粒度的加载状态监听

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 性能清单

  • 使用 transformopacity 而非触发布局的属性
  • 在回调内批量读取,批量写入,避免交错读写
  • 使用 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 });

参考规范

相关推荐
Wect1 小时前
React 更新触发原理详解
前端·react.js·面试
光影少年1 小时前
React Hooks的理解?常用的有哪些?
前端·react.js·掘金·金石计划
大鸡爪1 小时前
Vue3 组件库实战(七):从本地到 NPM:版本管理与自动化发布指南(下)
前端·vue.js
幸福摩天轮1 小时前
记录commonjs的一道面试题
前端
qq_406176141 小时前
详解Vue中的计算属性(computed)和观察属性(watch)
开发语言·前端·javascript·vue.js·前端框架
kyriewen1 小时前
Grid 网格布局:二维世界的布局王者,像下围棋一样掌控页面
前端·css·html
顽固_倔强1 小时前
Vue2 与 Vue3 对比:从 Options API 到 Composition API 的演进
前端·面试
巫山老妖1 小时前
用 OpenClaw 每日自动发布 AI 速递:微信公众号 + 小红书全流程实操
前端
兆子龙2 小时前
V8 与 JavaScript 执行:从字节码、Ignition 到 TurboFan JIT 的完整管线
前端