2、渲染流水线(上):CPU阶段——从JS执行到DOM树构建

聊到刷新卡顿,很多人第一反应就是“DOM操作太多了”或者“JS太慢了”。

但说实话,这种说法太笼统了。你想想看,一个页面从JS代码执行到最终像素显示在屏幕上,中间到底经历了什么?哪个环节出了问题才会卡?

我个人习惯把渲染流水线分成两大阶段:CPU阶段和GPU阶段。这一章我们先啃CPU阶段——从JS执行到DOM树构建这一段。

2.1 渲染流水线的全貌

先给你一张总图,心里有个底。

渲染流水线(简化版)

JS执行 → DOM树构建 → CSSOM树构建 → 渲染树构建 → 布局(Layout) → 绘制(Paint) → 合成(Composite)

其中,JS执行、DOM树构建、CSSOM树构建、渲染树构建、布局、绘制,主要发生在CPU上。合成阶段会交给GPU处理。

这一章我们只讲前两步:JS执行和DOM树构建。别小看这两步,很多卡顿的根子就在这里。

CPU阶段核心流程(本章范围) JS执行 解析/编译/执行 DOM树构建 HTML解析 → 节点树 CSSOM树构建 CSS解析 → 样式规则 渲染树构建 DOM + CSSOM 合并 📌 本章重点:JS执行 → DOM树构建 • JS执行会阻塞DOM解析(除非使用 async/defer) • DOM树构建是后续所有步骤的基础 • 这两步如果耗时过长,直接导致首次渲染延迟和交互卡顿

2.2 JS执行——卡顿的第一道关口

JS是单线程的,这个大家都知道。但单线程意味着什么?意味着一次只能干一件事。

浏览器的主线程既要跑你的JS代码,又要处理DOM解析、样式计算、布局、绘制。如果JS执行时间太长,后面所有事情都得等着。

2.2.1 长任务(Long Task)

我记得Chrome团队把超过50毫秒的任务定义为“长任务”。为什么是50ms?因为人眼能感知的卡顿阈值大概就在这个范围。

我在项目中遇到过这样一个案例:一个数据报表页面,每次点击筛选按钮都要卡顿1-2秒。一查Performance面板,发现有个JS函数执行了800多毫秒——里面有个三层嵌套循环在格式化数据。

💡 我的建议:写代码时养成一个习惯——任何可能超过50ms的操作,都要考虑拆解或异步化。比如用 requestAnimationFrame 或 setTimeout 把大任务切成小块。

2.2.2 JS执行对DOM解析的阻塞

这里有个关键点:浏览器遇到 <script> 标签时,会暂停DOM解析。

为什么?因为JS可能通过 document.write() 修改文档流,或者通过DOM API操作已经解析好的节点。浏览器不敢继续往下解析,怕你改乱了。

<!-- 阻塞示例 -->
<html>
<head>
  <script src="heavy.js"></script> <!-- 浏览器停在这里,等heavy.js下载并执行完 -->
</head>
<body>
  <div>这段内容要等heavy.js执行完才能被解析</div>
</body>
</html>

说白了,这就是“JS阻塞DOM构建”的经典场景。你想想看,如果 heavy.js 有500KB,下载加执行要1秒,那页面首屏就得等1秒才能开始渲染。

2.2.3 如何减少JS阻塞

方案 原理 适用场景
defer 脚本并行下载,DOM解析完成后按顺序执行 需要操作DOM的脚本
async 脚本并行下载,下载完立即执行(不保证顺序) 独立工具库、统计脚本
内联小脚本 减少网络请求,但注意不要写太长 关键渲染路径上的轻量逻辑

⚠️ 我曾经踩过的坑:有次为了优化首屏,我把所有脚本都加了 async。结果某个脚本依赖另一个脚本的全局变量,执行顺序乱了,页面直接报错。所以 async 只适合没有依赖关系的脚本,有依赖关系的用 defer 更安全。

2.3 DOM树构建——从字节到节点

JS执行完了(或者被延迟了),浏览器终于可以安心解析HTML了。

DOM树构建的过程,说白了就是把HTML字符串变成一棵节点树。具体分几步?

  1. 字节流 → 字符:浏览器拿到HTML文件的原始字节,按编码(比如UTF-8)转成字符。
  2. 字符 → 令牌(Token):通过词法分析,把字符流拆成一个个令牌。比如 <div> 是一个开始标签令牌,</div> 是结束标签令牌。
  3. 令牌 → 节点(Node):每个令牌对应一个DOM节点。比如 <div> 令牌生成一个 HTMLDivElement 节点。
  4. 节点 → 树(Tree):根据令牌的嵌套关系,把节点挂载到对应的父节点下,最终形成一棵树。

这个过程是边解析边构建的。浏览器不会等整个HTML都解析完再建树,而是解析一点、建一点。

2.3.1 一个简单的例子

<html>
  <head></head>
  <body>
    <h1>Hello</h1>
    <p>World</p>
  </body>
</html>

浏览器解析到 <html> 时,创建 document 节点下的 html 节点。接着解析到 <head>,创建 head 节点并挂到 html 下。然后解析到 <body>,创建 body 节点……以此类推。

最终生成的DOM树大概长这样:

document
 └── html
      ├── head
      └── body
           ├── h1
           │    └── "Hello" (文本节点)
           └── p
                └── "World" (文本节点)

2.3.2 DOM树构建的性能瓶颈

DOM树构建本身很快,但有几个场景会让它变慢:

  • HTML文件太大:比如一个页面有2万行HTML,解析器要处理大量令牌,时间自然变长。
  • 深层嵌套:<div><div><div>...</div></div></div> 这种嵌套会让树结构变深,遍历和挂载成本增加。
  • JS阻塞:前面说了,遇到 <script> 标签会暂停解析,DOM树构建也跟着停。
  • CSS阻塞(间接影响):虽然CSS不直接阻塞DOM解析,但浏览器遇到 <script> 时,如果前面的CSS还没加载完,会等CSS加载完再执行JS。这间接拖慢了DOM树构建。

🔍 一个冷知识:DOM树构建完成后,会触发 DOMContentLoaded 事件。如果你在Performance面板里看到这个事件触发很晚,说明DOM解析阶段出了问题——要么HTML太大,要么被JS/CSS阻塞了。

2.4 从JS执行到DOM树构建——卡顿的传导链

现在把这两步串起来看,卡顿是怎么传导的:

  1. 用户触发一个操作(比如点击按钮)。
  2. 浏览器执行对应的JS事件处理函数。
  3. 如果这个函数执行时间超过50ms,用户就会感觉到“点击没反应”。
  4. 如果这个函数还通过 innerHTML 或 document.createElement 修改了DOM,浏览器需要重新解析HTML片段或创建新节点。
  5. DOM树更新后,触发后续的样式计算、布局、绘制……
  6. 如果整个流程超过100ms,用户就会明显感觉到卡顿。

说白了,JS执行是卡顿的源头,DOM树构建是卡顿的放大器。JS写得烂,DOM操作又多又频繁,卡顿就来了。

💡 我个人的调试习惯:遇到卡顿问题,先打开Chrome DevTools的Performance面板,录一段操作。看“Main”线程里有没有超过50ms的黄色长条(长任务)。如果有,点进去看是哪个函数在耗时。很多时候,问题就出在JS执行阶段。

2.5 本章小结

这一章我们只讲了渲染流水线的CPU阶段的前两步:

  • JS执行:单线程、长任务、阻塞DOM解析。优化方向是拆解长任务、用 defer/async 控制脚本加载。
  • DOM树构建:从字节到令牌到节点到树。优化方向是精简HTML、减少嵌套、避免不必要的DOM操作。

这两步是后续所有渲染工作的基础。基础没打好,后面再怎么优化也是事倍功半。