上一篇走完整条“把 URL 变成字节流”的网络链路;这篇我们把字节流 → 词(token) → DOM 树的两大阶段讲透:
1)词法分析:用状态机把字符流切成 token;
2)语法分析:用栈把 token 组装成一棵 DOM 树。
目标是工程师能落地的理解与代码骨架,而不是浏览器内核级别的全实现。
01|从字符到 token:为什么必须用“状态机”?
HTML 看似“人读即懂”,但对机器来说只有一串字符。浏览器要想理解它,第一步就得把这串字符分割成最小有意义单元(token):
- 开始标签:
<p
、<img
、<div
… - 属性:
class="a"
、src="a.png"
… - 结束标签:
</p>
、</div>
… - 文本:
text text text
- 注释:
<!-- ... -->
- CDATA/Doctype 等特殊节点
“一口一口”嚼的逻辑
- 读到
<
→ 进入“标签相关状态”; - 读到非
<
→ 进入“文本状态”; - 在“标签状态”里再分岔:
!
(注释/doctype)、/
(结束标签)、字母(开始标签)、?
/%
(兼容奇怪历史语法)……
HTML 规范直接给了词法状态机(约 80 个状态),是少见把“实现方式”写进标准的语言。这里用一个精简版足以说明原理。
02|一个最小可运行的 HTML 词法器(骨架)
思路:每个状态 = 一个函数,输入一个字符 c
,返回“下一个状态函数”。输出 token 用统一的 emitToken
。
// ===== 工具与输出 =====
const tokens = [];
let currentToken = null;
const emitToken = t => t && tokens.push(t);
// ===== 状态们:data → tagOpen → tagName ... =====
function data(c) {
if (c === '&') return charRefInData; // 简化:字符引用
if (c === '<') return tagOpen;
if (c === '\0') { emitToken({type:'error'}); return data; }
if (c === EOF) { emitToken({type:'EOF'}); return data; }
emitToken({ type: 'Text', value: c });
return data;
}
function tagOpen(c) {
if (c === '/') return endTagOpen;
if (/[A-Za-z]/.test(c)) {
currentToken = { type:'StartTag', name: c.toLowerCase(), attrs: [] };
return tagName;
}
if (c === '!') return markupDeclOpen; // 注释/doctype 分支(略)
if (c === '?') return bogusComment; // 兼容分支(略)
emitToken({ type:'error' }); return data;
}
function tagName(c) {
if (/\s/.test(c)) return beforeAttrName;
if (c === '/') return selfClosingStartTag;
if (c === '>') { emitToken(currentToken); currentToken = null; return data; }
currentToken.name += c.toLowerCase();
return tagName;
}
function beforeAttrName(c) {
if (/\s/.test(c)) return beforeAttrName;
if (c === '/' || c === '>' || c === EOF) return afterAttrName(c);
currentAttribute = { name:'', value:'' };
return attrName(c);
}
let currentAttribute = null;
function attrName(c) {
if (/\s/.test(c) || c === '/' || c === '>' || c === EOF) {
currentToken.attrs.push(currentAttribute);
return afterAttrName(c);
}
if (c === '=') return beforeAttrValue;
currentAttribute.name += c;
return attrName;
}
function beforeAttrValue(c) {
if (/\s/.test(c)) return beforeAttrValue;
if (c === '"') { currentAttribute.quote = '"'; currentAttribute.value=''; return attrValueQuoted; }
if (c === "'") { currentAttribute.quote = "'"; currentAttribute.value=''; return attrValueQuoted; }
currentAttribute.quote = null; currentAttribute.value=''; return attrValueUnquoted(c);
}
function attrValueQuoted(c) {
if (c === currentAttribute.quote) { currentToken.attrs.push(currentAttribute); return beforeAttrName; }
currentAttribute.value += c; return attrValueQuoted;
}
function attrValueUnquoted(c) {
if (/\s/.test(c)) { currentToken.attrs.push(currentAttribute); return beforeAttrName; }
if (c === '>') { currentToken.attrs.push(currentAttribute); emitToken(currentToken); currentToken=null; return data; }
currentAttribute.value += c; return attrValueUnquoted;
}
function selfClosingStartTag(c) {
if (c === '>') { currentToken.selfClosing = true; emitToken(currentToken); currentToken=null; return data; }
return beforeAttrName(c);
}
function endTagOpen(c) {
if (/[A-Za-z]/.test(c)) { currentToken = { type:'EndTag', name: c.toLowerCase() }; return tagName; }
emitToken({ type:'error' }); return data;
}
// ===== 驱动器:把字符流喂给状态机 =====
const EOF = Symbol('EOF');
function lex(input) {
let state = data;
for (const ch of input) state = state(ch);
state = state(EOF);
return tokens;
}
可运行建议:把上面粘进任意 JS 运行环境(如浏览器控制台),
lex('<p class="a">hi</p>')
看看输出的 token 流。
03|从 token 到 DOM:为什么“用栈就够了”?
核心想法:HTML 是一个递归嵌套的结构,配对的“开始/结束标签”天然适合用**栈(stack)**匹配与归属。
- 遇到 StartTag:创建元素节点
Element
,挂到“栈顶元素”的children
,并入栈成为新的“当前节点”。 - 遇到 EndTag:出栈,同时可做“是否匹配”的校验(容错时会补全/忽略不合法标签)。
- 遇到 Text:如果栈顶已是
Text
,合并文本;否则新建Text
追加到栈顶的children
。 - 遇到 Comment/Doctype:作为栈顶的子节点挂入(可选实现)。
“最简 DOM 节点模型” + 语法分析器骨架
class Element {
constructor(name, attrs = []) {
this.type = 'Element';
this.name = name;
this.attributes = Object.fromEntries(attrs.map(a => [a.name, a.value]));
this.children = [];
}
}
class Text {
constructor(value) { this.type = 'Text'; this.value = value; }
}
function parse(tokens) {
const root = { type:'Document', children: [] };
const stack = [root];
const top = () => stack[stack.length - 1];
for (const t of tokens) {
if (t.type === 'StartTag') {
const el = new Element(t.name, t.attrs);
top().children.push(el);
if (!t.selfClosing) stack.push(el);
} else if (t.type === 'EndTag') {
// 简化:直接弹到同名标签(真内核此处有复杂的“插入模式”规则)
while (stack.length > 1 && top().name !== t.name) stack.pop(); // 容错闭合
if (top().name === t.name) stack.pop();
} else if (t.type === 'Text') {
const parent = top();
const last = parent.children[parent.children.length - 1];
if (last && last.type === 'Text') last.value += t.value;
else parent.children.push(new Text(t.value));
}
}
return root;
}
// 体验:从 HTML 字符串 → DOM 栅格
const dom = parse(lex('<div><p class="a">hi<img src="a.png"/></p>ok</div>'));
console.log(JSON.stringify(dom, null, 2));
真实浏览器在这一步实现的是 HTML 规范里的**“树构建(Tree Construction)” ,包含十几种“插入模式”(In body / In head / In select …)与一套容错闭合**策略——这正是“乱写 HTML 也能显示出来”的奥义。
04|HTML 的“宽容”来自哪?
- 省略标签:
<html>、<head>、<body>
等在某些情形下可省略,解析器会自动补齐; - 错位闭合:
<p><div></p></div>
最终也能“看起来对”,因为树构建规则会重排/插入/忽略部分标签; - 奇怪前缀:
<!DOCTYPE ...>
、<!-- -->
、<![CDATA[ ]]>
、<? ?>
、<% %>
兼容历史/模板系统; - 属性的多样写法:布尔属性、未引号值、实体引用(
&
)等在状态机里都有专门分支。
结论:HTML 不是 XML。它更像“会自动帮你收拾烂摊子”的语法;也因此,用状态机 + 树构建规则才能稳妥处理。
05|落地到工程:这套知识能帮你什么?
- 定位“奇怪的渲染” :知道浏览器可能自动补标签 / 重排树,就会优先用 DevTools Elements 面板对比“预期 DOM vs 实际 DOM”。
- 安全意识:理解文本 vs HTML的边界,知道哪里必须 escape,防止 XSS(文本永远走
textContent
/属性走setAttribute
)。 - 性能优化:知道解析是流式的,把关键 CSS/首屏 HTML尽早送达(SSR +
preload
),能更早进入样式/布局阶段。 - 编译器思维迁移:状态机/栈这两件“老兵利器”,在你实现自定义模板/Markdown/迷你 DSL 时也能直接复用。
06|练习 & 进阶挑战(建议收藏)
- 练习 1:给词法器补上注释(
<!-- ... -->
)与自闭合(<img />
)的完整状态; - 练习 2:在语法分析阶段实现**“自动补全
<body>
/<html>
”**的小规则; - 练习 3:把
&
/<
的字符引用解码加到charRefInData
; - 加分项:实现“错误恢复”:当遇到
</p>
而栈顶不是p
,按规则弹栈直到遇到可隐式闭合的元素。
完成后,你就有了一个可跑的小型 HTML 解析器:能把“有点乱”的 HTML 转成一棵可用的 DOM 结构(当然不含布局样式,只做“结构”)。
结语
浏览器把字节流变成像素,中间第一件大事就是:把字符切成 token,再把 token 堆成树。
理解“状态机 + 栈”这两个简单但强悍的思想,你会对“HTML 为什么宽容”“DOM 为什么长这样”有底层把握,也更能在真实项目里定位渲染问题、规避安全坑、优化首屏路径。
下篇我们继续沿着流水线往前走:样式计算(Style)是如何发生的? 为什么改个 class
会触发回流/重绘?以及“层合成”到底做了什么。