ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手写HTML解析器:从字符串到像素的完整实现

手写HTML解析器:从字符串到像素的完整实现

做这个项目之前,我其实犹豫了很久。市面上的浏览器内核动辄上百万行代码,光是HTML解析器部分就够啃半年。可我心里一直有个疑问:浏览器把一个字符串变成我们看到的网页,中间到底发生了什么?跨过很多资料之后,我把目标锁定在一个小得多的范围上:手写一个能解析早期HTML语法、构建出DOM树、再做最基础排版的“迷你渲染引擎”。项目的名字就叫“HTML 2”。这个“2”不是指HTML语言本身的版本,而是我自己从零写的第二版解析器。第一版用正则硬匹配,写成一坨不可维护的状态机,第二版才真正按照“词法分析 + 栈式建树 + 样式计算 + 简单绘制”的流程来组织。这篇文章就把整个项目的设计、实现、踩坑过程完整记录下来,适合觉得自己对浏览器原理一知半解、想动手做一个解析器练手的人参考。它不能让你一夜之间成为浏览器内核专家,但能帮你把“HTML到页面”这条链路上的核心环节理解透。

1. 项目视角:为什么值得手写一个HTML解析器

1.1 起名“HTML 2”的真实原因

最初我给它取名时,就面临着被误会的风险。很多朋友以为我在写HTML语言的教程第二版,或者我做了个叫“HTML 2”的工具站。其实项目全名是“HTML 2: A Minimal HTML Parser and Renderer”,一个极简HTML解析与渲染实验项目。

选择这个方向有两个现实背景。一是现在的前端开发已经高度工程化,大多数同学每天都在写JSX、Vue模板、小程序语法,真正和原生HTML标签打交道的机会变少了,反而对浏览器如何理解这些标签感到陌生。二是市面上关于浏览器内核的书籍和源码解析文虽然多,但大多是“按模块讲源码”,很少有带着你从零到一、能把一条HTML字符串变成屏幕上的像素的完整教学项目。我想做的,就是这个“从字符串到像素”的最小闭环。

所以“HTML 2”也有“第二遍重新理解HTML”的意思。第一遍是我作为网页开发者的日常使用,第二遍是我跳出使用者的角色,去思考“如果我来设计这套规则,我会怎么写”。这种换位思考特别有用,做完之后,我再去看那些复杂网页的DOM结构,脑子里会自动浮现出解析器处理时的栈的变化,很多以前靠死记硬背的规则也变得理所当然了。

1.2 解析一条HTML究竟要经历什么

把一句“浏览器把HTML渲染成页面”翻译成工程问题,其实是一个流水线:

  1. 读取字节流,按编码规范解码为字符串。
  2. 词法分析:把字符串切成有意义的Token,比如开始标签、结束标签、文本、注释。
  3. 语法分析:根据标签的嵌套关系构建DOM树,这一步类似于给一堆带括号的表达式建一棵语法树。
  4. 样式计算:解析CSS规则,匹配选择器,把它们挂载到对应的DOM节点上。
  5. 布局:根据盒模型、文档流等规则计算每个节点在页面上的位置和尺寸。
  6. 绘制:把布局结果画到屏幕上。

我做的“HTML 2”项目把第2到第6步全部实现了,只是每个环节都做了相当大的简化。比如绘制环节没有走完整的位图合成管线,直接把块级元素画成带边框和背景的矩形;文字采用最简单的从左到右排列,不处理连字符和复杂断行。但核心骨架是完整的:一个字符串进去,一段可以被肉眼验证的“页面”出来。

1.3 技术选型:为什么坚持不用正则一把梭

项目选什么语言其实没什么好纠结的。我选了Python,因为写起来快,调试方便,而且标准库就够用。真正纠结的是架构设计。

很多人在写解析器的时候,首先想到的就是正则表达式。第一版我也这么干了,结果踩了一堆坑。正则擅长匹配结构规整的字符串,但HTML是一种“容忍错误”的标记语言——标签可以不闭合、属性值可以不带引号、标签名可以有大小写。这些自由散漫的输入会让正则表达式膨胀到不可读,而且一旦漏掉某种形式,补丁只会越打越多。

