HTML 解析器罢工了
DOM 工地才搭到一半,一个普通脚本就抱着枕头躺上了轨道:“文件没到,谁也别想过去。”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> 迅速把枕头踢到身后。
浏览器老大把两张通行证拍在桌上:defer 和 async。
“普通外部脚本不必立刻执行时,按工作需要选。拿了证,获取文件时不准继续堵轨道。”
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> 里面,不用出门取文件。所以就算把 async 或 defer 贴在我身上,解析器遇到我时还是会当场执行。”
“模块脚本另有规则。” 浏览器老大把内联脚本按了回去,“它默认更接近 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,再按规则搭节点。”
“普通脚本?”
“堵在当前位置。枕头没收。”
“defer 和 async?”
“一个收工后按顺序,一个到货就上场。”
“CSS?”
“不直接拦建树,但可能拦脚本、拦渲染。” HTML 解析器咬了咬牙,“还能借脚本的手拦我。”
DOMContentLoaded 满意地交出验收单:“看来这次真懂了。”
“这些本来就是我的工作!”
“这回能复工了吧?” JavaScript 老大递来一杯新咖啡。
HTML 解析器喝了一口,把罢工牌塞回工具箱:“能。”
“以后不摔扳手了?”
“看情况。”
话音刚落,像素工地又传来一阵巨响。
布局工人冲了出来,卷尺弯成了蚊香:“JavaScript 每改一次尺寸,就立刻问一次新位置!同一块布局已经重算八遍了!”
HTML 解析器沉默两秒,从工具箱里抽出罢工牌。
“借给你。”
“不用还。”