17、浏览器工作原理(解析篇):HTML 是怎么被“嚼碎”和“拼好”的?

0 阅读5分钟

上一篇走完整条“把 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[ ]]><? ?><% %> 兼容历史/模板系统;
  • 属性的多样写法:布尔属性、未引号值、实体引用(&amp;)等在状态机里都有专门分支。

结论:HTML 不是 XML。它更像“会自动帮你收拾烂摊子”的语法;也因此,用状态机 + 树构建规则才能稳妥处理。


05|落地到工程:这套知识能帮你什么?

  1. 定位“奇怪的渲染” :知道浏览器可能自动补标签 / 重排树,就会优先用 DevTools Elements 面板对比“预期 DOM vs 实际 DOM”。
  2. 安全意识:理解文本 vs HTML的边界,知道哪里必须 escape,防止 XSS(文本永远走 textContent/属性走 setAttribute)。
  3. 性能优化:知道解析是流式的,把关键 CSS/首屏 HTML尽早送达(SSR + preload),能更早进入样式/布局阶段。
  4. 编译器思维迁移:状态机/栈这两件“老兵利器”,在你实现自定义模板/Markdown/迷你 DSL 时也能直接复用。

06|练习 & 进阶挑战(建议收藏)

  • 练习 1:给词法器补上注释<!-- ... -->)与自闭合<img />)的完整状态;
  • 练习 2:在语法分析阶段实现**“自动补全 <body>/<html>”**的小规则;
  • 练习 3:把 &amp;/&lt;字符引用解码加到 charRefInData
  • 加分项:实现“错误恢复”:当遇到 </p> 而栈顶不是 p,按规则弹栈直到遇到可隐式闭合的元素。

完成后,你就有了一个可跑的小型 HTML 解析器:能把“有点乱”的 HTML 转成一棵可用的 DOM 结构(当然不含布局样式,只做“结构”)。


结语

浏览器把字节流变成像素,中间第一件大事就是:把字符切成 token,再把 token 堆成树
理解“状态机 + ”这两个简单但强悍的思想,你会对“HTML 为什么宽容”“DOM 为什么长这样”有底层把握,也更能在真实项目里定位渲染问题、规避安全坑、优化首屏路径

下篇我们继续沿着流水线往前走:样式计算(Style)是如何发生的? 为什么改个 class 会触发回流/重绘?以及“层合成”到底做了什么。