所以我最终选择了“有限状态机”思路来写词法分析器:一个循环从头到尾扫描字符串,根据当前字符决定下一步动作。这种方法写起来更啰嗦,但每一步都是显式可控的,捕获各种异常情况也容易许多。第二版的核心解析逻辑只用了递归下降的思路配合一个显式栈,写起来清晰,排查问题时也能直接对着栈的状态说话。

模块划分大致是这样的:

模块职责核心数据结构/算法
词法分析器字符串 -> Token流状态机、标签语法的字符级扫描
树构建器Token流 -> DOM树栈式嵌套算法,腰斩式闭合
CSS解析器CSS字符串 -> 规则列表选择器拆解、属性键值对提取
匹配引擎规则列表 + DOM树 -> 带样式的节点选择器匹配、层叠优先级比较
布局引擎带样式节点 -> 盒子模型块级/行内元素的坐标计算
绘制器盒子模型 -> 画布矩形填充、文本绘制

这个框架画出来之后,项目看起来就清晰多了。接下来的每一章,我会按这个流水线逐个拆解核心实现。

2. 核心机制一:词法分析与树构建的工程细节

2.1 词法分析:把字符流切成Token的现场

词法分析是整个解析器最容易让人翻车的地方。HTML的结构比JSON复杂得多,因为它存在大段的纯文本,而文本内容里也可能出现尖括号,比如在代码示例里写<div>这种内容时,如果不加转义,解析器很容易误判。

我在项目里设计了一个 Token 结构,只有三种类型:

  • 开始标签,附带标签名和属性字典。
  • 结束标签,只带标签名。
  • 文本段。

对注释和文档类型声明这类稀有Token,我在扫描时跳过它们,先不纳入DOM树。

扫描逻辑的核心代码长这样:

def tokenize(html: str): tokens = [] text_buf = [] i = 0 n = len(html) while i < n: ch = html[i] if ch == "<": # 检查是不是注释 if html.startswith("<!--", i): if text_buf: tokens.append(("text", "".join(text_buf).strip())) text_buf = [] end = html.find("-->", i + 4) i = (end + 3) if end != -1 else n continue # 尝试匹配标签 m = match_tag(html, i) if m: if text_buf: tokens.append(("text", "".join(text_buf).strip())) text_buf = [] is_end, tag_name, attrs = m tokens.append(("end_tag", tag_name) if is_end else ("start_tag", tag_name, attrs)) i += consumed_len(m) continue # 走到这里说明 < 后面跟的不是合法标签,当作普通文本 text_buf.append(ch) i += 1 else: text_buf.append(ch) i += 1 if text_buf: tokens.append(("text", "".join(text_buf).strip())) return tokens

match_tag函数是关键。它必须处理三种情况:看结束标签的</、开始标签里的属性、自闭合的/>。属性部分我坚持不用正则一步到位,而是单独写了一个parse_attrs函数,因为属性边界情况太多了。

def parse_attrs(s: str): attrs = {} i = 0 n = len(s) while i < n: # 跳过空白 while i < n and s[i].isspace(): i += 1 if i >= n: break # 收集属性名 start = i while i < n and not s[i].isspace() and s[i] not in "=/>": i += 1 name = s[start:i] # 跳过空白和等号 while i < n and (s[i].isspace() or s[i] == "="): i += 1 # 收集属性值 if i < n and (s[i] == "\"" or s[i] == "'"): quote = s[i] i += 1 val_start = i while i < n and s[i] != quote: i += 1 value = s[val_start:i] i += 1 # 跳过右引号 else: val_start = i while i < n and not s[i].isspace() and s[i] != ">": i += 1 value = s[val_start:i] attrs[name.lower()] = value return attrs

这段代码也体现了典型的“状态机思维”:不试图一眼看穿整个属性区,而是逐个字符消费输入,遇到引号就切换“引号内模式”,遇到空白就切换“等号模式”。写起来比正则长,但逻辑完全可读,出问题的时候很容易断点排查。

