HTML 解析器罢工了

DOM 工地才搭到一半,一个普通脚本就抱着枕头躺上了轨道:“文件没到,谁也别想过去。”HTML 解析器当场摔掉扳手——这树爱谁搭谁搭!

分享
HTML 解析器罢工了

谁躺轨道上了

本文承接上一篇:《事件大厅的插队者》

html,站稳!”

head,往左边挪!”

body,别插队,还没轮到你!”

DOM 工地上,HTML 解析器戴着黄色安全帽,嘴里叼着哨子,正指挥一群节点搭树。

起重机刚吊起一块 p 节点,轨道上突然传来“咚”的一声。

一个普通 <script> 抱着枕头躺下了。

还是横着躺。

“起来!” HTML 解析器吹响哨子,“轨道不是宿舍!”

“文件还在路上。” <script> 翻了个身,“等到了,JavaScript 老大执行完,我自然会走。”

“那你靠边等!”

“不行,我可能会改你后面的施工计划。”

“DOM 才搭到一半!”

“那不是正好吗?另一半先别搭错了。”

运输带停了。起重机停了。后面的节点撞成一团,div 一屁股坐在了 span 脑袋上。

HTML 解析器盯着 <script>,摘下安全帽,解下工具带,最后把扳手往地上一摔。

“行。”

“这树爱谁搭谁搭!”

把事故倒回去

JavaScript 老大赶到工地时,手里的咖啡还冒着热气。

“电话里不是说有点堵吗?”

“这叫有点堵?” HTML 解析器指着瘫痪的轨道,“这叫一个脚本瘫痪一座工地!”

老大试图打圆场:“别急,先喝口咖啡。”

“不喝。喝完还得替你加班。”

JavaScript 老大跨过歪倒的节点,走到 <script> 面前:“普通脚本确实要在这里执行,这是规矩。可你怎么气成这样?”

“因为你只看见它躺下,没看见我搭到这里有多麻烦!”

HTML 解析器一把拽住老大,把他拖进工地的监控室。

“想知道一个脚本为什么能卡住整棵树,就先看看这棵树是怎么搭到它脚下的。”

HTML 解析器抓住回放杆,猛地往后一拉。

屏幕里的起重机倒着转,节点一个个飞回运输车,横在轨道上的 <script> 也抱着枕头退回了文档深处。画面最终停在刚开工的那一刻。

“事故回放,开始。”

HTML 解析器把当时收到的文档拍到屏幕上:

<!doctype html>
<html>
    <head>
        <title>浏览器工地</title>
    </head>
    <body>
        <h1>今日进度</h1>
        <p id="before">DOM 建设中</p>

        <script src="work.js"></script>

        <p id="after">等待验收</p>
    </body>
</html>

“看见什么了?”

“标签、文字,还有一些换行。”

“对。就是没有树!” HTML 解析器敲了敲屏幕,“接下来盯紧了,别眨眼。”

HTML 解析器朝分词台一挥手:“开工!”

分词员“咔嚓、咔嚓”地剪起文档:

<!doctype html>  → DOCTYPE token
<h1>             → 开始标签 token
今日进度          → 字符 token
</h1>            → 结束标签 token

<h1> 刚拿到开始标签 token 就冲向工地:“我的位置在哪儿?”

body 下面!” 建树员把它按进节点槽。

“那‘今日进度’呢?”

“变成文本节点,住你里面。”

“凭什么住我家?”

“因为结束标签还没来。少废话,抱紧!”

</h1> 气喘吁吁地跑到后,建树员才合上这一层。没过多久,工地上就立起了一棵树:

Document
└── html
    ├── head
    │   └── title
    │       └── "浏览器工地"
    └── body
        ├── h1
        │   └── "今日进度"
        ├── p#before
        │   └── "DOM 建设中"
        └── script[src="work.js"]

老大盯着树看了半天:“after 呢?文档里明明还有一段‘等待验收’。”

“还在纸上排队!” HTML 解析器指向画面里的 <script>,“回放到这里,这家伙就躺下了。后面的字符还没轮到,after 当然也没进 DOM。”

