一张栈的图,治好你面试答不出 script 阻塞的病

面试官问"为什么 <script> 会阻塞 DOM 解析",大部分人背的答案是"因为 JS 可能操作 DOM"。但这只是表面原因------真正的问题是:浏览器用栈在搭 DOM 树,JS 可能把栈拆了。 这篇文章从浏览器收到 HTML 字节流那一刻讲起,一步步推到"JS 为什么非得让解析器暂停",让你以后再也不用背这个答案。


一、从一个问题开始

打开任意网页,F12 控制台输入:

js 复制代码
const nav = performance.getEntriesByType("navigation")[0];
console.log(
  "DOM解析耗时:",
  (nav.domInteractive - nav.responseEnd).toFixed(1),
  "ms",
);

你会看到一个数字,比如 47.2ms。这 47.2 毫秒里,浏览器干了四件事:

字节 → 字符 → Token → 节点 → DOM 树

这是 DOM 解析的完整路径。理解了这四步,你就理解了为什么 JS 会打断它------因为 JS 可能从中间插一杠子,把正在搭的积木碰倒。

下面我们一步步来。


二、第一步:字节 → 字符(浏览器在"识字")

浏览器从服务器拿到的是字节流------一堆 0 和 1。它得先"识字":

css 复制代码
服务器发来的:0x3C 0x68 0x74 0x6D 0x6C 0x3E
                 ↓ 按 UTF-8 解码
浏览器读到的:<html>

怎么知道用 UTF-8 解码?两个地方说了算:

  • 响应头 Content-Type: text/html; charset=UTF-8
  • HTML 里的 <meta charset="UTF-8">

如果这俩不一致呢? 浏览器选错解码器 → 字节映射成错误的字符 → 页面乱码。这就是为什么乱码排查第一步永远是检查编码声明。

三、第二步:字符 → Token(状态机在"分词")

识完字,浏览器要搞清楚每个字是"标签名""属性"还是"文字内容"。这个活交给状态机------一个有 80 多个状态的自动机,逐字符读入,根据当前字符切换状态,碰到关键节点就"吐出"一个 Token。

举个例子,读入 <div id="app">

bash 复制代码
当前状态:Data(闲着等输入)
  读到 '<'  → 切换到 TagOpen 状态:"哦,标签来了"
  读到 'd'  → 切换到 TagName 状态,开始收集标签名
  读到 'i'  → 继续,标签名 = "di"
  读到 'v'  → 继续,标签名 = "div"
  读到 ' '  → 标签名收完,切到 AttrBefore:"要读属性了"
  读到 'i'  → 切到 AttrName,收集属性名
  ...(省略属性值读取)
  读到 '>'  → 发出 StartTagToken { name:"div", id:"app" },回到 Data 状态

关键点:状态机是逐字符推进的,它不知道后面会来什么。 读到 < 的时候,它不知道后面是 <div> 还是 </div> 还是 <!-- 注释 -->,它只知道"标签开始了,我得换个状态处理"。

这也解释了为什么 HTML 比 XML 容错------HTML 的状态机有大量"兜底逻辑":缺闭合标签?自动补上。属性没加引号?也行。</br> 当成 <br> 处理。XML 的状态机没有这些后门,一个错误直接报错停机。


四、第三步:Token → 节点(造零件)

每个 StartTagToken 都会变成一个 DOM 节点:

css 复制代码
StartTagToken { name: "div" }  →  HTMLDivElement { nodeName: "DIV", childNodes: [] }
StartTagToken { name: "p" }    →  HTMLParagraphElement { nodeName: "P", childNodes: [] }
CharacterToken { value: "hi" } →  Text { data: "hi" }

这一步比较直白------Token 告诉你"要造什么类型的节点",浏览器就造一个对应的空节点出来,属性挂上,等后面组装。


五、第四步:节点 → DOM 树(用栈搭积木,这是关键!)

单有节点没用,得拼成树。浏览器用来维护父子关系:

arduino 复制代码
规则很简单:
  遇到 StartTag → 造节点 → 压栈(这个节点变成"当前父节点")
  遇到 EndTag   → 弹栈(回到上一层父节点)
  遇到文字      → 造文本节点,挂到栈顶节点下面

<div><p>hello</p></div> 走一遍:

less 复制代码
读取 <div>  → 创建 div 节点 → 压栈 → 栈:[div]     → div 挂到 document
读取 <p>    → 创建 p 节点   → 压栈 → 栈:[div, p]   → p 挂到 div 下面
读取 hello  → 创建文本节点  → 挂栈顶 → "hello" 挂到 p 下面
读取 </p>   → 弹栈 → 栈:[div]                       → p 子树完成
读取 </div> → 弹栈 → 栈:[]                          → div 子树完成

