一张栈的图,治好你面试答不出 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()"------它跟解析器的栈机制绑得太紧,时机不对就炸。


八、解法:defer 和 async------让 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 不等 defer 和 async 区别?
CSS 不阻塞解析但阻塞渲染 先画没样式的,再画有样式的 FOUC 怎么来的?
预扫描器提前下载资源 外联脚本不是最慢的 为什么外联脚本没那么惨?
SSR 直接给完整 HTML 少走好几步 SSR 为什么首屏快?

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

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

相关推荐
计算机魔术师38 分钟前
管谷歌的不是Pichai一个人,是一整层AI智能体
前端
米米商聊42 分钟前
图标能点,按钮就可用了吗?键盘、名称与收藏状态
前端·javascript
Younglina1 小时前
三个晚上,我和 WorkBuddy 做了个生图网站
前端·后端·ai编程
xy34531 小时前
Axure 9.0 内联框架加载方式
前端·ui·html·axure·原型·产品设计
技术小甜甜2 小时前
【Cursor】Skill 被标黄色警告,怎么改成合格的 SKILL.md
开发语言·javascript·ai编程·cursor·1024程序员节·ai agent·skills
陈随易2 小时前
副业搞钱点子之收入查看器
前端·后端
Profile排查笔记3 小时前
浏览器指纹逆向怎么分析:从网络请求追踪到 JavaScript 采集链
前端·人工智能·后端·自动化
hudie7653 小时前
LikeShop 二开最常改的 5 个文件(附改前改后对比)
前端·uni-app·php·个人开发
qetfw3 小时前
Bash 函数与流程控制:read、if、for、case 验证
linux·前端·javascript·bash
鬼手点金4 小时前
从 API 消息到模型 Token:Chat Template
java·前端·javascript·数据库·人工智能·深度学习·1024程序员节