老大绕着树走了一圈:“所以 DOM 就是把源码摆成树?”

“你管这叫‘摆’?” HTML 解析器把扳手往桌上一拍,“分词员剪了半天,建树员忙得安全帽都歪了,到你嘴里就剩一个‘摆’?”

HTML 解析器敲了敲树根:“源码是送进工地的材料。分词员识别 token,建树员按解析规则创建并插入节点。Document、元素、文本、注释,都可能成为节点。”

“JavaScript 平时拿的 document……”

“就是这棵节点树的入口。”

“源码要是后来改了呢?”

“已经装进浏览器的 DOM 不会跟着编辑器隔空变身。反过来也一样,JavaScript 在 DOM 里拆个节点,服务器上的 HTML 文件也不会少一块。”

老大点点头:“原来不是复印件。”

“是工地现状!”

回放里还有一辆歪车

JavaScript 老大正要催着快进,监控里忽然响起一阵喇叭声。

“让一让!急件!”

一辆货车歪歪扭扭地冲了进来:

<p>第一段
<div>临时通知</div>
<p>第二段

车门一开,三个标签滚了一地。

HTML 解析器捡起文档,额头青筋直跳:“结束标签呢?”

<p> 摊开手:“出门太急,忘带了。”

“忘带了还敢来?”

老大探头看了一眼:“要不报错,整车退回?”

“HTML 工地没有这福气。”

建树员刚把第一个 <p> 放好,<div> 就要往它肚子里钻。

“出来!” HTML 解析器揪住 div,“按规则,这里先把前面的 p 结束掉。你另找位置。”

第二个 <p> 也想蒙混过关,又被单独安排成一个段落。

“浏览器还会猜开发者的心思?” 老大问。

“不猜。” HTML 解析器拍掉手上的灰,“分词状态、插入模式、错误恢复都有规则。材料送歪了,就按规则修。修出来的 DOM,不一定是盯着源码时脑补的那棵树。”

<p> 悄悄往外溜。

“站住!”

“还有事?”

“下次把结束标签带上!”

JavaScript 老大按住回放杆:“这车和今天的罢工有关系吗?”

“有。” HTML 解析器指着被重新安排的节点,“正常材料要搭,歪材料也得按规则修。DOM 就是这么一块一块长出来的。前面的处理结果一变,后面该怎么搭也可能跟着变。”

“所以不能随便跳过中间那一段。”

“终于听懂一句。” HTML 解析器把回放杆往前一推,“现在快进到真正的肇事者。”

回到那只枕头

监控画面飞快掠过。分词、建树、插入节点……

“停!”

画面定格。

普通 <script> 刚落到施工轨道上,熟练地抖开枕头,躺下,还从口袋里摸出一包饼干。

HTML 解析器推开监控室的门。现实里的 <script> 也刚好睡醒,正坐在同一只枕头上吃饼干。

HTML 解析器一把抢走饼干:“说!为什么每次都要全工地等你?”

<script> 拍拍碎屑:“因为我真能改图纸。”

<body>
    <p id="before">前面的节点</p>

    <script src="work.js"></script>

    <p id="after">后面的节点</p>
</body>

work.js 指向前半棵树:“轮到我时,before 已经建好了。”

document.querySelector("#before").textContent = "已修改";
console.log(document.querySelector("#after")); // null

“看,前面的节点能改。” <script> 又指向空荡荡的轨道,“可 after 还没被解析,当然找不到。”

HTML 解析器抱着胳膊:“改前面就改前面,后面关你什么事?”

“我还可能调用 document.write(),往正在解析的文档里塞新材料。”

“谁给你的权力?”

“历史。”

“最难处理的那个部门。”

JavaScript 老大赶紧挡在中间:“先说明一下。像它这种直接运行普通 JavaScript、不是 type="module" 的脚本,规范里叫经典脚本。”

HTML 解析器皱起眉头:“躺得这么熟练,还敢叫经典?”

“名字先别管。” 老大把话题拽了回来,“解析器插入的这种普通脚本,既没有 async,也没有 defer,就得在当前位置处理。内联脚本执行时暂停解析;外部脚本还要等文件获取并执行完成。否则脚本刚改完结构,解析器又照旧图纸往下搭,谁也不知道会盖出什么。”

