资讯详情

资讯详情

手写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渲染成页面”翻译成工程问题其实是一个流水线读取字节流按编码规范解码为字符串。词法分析把字符串切成有意义的Token比如开始标签、结束标签、文本、注释。语法分析根据标签的嵌套关系构建DOM树这一步类似于给一堆带括号的表达式建一棵语法树。样式计算解析CSS规则匹配选择器把它们挂载到对应的DOM节点上。布局根据盒模型、文档流等规则计算每个节点在页面上的位置和尺寸。绘制把布局结果画到屏幕上。我做的“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 tokensmatch_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树。这个环节的经典思路是用一个栈。规则很简单碰到开始标签创建节点挂到栈顶节点的子节点列表里只要它不是空元素就压入栈作为新的栈顶。碰到结束标签从栈顶向下寻找同名节点找到后把这个节点以上的所有节点全部出栈。中间没闭合的节点自动视为已闭合。碰到文本创建文本节点挂到栈顶。这个“腰斩式闭合”的处理方式很重要。比如解析pb你好/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, attrsattrs) 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, texttok[1])) return root这个实现最核心的妙处在于建树过程不需要递归栈本身就隐式地保存了节点的父子关系。我曾在日志里打印每一步栈的变化那感觉就像在看着一个括号表达式的求值过程非常直观。2.3 样式计算与简单布局树构建完成之后项目进入后半程解析CSS并给节点上样式。这里我做了大量简化。CSS选择器只支持三类标签选择器、类选择器、id选择器支持后代选择器但不支持兄弟选择器和伪类。不过“层叠”的核心没有省——每个规则有一个优先级用一个元组(0, 0, 0)表示id选择器数量、类选择器数量、标签选择器数量依次对应三位。匹配的时候取优先级最高的规则生效。布局部分则是简化到“能做教学演示”的程度。块级元素从上到下依次排列外边距不合并行内元素在块内从左到右排一行放不下就换行。用坐标矩形表示每个元素的最终位置。绘制环节我是用了标准库的图形接口只支持画矩形、画文本、设置颜色。这样最终输出的不是真实浏览器里那种精密的网页而是一张看得懂结构的“示意图”。但这个示意图已经足够说明问题标签嵌套、样式作用、文档流三个核心概念都能被直接看见。3. 实操演示从HTML字符串到一张可验证的图3.1 输入样例与预期输出为了演示整个流程我准备了一个极简的页面html headtitleHTML 2 Demo/title/head body h1 stylecolor: #333欢迎/h1 p classintro这是一段 b重要/b 的内容。/p ul li第一项/li li第二项/li /ul /body /html这段HTML包含了嵌套结构、行内元素b、列表元素、文本、以及一个内联样式属性。如果把词法分析和建树的结果打印出来应该能看到一个清晰的多层嵌套结构。父节点html下面有head和bodybody下面挂着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: x0, y0, w320, h40 p: x0, y40, w320, h24 b: x58, y40, w48, h24 [行内位于 p 内部] ul: x0, y64, w320, h64看到这个日志的时候我第一次觉得整个链路是通的。从字符到Token到节点树再到坐标盒子每一步都有迹可循。那种成就感不是跑通一个现成框架能比的。4. 踩坑记录关于HTML解析器我劝你避开的五个坑4.1 最坑的点属性值解析第一版实现里属性值直接用一个正则[\\]?([^\\\s])去提取。测试常规页面的时候一切正常直到我遇到一个很像真实场景的用例a href/path?a1b2 titlehello world链接/a问题立刻出现单引号混在双引号里时正则提取会把hello world截断成hello然后剩下的world被当成属性名。更麻烦的是在HTML里是转义符开头直接放在属性值里会触发实体解码逻辑我的第一版没有处理结果a1b2里的b被错误地当成了命名实体。这个坑让我彻底放弃了“用一个正则吃透属性”的想法改成逐字符的状态机扫描。类似的问题还有属性值可以完全不带引号、标签名后直接跟而没有空格、属性之间隔着多个换行。这些“脏输入”在实际网页中出现的概率不低只有状态机扫描才能优雅应对。4.2 自闭合标签的栈管理第一版把所有的开始标签都压栈结果解析img srca.pngp文字/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, encodingutf-8): with open(path, r, encodingencoding, errorsreplace) as f: return f.read()注意errorsreplace很关键。遇到非法字节时替换成\ufffd总比整个程序崩掉强。在实际使用中大多数下载下来的旧网页都是UTF-8但如果遇到乱码先检查这一层而不必怀疑解析器本身。5. 常见问题速查表实操中直接对着查现象可能原因排查与解法标签嵌套关系错乱图片后被识别为子元素节点缺少void_elements集合补全空元素集合确保它们不出栈属性值变成乱码或额外多出属性名属性值中含有非法字符或引号不配对改用状态机扫描属性区不依赖正则大法页面文字整体带上一行缩进纯空白文本节点被保留在词法阶段丢弃纯空白文本节点多层嵌套页面渲染时崩溃递归遍历造成栈溢出布局阶段改为显式栈迭代同一个元素样式忽大忽小CSS规则的层叠顺序不对检查优先级元组是否按 id / class / 标签正确排序文本里的被误判成标签词法分析没有做否定判断在match_tag失败时把当作普通字符处理解析结束后DOM树多出若干个孤立的空节点Token流里混入了注释或DOCTYPE在词法阶段显式跳过!-- --与!DOCTYPE这张表是我在实际开发中记录下来的每一条背后都是至少一小时的排查。这里想多说一句解析器的调试和业务代码调试有个很大不同——你没法只靠“输出不符合预期”来判断错误位置因为错误可能在词法、建树、样式、布局任何一个环节产生。所以打日志时最好每一层都留一个可验证的中间输出物。我的Token流和DOM树在跑任何一个用例的时候都会打印到文件里出错时先缩小范围再对症下药。6. 这个项目给我的长期影响做完“HTML 2”之后我再看普通网页视角已经完全变了。以前我只关心页面好不好看、交互顺不顺现在我会下意识地去看“这段标签在解析器里会怎么处理”那些以前觉得奇怪的渲染行为——比如某些标签自动闭合、某些空白被折叠、某些CSS样式没有生效——现在都有了确切的答案。这个项目也让我对“学习底层”这件事本身有了新的体会。同样是写一个解析器如果一开始就照着浏览器内核源码去读很容易被复杂度劝退但先用量级小很多的实现跑通全流程建立心理模型再回头看那些复杂的源码你会发现那些看似艰深的算法其实底层全都是些朴素的想法。栈、递归下降、优先级比较、盒模型这些概念任何一个学过编程的人都懂真正难的只是把它们组合成一个能工作的系统。如果你也想做一个类似的项目我的建议是不要试图复刻HTML5的完整解析算法那是一个巨大的工程。先从一个“只支持核心标签”的极简版本开始能解析、能建树、能渲染就已经成功了一半。后面想加的东西都是在这个稳定的骨架上长出来的血肉。任何一次“浏览器还是很了不起”的感叹都比不上自己亲手写完一个极简解析器来得真实。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →