HTML/CSS 解析与 DOM/CSSOM 构建

本节目标

解析 HTML → DOM:渲染进程的主线程逐字节读取 HTML,先做词法分析切成 token(标签、文本、属性),再按层级组装成节点树(DOM)。解析是增量的——边解析边可用,所以首屏能「逐步显示」,而不是等全部下载完。

解析 CSS → CSSOM:CSS 也会被构建成树(CSSOM)。CSS 是阻塞渲染的:CSSOM 没建完,页面不会做首次绘制(否则会出现「样式闪烁 FOUC」)。

三类阻塞资源:

① 普通 <script>  :阻塞 HTML 解析 + 阻塞渲染——必须下载并执行完,才继续往下解析
② <link> 的 CSS  :阻塞「首次绘制」(不阻塞解析,但没它就不画)
③ 图片 / iframe  :既不阻塞解析,也不阻塞渲染(加载完再显示)

script 的三种加载方式(这是工程里天天用的):

<script src="a.js"></script>               <!-- 同步:下载+执行完才继续解析 -->
<script defer src="a.js"></script>         <!-- 下载不阻塞解析,HTML 解析完才按序执行 -->
<script async src="a.js"></script>         <!-- 下载不阻塞解析,下完立刻执行(顺序不定) -->

预加载提示(提前告诉浏览器去拿关键资源):

<link rel="preload" as="style" href="app.css">              <!-- 提前下载关键 CSS -->
<link rel="preconnect" href="https://cdn.example.com">      <!-- 提前完成 DNS/TCP/TLS 握手 -->

下面这个 HTML 文件,直接在浏览器打开就能看到「同步脚本阻塞解析」与「defer 按序执行」的差异;而 async 的「谁先下载完谁先执行」需要真实网络延迟才能体现,所以用紧接其后的 brw-l4-server.js 演示:

<!-- 运行环境:任意现代浏览器 -->
<!-- 步骤:把下面内容存为 brw-l4.html,用浏览器打开,按 F12 看 Console 输出 -->
<!-- 你会看到:同步脚本立刻执行;defer 的脚本按 1→2→3→4 顺序、且都排在 HTML 解析完后才执行 -->

<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <title>defer vs 同步脚本演示</title>
  <!-- 同步脚本:会阻塞解析,放这里最糟;仅作对比 -->
  <script>
    console.log('[同步] 这一行在解析到它时立刻执行')
  </script>

  <!-- 三个 defer:下载不阻塞解析,HTML 解析完后按出现顺序 1→2→3 执行 -->
  <script defer src="data:text/javascript,console.log('[defer] 脚本 1')"></script>
  <script defer src="data:text/javascript,console.log('[defer] 脚本 2')"></script>
  <script defer src="data:text/javascript,console.log('[defer] 脚本 3')"></script>
</head>
<body>
  <h1>打开控制台看脚本执行顺序</h1>
  <p>defer 脚本永远按 1→2→3→4 顺序执行,且都排在 HTML 解析完之后(data: 链接没有网络延迟,但不影响 defer 的「按序」特性)。</p>
  <script defer src="data:text/javascript,console.log('[defer] 脚本 4(始终最后)')"></script>
  <p>想看 <b>async</b> 的「谁先下载完谁先执行」乱序,请用下面的 brw-l4-server.js(data: 链接无网络延迟,无法体现乱序)。</p>
</body>
</html>
// 运行环境:Node.js 14+(仅用内置 http)
// 步骤:node brw-l4-server.js  → 浏览器打开 http://localhost:3000/,按 F12 看控制台,多刷新几次
// 说明:async 脚本「谁先下载完谁先执行」。这里给三个 async 脚本设置不同下载延迟,
//       你会看到「先下完的 b.js 先执行」,且每次刷新顺序可能不同(延迟有抖动)。

const http = require('node:http')