HTML 解析器指着枕头:“所以必须躺着等?”

“枕头是个人行为。” 老大瞪了 <script> 一眼,“阻塞解析才是工作需要。”

<script> 默默把被子也收了起来。

工地停了,侦察兵没停

“报告!”

一名预加载扫描员从文档后面钻了出来,头戴夜视镜,手里举着望远镜。

HTML 解析器立刻拉响警报:“抓住!有人偷着往后解析!”

“冤枉!” 扫描员一头撞上路障,“只看资源,不搭 DOM!”

“说清楚。”

“主解析器等脚本时,有些浏览器会继续扫描后面的标记。发现样式表、图片或其他脚本,就赶紧通知网络部门先去取。”

“那顺便把后面的节点建了。”

“不行。”

“为什么?”

“前面那位真可能改图纸。” 扫描员指着 <script>,“提前下载可以,越过它决定 DOM 怎么长,不可以。”

几辆资源车收到消息,提前冲进网络隧道。

HTML 解析器望着仍旧没动的施工轨道:“网络部门倒是不闲了。”

“知足吧。” 扫描员扶正夜视镜,“能偷跑的只有下载,不是你的活。”

两张免躺证

浏览器老大终于来了。

“听说有人在工地搭床?”

普通 <script> 迅速把枕头踢到身后。

浏览器老大把两张通行证拍在桌上:deferasync

“普通外部脚本不必立刻执行时,按工作需要选。拿了证,获取文件时不准继续堵轨道。”

defer:等你收工

defer 戴着手表,抱着两份文件走出来:

<script defer src="prepare.js"></script>
<script defer src="finish.js"></script>

“两份文件现在就去取。” defer 看了一眼 HTML 解析器,“您继续搭,我不拦。”

HTML 解析器眯起眼睛:“回来以后呢?”

“等文档解析完成再执行。”

“谁先?”

“按文档顺序。prepare.js 在前,它先;finish.js 在后,它等着。”

finish.js 举手:“要是我先下载完?”

“也等。”

“凭什么?”

“名字都叫 finish 了,急什么?”

浏览器老大在通行证上盖章:“defer 适合依赖完整 DOM,或者彼此有执行顺序的普通外部脚本。下载与解析并行,解析结束后按文档顺序执行,而且要赶在 DOMContentLoaded 前交工。”

HTML 解析器第一次露出笑脸:“这位说话像个干活的。”

async:谁先到谁上

async 骑着摩托冲进工地,刹车时差点撞翻 defer

<script async src="analytics.js"></script>
<script async src="notice.js"></script>

“都闪开!文件各取各的,谁先回来谁先执行!”

“不用等 DOM 建完?” HTML 解析器问。

“不等!”

“不用守文档顺序?”

“不守!”

“那你回来时我还在施工呢?”

“请暂停一下!”

HTML 解析器的笑脸消失了:“你管这叫免堵?”

“获取时不堵。” async 摘下头盔,“文件准备好就尽快执行。如果那时还在解析,只好短暂停一下。”

“适合不依赖完整 DOM、也不依赖其他脚本的独立工作。” 浏览器老大一把拽住准备再次起步的摩托,“但多个 async 脚本不保证文档顺序,DOMContentLoaded 也不等它们。”

defer 晃了晃手表:“我等工地收工。”

async 拧了拧油门:“我等文件到手。”

普通 <script> 抱紧枕头:“我在当前位置等。”

HTML 解析器拿起扳手:“再总结一遍就都出去。”

浏览器老大赶紧补充:“刚才两张通行证,只发给带 src、需要去外面取文件的普通脚本。”

一名内联脚本从标签里探出脑袋:“我的代码直接写在 <script>...</script> 里面,不用出门取文件。所以就算把 asyncdefer 贴在我身上,解析器遇到我时还是会当场执行。”

“模块脚本另有规则。” 浏览器老大把内联脚本按了回去,“它默认更接近 defer,加上 async 后才会准备好就执行。”

“很好。” HTML 解析器推开大门,“全都出去。”