2.2 栈式建树:解析器最精彩的一段

Token流拿到手之后,下一步就是构建DOM树。这个环节的经典思路是用一个栈。规则很简单:

  • 碰到开始标签,创建节点,挂到栈顶节点的子节点列表里;只要它不是空元素,就压入栈,作为新的栈顶。
  • 碰到结束标签,从栈顶向下寻找同名节点,找到后把这个节点以上的所有节点全部出栈。中间没闭合的节点自动视为已闭合。
  • 碰到文本,创建文本节点挂到栈顶。

这个“腰斩式闭合”的处理方式很重要。比如解析<p><b>你好</p>时,遇到</p>会在栈里找到未闭合的<b>和<p>,正确的浏览器行为是自动补充</b>,再闭合<p>。我的实现直接把栈削到<p>位置,中间所有节点都被收进<p>的子节点里。

def build_tree(tokens): root = Node("document") stack = [root] void_elements = {"br", "hr", "img", "input", "meta", "link"} for tok in tokens: if tok[0] == "start_tag": _, tag_name, attrs = tok node = Node(tag_name, attrs=attrs) stack[-1].children.append(node) if tag_name not in void_elements: stack.append(node) elif tok[0] == "end_tag": _, tag_name = tok # 从栈顶向上找同名节点 for i in range(len(stack) - 1, 0, -1): if stack[i].tag == tag_name: del stack[i:] break else: # 找不到对应开始标签:忽略 pass elif tok[0] == "text": stack[-1].children.append(Node("#text", text=tok[1])) return root

这个实现最核心的妙处在于:建树过程不需要递归,栈本身就隐式地保存了节点的父子关系。我曾在日志里打印每一步栈的变化,那感觉就像在看着一个括号表达式的求值过程,非常直观。

2.3 样式计算与简单布局

树构建完成之后,项目进入后半程:解析CSS并给节点上样式。这里我做了大量简化。CSS选择器只支持三类:标签选择器、类选择器、id选择器,支持后代选择器但不支持兄弟选择器和伪类。不过“层叠”的核心没有省——每个规则有一个优先级,用一个元组(0, 0, 0)表示,id选择器数量、类选择器数量、标签选择器数量依次对应三位。匹配的时候取优先级最高的规则生效。

布局部分则是简化到“能做教学演示”的程度。块级元素从上到下依次排列,外边距不合并;行内元素在块内从左到右排,一行放不下就换行。用坐标矩形表示每个元素的最终位置。

绘制环节我是用了标准库的图形接口,只支持画矩形、画文本、设置颜色。这样最终输出的不是真实浏览器里那种精密的网页,而是一张看得懂结构的“示意图”。但这个示意图已经足够说明问题:标签嵌套、样式作用、文档流,三个核心概念都能被直接看见。

3. 实操演示:从HTML字符串到一张可验证的图

3.1 输入样例与预期输出

为了演示整个流程,我准备了一个极简的页面:

<html> <head><title>HTML 2 Demo</title></head> <body> <h1 style="color: #333">欢迎</h1> <p class="intro">这是一段 <b>重要</b> 的内容。</p> <ul> <li>第一项</li> <li>第二项</li> </ul> </body> </html>

这段HTML包含了:嵌套结构、行内元素(b)、列表元素、文本、以及一个内联样式属性。如果把词法分析和建树的结果打印出来,应该能看到一个清晰的多层嵌套结构。父节点html下面有head和body,body下面挂着h1、p、ul等。

3.2 核心代码:把整个流水线串起来

我把整套流程封装成一个类,对外只要一个render(html_string, css_string, output_image)方法。

class MiniRenderer: def __init__(self): self.tokens = [] self.dom = None self.rules = [] self.layout_boxes = [] def render(self, html_str, css_str, output_path): # 1. 词法分析 self.tokens = tokenize(html_str) # 2. 构建 DOM 树 self.dom = build_tree(self.tokens) # 3. 解析 CSS self.rules = parse_css(css_str) # 4. 样式计算 self.apply_styles() # 5. 布局 self.layout() # 6. 绘制 self.paint(output_path)

apply_styles用一个递归函数遍历DOM树,遍历时同步维护一个“当前生效的样式层叠”。每个节点从祖先继承样式,再从匹配到的CSS规则覆盖,最后内联style属性优先级最高。

layout则维护一个游标坐标(x, y),遇到块级元素就换行、下移;遇到行内元素就向右排列。

3.3 运行结果与日志解读

实际跑完上面那个例子,控制台会输出类似这样的日志:

[tokenize] 共生成 17 个 token: start_tag html start_tag head start_tag title text "HTML 2 Demo" end_tag title end_tag head start_tag body ... [build_tree] 最终 DOM 根节点: document └── html ├── head │ └── title │ └── #text "HTML 2 Demo" └── body ├── h1 ├── p │ ├── "这是一段 " │ ├── b │ │ └── "重要" │ └── " 的内容。" └── ul ├── li └── li [layout] 布局完成: h1: x=0, y=0, w=320, h=40 p: x=0, y=40, w=320, h=24 b: x=58, y=40, w=48, h=24 [行内,位于 p 内部] ul: x=0, y=64, w=320, h=64

看到这个日志的时候,我第一次觉得整个链路是通的。从字符,到Token,到节点树,再到坐标盒子,每一步都有迹可循。那种成就感不是跑通一个现成框架能比的。

4. 踩坑记录:关于HTML解析器,我劝你避开的五个坑

4.1 最坑的点:属性值解析

第一版实现里,属性值直接用一个正则[\"\']?([^\"\'>\s]+)去提取。测试常规页面的时候一切正常,直到我遇到一个很像真实场景的用例:

<a href="/path?a=1&b=2" title="hello 'world'">链接</a>

问题立刻出现:单引号混在双引号里时,正则提取会把hello 'world'截断成hello,然后剩下的world'被当成属性名。更麻烦的是&在HTML里是转义符开头,直接放在属性值里会触发实体解码逻辑,我的第一版没有处理,结果a=1&b=2里的&b被错误地当成了命名实体。

这个坑让我彻底放弃了“用一个正则吃透属性”的想法,改成逐字符的状态机扫描。类似的问题还有:属性值可以完全不带引号、标签名后直接跟>而没有空格、属性之间隔着多个换行。这些“脏输入”在实际网页中出现的概率不低,只有状态机扫描才能优雅应对。

4.2 自闭合标签的栈管理

第一版把所有的开始标签都压栈,结果解析<img src="a.png"><p>文字</p>时,img会被当成<p>的父标签,后续文本全被错误地挂到img下面。

原因是<img>没有对应的结束标签,但它是空元素,不应该进入栈。

解决办法是维护一个void_elements集合:

void_elements = {"br", "hr", "img", "input", "meta", "link", "area", "base", "col", "embed", "source", "track", "wbr"}

碰到这些标签,创建节点但绝不出栈。这个集合看上去只是一个小字典,但少了它,任何包含图片或换行的页面都会解析出错误的DOM结构,而且这种错误非常隐蔽,因为页面往往还能渲染,只是嵌套关系不对。

4.3 文本节点的空白处理

文本处理也有隐藏陷阱。直接保留源码字符串里所有的空白,渲染出来时会出现意想不到的缩进和间距。比如:

<p> 内容 </p>

如果把换行和缩进都保留,渲染出的文本就会带上开头的\n,视觉上很丑。我最终决定在Token化阶段丢弃纯空白文本节点,但如果文本节点里既包含非空白内容又包含前后空格,则需要保留内容本身并做.strip()处理。这个选择其实是在“保留作者排版”和“所见即所得”之间做权衡。因为我做的是教学演示项目,更关心结果整洁,所以选了统一裁剪的策略。

4.4 深层嵌套导致的递归问题

布局阶段最初用递归函数遍历树。原理上没问题,但当我测试一段几百层嵌套的DIV时,Python直接报了RecursionError。虽然真实网页里不会出现这种极端结构,但解析器不能因此崩溃。

解决方案是布局遍历改成显式栈迭代:

def compute_layout(root): stack = [(root, 0)] while stack: node, depth = stack.pop() if node.tag in {"#document", "#text"}: continue if node.is_block(): compute_block_position(node, depth) else: compute_inline_position(node) # 把子节点按逆序压栈,保持输出顺序 for child in reversed(node.children): stack.append((child, depth + 1))

这个改动同时也提升了性能。更深层的启示是:任何涉及“树形遍历”的算法,都应该想一想会不会碰到递归层数问题。尤其是在教育类项目里,学生测试时经常拿极端样例去“摧毁”你的程序,显式栈能挡掉很大一部分崩溃。

4.5 编码问题:中文乱码的源头

解析器内部处理的是Unicode字符串,但外部输入可能是gb2312、big5等历史编码的文件。我不打算支持所有编码,但至少在读取文件时给了用户一个可选的解码参数:

def load_html(path, encoding="utf-8"): with open(path, "r", encoding=encoding, errors="replace") as f: return f.read()

注意errors="replace"很关键。遇到非法字节时替换成\ufffd,总比整个程序崩掉强。在实际使用中,大多数下载下来的旧网页都是UTF-8,但如果遇到乱码,先检查这一层,而不必怀疑解析器本身。

5. 常见问题速查表:实操中直接对着查

现象可能原因排查与解法
标签嵌套关系错乱,图片后被识别为子元素节点缺少void_elements集合补全空元素集合,确保它们不出栈
属性值变成乱码或额外多出属性名属性值中含有非法字符或引号不配对改用状态机扫描属性区,不依赖正则大法
页面文字整体带上一行缩进纯空白文本节点被保留在词法阶段丢弃纯空白文本节点
多层嵌套页面渲染时崩溃递归遍历造成栈溢出布局阶段改为显式栈迭代
同一个元素样式忽大忽小CSS规则的层叠顺序不对检查优先级元组是否按 id / class / 标签正确排序
文本里的<被误判成标签词法分析没有做否定判断在match_tag失败时把<当作普通字符处理
解析结束后DOM树多出若干个孤立的空节点Token流里混入了注释或DOCTYPE在词法阶段显式跳过<!-- -->与<!DOCTYPE>

这张表是我在实际开发中记录下来的,每一条背后都是至少一小时的排查。这里想多说一句:解析器的调试和业务代码调试有个很大不同——你没法只靠“输出不符合预期”来判断错误位置,因为错误可能在词法、建树、样式、布局任何一个环节产生。所以打日志时,最好每一层都留一个可验证的中间输出物。我的Token流和DOM树,在跑任何一个用例的时候都会打印到文件里,出错时先缩小范围,再对症下药。

6. 这个项目给我的长期影响

做完“HTML 2”之后,我再看普通网页,视角已经完全变了。以前我只关心页面好不好看、交互顺不顺,现在我会下意识地去看“这段标签在解析器里会怎么处理”,那些以前觉得奇怪的渲染行为——比如某些标签自动闭合、某些空白被折叠、某些CSS样式没有生效——现在都有了确切的答案。

这个项目也让我对“学习底层”这件事本身有了新的体会。同样是写一个解析器,如果一开始就照着浏览器内核源码去读,很容易被复杂度劝退;但先用量级小很多的实现跑通全流程,建立心理模型,再回头看那些复杂的源码,你会发现那些看似艰深的算法,其实底层全都是些朴素的想法。栈、递归下降、优先级比较、盒模型,这些概念任何一个学过编程的人都懂,真正难的只是把它们组合成一个能工作的系统。

如果你也想做一个类似的项目,我的建议是:不要试图复刻HTML5的完整解析算法,那是一个巨大的工程。先从一个“只支持核心标签”的极简版本开始,能解析、能建树、能渲染,就已经成功了一半。后面想加的东西,都是在这个稳定的骨架上长出来的血肉。任何一次“浏览器还是很了不起”的感叹,都比不上自己亲手写完一个极简解析器来得真实。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进