注意看这个栈------它记录的是"当前正在构建的层级"。 栈顶就是"我正在给谁当爹"。压栈就是"我生了个儿子,现在给孙子当爹",弹栈就是"这个儿子生完了,回到上一层"。


六、好了,JS 来了------问题出在哪?

现在设想这个场景:浏览器正在用栈搭 DOM 树,栈里是 [html, body, div],正准备读下一个 Token------

突然遇到 <script>

浏览器必须暂停解析。

为什么?因为 JS 可以调用 document.write(),往当前解析位置插入新的 HTML

假设 JS 执行了 document.write('<p>surprise!</p>'),这意味着:

  1. <p>surprise!</p> 这段 HTML 要被插入到当前栈的位置 ------也就是 <div> 里面
  2. 如果浏览器不暂停,继续往后解析,栈结构就跟 JS 插入的内容对不上了
  3. 父子关系就乱了

栈是解析器的命脉,JS 可能改栈,所以解析器必须等 JS 执行完才敢继续。

这就像你在搭乐高,搭到一半有人要往中间插一块------你必须让他先插完,确认结构没乱,才能继续搭。不然你搭你的他插他的,最后拼出来啥都不是。

那 JS 不调用 document.write() 呢?

浏览器不知道你会不会调。它不能赌------万一你调了呢?所以保险起见,一律暂停。

这就像安检:你包里可能没有违禁品,但安检员不能赌你没有,一律过机器。


七、document.write() 的两个世界

理解了栈,就能理解 document.write() 最容易踩的坑:

DOM 构建中调用:内容插入到栈顶位置,正常追加。因为栈还在,解析器知道"往哪插"。

js 复制代码
// <div> 正在解析中
document.write("<p>插入的内容</p>");
// <p> 被插入到 <div> 里面,栈正常处理

DOM 构建完成后调用document.write() 会调用 document.open()清空整个页面,然后重写。因为栈已经空了------DOM 树搭完了,栈弹出所有元素归零了,没有"当前插入位置"。

js 复制代码
// DOMContentLoaded 之后
document.write("<h1>新页面</h1>");
// 整个页面被清空,只剩 <h1>新页面</h1>

这也是为什么 MDN 上一再警告"不要用 document.write()"------它跟解析器的栈机制绑得太紧,时机不对就炸。


八、解法:deferasync------让 JS 别那么霸道

既然问题是"JS 执行时解析器必须暂停",那解法就是让 JS 晚点执行

defer:下载不挡路,执行等解析完

css 复制代码
主解析器:  ──解析HTML──────────────────完成──
defer脚本:   ──下载──                   ──执行──
  • 下载和 HTML 解析并行,不阻塞
  • 执行等 DOM 解析完(在 DOMContentLoaded 之前)
  • 多个 defer 脚本按顺序执行

适用:需要操作 DOM 的脚本(比如绑定事件)

async:下载完就执行,不管解析器

csharp 复制代码
主解析器:  ──解析HTML────暂停──继续──完成──
async脚本:   ──下载──────执行──
  • 下载和 HTML 解析并行,不阻塞
  • 下载完立即执行,执行时暂停 DOM 解析
  • 多个 async 脚本执行顺序不保证(谁先下完谁先跑)

适用:不依赖 DOM 的独立脚本(比如统计、广告)

没有 defer/async(默认)

复制代码
主解析器:  ──解析──暂停──────继续──
script:         ──下载──执行──
  • 下载阻塞,执行也阻塞,最慢
  • 唯一的"好处":脚本执行时能确保前面的 DOM 都解析完了

一张图总结

xml 复制代码
              | 下载HTML时  | 下载JS时 | 执行JS时     | 执行顺序
普通 <script> | 阻塞解析    | 阻塞解析 | 阻塞解析     | 按出现顺序
defer         | 不阻塞      | 不阻塞   | 等解析完再执行 | 按出现顺序
async         | 不阻塞      | 不阻塞   | 立即执行(阻塞)| 谁先下完谁先跑
放 </body>前  | 不阻塞      | 不阻塞   | 几乎不阻塞    | 按出现顺序

九、CSS 呢?CSS 不阻塞解析,但会"迂回"影响

CSS 和 JS 不同------CSS 不阻塞 DOM 解析 。解析器遇到 <link rel="stylesheet"> 时不会暂停,DOM 树照常搭。

但 CSS 会阻塞两件事:

1. 阻塞渲染:浏览器必须等 CSSOM(CSS 的树结构)建好,才能把 DOM 树 + CSSOM 合成 Render Tree 来绘制。CSS 没好 = 画不出来。

2. 阻塞后续 JS 执行 :如果 JS 前面有还没下完的 CSS,JS 也得等。因为 JS 可能读取样式(getComputedStyle),浏览器要确保 JS 读到的是正确的值。

所以 CSS 放页面底部会出现 FOUC(Flash of Unstyled Content)

复制代码
时间线:
  DOM解析完成 → 没等CSS,先画一次(没样式,丑)→ CSS下完了 → 再画一次(有样式,好看)
  用户看到:丑 → 闪烁 → 好看

解法:CSS 放 <head> 里尽早加载,让 CSSOM 和 DOM 树并行构建,DOM 解析完 CSSOM 也差不多了,不用等。


十、预扫描器:浏览器偷偷干的聪明事

你可能会想:既然 <script> 阻塞解析,那外联脚本不是最慢的吗?------因为要等下载啊。

其实没那么惨。现代浏览器有个预扫描器(Preload Scanner)

xml 复制代码
主解析器:   ──解析──遇到<script>暂停──等JS──继续──
预扫描器:   ───────────────────偷偷往后扫──发现<script src="b.js">──开始下载b.js──

主解析器暂停的时候,预扫描器继续往后读 HTML,提前发现 <script src><link href><img src> 这些资源引用,立刻开始下载。

等主解析器恢复时,资源可能已经下完了------不用再等。

这就像你在排队等上菜,厨师先把后面几桌的菜备好了,等轮到你的时候直接上。


十一、用解析原理解释 SSR 为什么快

CSR(客户端渲染):

css 复制代码
浏览器下载空HTML → 下载JS → JS执行 → JS请求数据 → JS生成DOM → 渲染
                └──────── 多了这几步 ──────────┘

SSR(服务端渲染):

css 复制代码
浏览器下载完整HTML → 字节→字符→Token→节点→DOM树 → 渲染

SSR 快的原因不是"服务端渲染有魔法",而是浏览器拿到的就是完整的 HTML,直接走四步解析就能出画面,省掉了 CSR 里 JS 下载、执行、请求数据、生成 DOM 那一大圈。

本质上就是:SSR 把"生成 DOM"的活从浏览器(JS 执行)搬到了服务器(拼 HTML 字符串),浏览器只需要做它最擅长的------解析 HTML。


十二、总结:一个原理串起一堆题

原理 推出结论 对应的题
字节→字符靠编码解码 编码不一致 → 乱码 页面乱码怎么排查?
状态机逐字符推进 HTML 有容错,XML 没有 为什么 HTML 比 XML 容错?
栈维护 DOM 父子关系 JS 可能改栈 → 必须暂停 <script> 为什么阻塞?
栈在建时才有"插入位置" 栈空后 write() 清空页面 document.write() 什么时候炸?
defer/async 改变执行时机 defer 等 DOM,async 不等 deferasync 区别?
CSS 不阻塞解析但阻塞渲染 先画没样式的,再画有样式的 FOUC 怎么来的?
预扫描器提前下载资源 外联脚本不是最慢的 为什么外联脚本没那么惨?
SSR 直接给完整 HTML 少走好几步 SSR 为什么首屏快?

一条线串下来:解析器在用栈搭 DOM 树 → JS 可能改栈 → 所以解析器必须等 JS → 所以 <script> 阻塞解析 → 所以有了 defer/async → 所以脚本位置很重要。

不是背答案,是从原理推出来的。

相关推荐
光辉GuangHui3 小时前
Agent Skill 也需要测试:如何搭建 Skill 评估框架
前端·后端·llm
To_OC3 小时前
我终于搞懂 Claude Code 核心逻辑!90%的人都用错了模式
前端·ai编程
蓝宝石的傻话3 小时前
Headless浏览器的隐形陷阱:为什么你的AI自动化工具抓不到页面早期错误?
前端
zithern_juejin3 小时前
原型与原型链
javascript
irving同学462383 小时前
Node 后端实战:JWT 认证与生产级错误处理
前端·后端
莽夫搞战术3 小时前
【Google Stitch】AI原生画布重新定义设计,让想法变成可交互界面
前端·人工智能·ui
甲维斯3 小时前
Gemini3.5Flash前端是真的强!
前端·人工智能
光泽雨4 小时前
c#中的Type类型
开发语言·前端
Captaincc4 小时前
来自 Codex 官方团队的分享:如何把 Codex 用到极致
前端·vibecoding