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树构建。别小看这两步,很多卡顿的根子就在这里。
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字符串变成一棵节点树。具体分几步?
- 字节流 → 字符:浏览器拿到HTML文件的原始字节,按编码(比如UTF-8)转成字符。
- 字符 → 令牌(Token):通过词法分析,把字符流拆成一个个令牌。比如
<div>是一个开始标签令牌,</div>是结束标签令牌。 - 令牌 → 节点(Node):每个令牌对应一个DOM节点。比如
<div>令牌生成一个HTMLDivElement节点。 - 节点 → 树(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树构建——卡顿的传导链
现在把这两步串起来看,卡顿是怎么传导的:
- 用户触发一个操作(比如点击按钮)。
- 浏览器执行对应的JS事件处理函数。
- 如果这个函数执行时间超过50ms,用户就会感觉到“点击没反应”。
- 如果这个函数还通过
innerHTML或document.createElement修改了DOM,浏览器需要重新解析HTML片段或创建新节点。 - DOM树更新后,触发后续的样式计算、布局、绘制……
- 如果整个流程超过100ms,用户就会明显感觉到卡顿。
说白了,JS执行是卡顿的源头,DOM树构建是卡顿的放大器。JS写得烂,DOM操作又多又频繁,卡顿就来了。
💡 我个人的调试习惯:遇到卡顿问题,先打开Chrome DevTools的Performance面板,录一段操作。看“Main”线程里有没有超过50ms的黄色长条(长任务)。如果有,点进去看是哪个函数在耗时。很多时候,问题就出在JS执行阶段。
2.5 本章小结
这一章我们只讲了渲染流水线的CPU阶段的前两步:
- JS执行:单线程、长任务、阻塞DOM解析。优化方向是拆解长任务、用
defer/async控制脚本加载。 - DOM树构建:从字节到令牌到节点到树。优化方向是精简HTML、减少嵌套、避免不必要的DOM操作。
这两步是后续所有渲染工作的基础。基础没打好,后面再怎么优化也是事倍功半。