// 三个 async 脚本,故意设不同下载延迟:b(80ms) 最快,c(180ms) 次之,a(300ms) 最慢
const scripts = {
  '/a.js': { delay: 300, code: 'console.log("[async] 脚本 A(最慢,最后执行)")' },
  '/b.js': { delay: 80,  code: 'console.log("[async] 脚本 B(最快,先执行)")' },
  '/c.js': { delay: 180, code: 'console.log("[async] 脚本 C(第二快)")' },
}

http.createServer((req, res) => {
  if (req.url === '/') {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
    res.end(`<!DOCTYPE html><html><head><meta charset="utf-8">
      <script async src="/a.js"></script>
      <script async src="/b.js"></script>
      <script async src="/c.js"></script>
    </head><body><h3>打开控制台看 async 脚本的执行顺序(刷新几次)</h3></body></html>`)
    return
  }
  const s = scripts[req.url]
  if (s) {
    setTimeout(() => { res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8' }); res.end(s.code) }, s.delay)
    return
  }
  res.writeHead(404); res.end()
}).listen(3000, () => console.log('http://localhost:3000  (多刷新几次看 async 顺序变化)'))

名词解释

DOM(文档对象模型,Document Object Model):HTML 被解析后形成的「树形结构」,每个标签是一个节点(element node),文字是文本节点。它提供了一组「方法」让 JS 操作页面:document.getElementById(id)(按 id 找节点)、element.appendChild(node)(加子节点)、element.remove()(删自己)、element.addEventListener(type, fn)(监听事件)。你可以把它想成「页面的骨架,JS 能伸手进去改」。

CSSOM(CSS 对象模型):CSS 解析后形成的样式树,记录了「每个元素最终该长什么样」。JS 也能读写它(window.getComputedStyle(el) 拿到计算后的样式)。它和 DOM 是两棵树,渲染时会「合并」成真正的画面。

FOUC(无样式内容闪烁,Flash of Unstyled Content):CSS 还没加载完,浏览器就先用默认样式画了页面,等 CSS 到了又重画,用户看到「闪一下」。这就是 CSS 要「阻塞首次绘制」的原因——宁可晚画一帧,也不让用户看到丑样子。

课后练习

练习 1:下面这段代码,用户最早可能在什么时候看到 <h1> 的文字?

<head>
  <link rel="stylesheet" href="slow.css">   <!-- 这个 CSS 要 3 秒才下载完 -->
</head>
<body>
  <h1>你好</h1>
  <script src="big.js"></script>            <!-- 这个 JS 要 2 秒 -->
</body>

答案:要等 slow.css 下载并构建完 CSSOM 后,浏览器才会做首次绘制——所以 <h1>你好</h1> 最早也要等约 3 秒(CSS 阻塞渲染)。big.js 还会再阻塞解析 2 秒,但那影响的是 <script> 之后的内容,这里 <h1> 在 script 之前,所以首绘只被 CSS 卡住。

练习 2:把上面那个 big.js 改成 <script defer src="big.js"></script>,对首屏显示 <h1> 有影响吗?为什么?

答案:几乎没影响(甚至可能更快看到)。因为 defer 的脚本不阻塞 HTML 解析,浏览器能顺利解析完 <body> 并配合已就绪的 CSSOM 做首次绘制;big.js 会在 HTML 解析完成后才执行,不再挡着首屏。这正是「脚本放 defer/尾部」能提速的根本原因。

总结

这一节我想纠正一个常见误解:很多人以为「页面慢」就是 JS 写得烂。其实在解析阶段,CSS 和脚本的摆放位置往往比 JS 本身的性能更影响首屏。CSS 阻塞绘制、普通脚本阻塞解析,这两个事实决定了「关键 CSS 内联、脚本用 defer、资源预加载」这类看似琐碎的工程规范,本质是给渲染「让路」。我特别建议你亲手打开 brw-l4.html 看 defer 永远按序、再跑 brw-l4-server.js 看 async 乱序——当你亲眼确认这些时,这个知识点就从「我背过」变成「我见过」了。后面讲渲染管线时你会明白,所有这些「阻塞」最终都汇聚成一件事:主线程忙不忙。