CSS:这锅别往这边扔

工地刚安静下来,一张外部样式表飘到门口:

<link rel="stylesheet" href="theme.css">
<script src="measure.js"></script>

HTML 解析器条件反射般举起扳手:“又来一个阻塞解析的!”

“看清楚再扔!” theme.css 缩到墙角,“样式表下载通常不直接拦你建 DOM。”

“那你拦谁?”

theme.css 指向后面的 measure.js:“它。”

measure.js 正举着尺子,准备读取样式:

const color = getComputedStyle(document.body).color;

“前面的样式还没准备好,我量出来的结果可能不对。” measure.js 理直气壮,“所以我要等。”

HTML 解析器指着自己:“你一等,我呢?”

“如果你正停在我这里……一起等。”

“CSS 拦脚本,脚本拦我?”

theme.css 摊开纸角:“请称为间接合作。”

“这叫连环堵车!”

浏览器老大又跑来灭火:“还得分清三件事:样式表通常不直接停止 DOM 构建,但可能阻塞脚本,也通常会阻塞渲染。建 DOM、跑脚本、把像素画出来,是三条互相影响却不完全相同的流水线。”

HTML 解析器冷笑一声:“终于承认这地方有三条流水线一起堵了?”

远处的像素工地突然“咣当”一声。

布局工人扯着嗓子大喊:“谁又改宽度了?”

JavaScript 老大刚想过去看看,浏览器老大一把将他拽了回来:“先别过去!那边是下一场事故,今天这张验收单还没盖完。”

谁扣住了验收单

深夜,HTML 解析器终于读到文档末尾。

“DOM 建完!” HTML 解析器按下铃,“DOMContentLoaded,进来验收!”

门外没有动静。

“人呢?”

defer 从桌子底下探出头:“还有两份延迟脚本没执行。”

“不是说我收工了吗?”

“对。您收工,轮到我们开工。”

状态管理员把 document.readyState 的牌子从 loading 翻到 interactive

HTML 解析器指着牌子:“都 interactive 了,还不放人?”

“只说明文档解析完了。” 状态管理员躲到牌子后面,“两名 defer 得按顺序交工,模块脚本也得把该做的做完。别拿扳手指着我。”

HTML 解析器再次按铃:“现在呢?”

一张样式表举手:“延迟脚本可能还在等我。”

DOMContentLoaded 不是不直接等样式表吗?”

“是不直接等。” 样式表指了指脚本,“但它等我,验收又等它。”

“又是连环堵车!”

最后一名延迟脚本终于盖下印章。事件大厅的大门“唰”地打开,DOMContentLoaded 抱着验收单走了进来。

“图片到齐了吗?” HTML 解析器问。

“不用等普通图片全部下载。”

async 呢?”

远处传来摩托车声:“也不用等我!”

“那你来验什么?”

“文档已经解析完成,该在解析后执行的脚本也交工了。” DOMContentLoaded 翻开验收单,“就验这个。”

HTML 解析器伸手拿验收单,DOMContentLoaded 却往后一缩:“先对口供。”

“又来?”

“源码怎么变成 DOM?”

“拆成 token,再按规则搭节点。”

“普通脚本?”

“堵在当前位置。枕头没收。”

deferasync?”

“一个收工后按顺序,一个到货就上场。”

“CSS?”

“不直接拦建树,但可能拦脚本、拦渲染。” HTML 解析器咬了咬牙,“还能借脚本的手拦我。”

DOMContentLoaded 满意地交出验收单:“看来这次真懂了。”

“这些本来就是我的工作!”

“这回能复工了吧?” JavaScript 老大递来一杯新咖啡。

HTML 解析器喝了一口,把罢工牌塞回工具箱:“能。”

“以后不摔扳手了?”

“看情况。”

话音刚落,像素工地又传来一阵巨响。

布局工人冲了出来,卷尺弯成了蚊香:“JavaScript 每改一次尺寸,就立刻问一次新位置!同一块布局已经重算八遍了!”

HTML 解析器沉默两秒,从工具箱里抽出罢工牌。

“借给你。”

“不用还。”