ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

CHTMLDome2:用C语言实现HTML DOM解析与操作

CHTMLDome2:用C语言实现HTML DOM解析与操作

简介:一份基于MFC CHtmlView类的工程示例包,面向需要在桌面程序中嵌入HTML/JavaScript界面的Windows开发者。压缩包共57个文件,约645KB,涵盖完整的Visual Studio解决方案(.sln/.vcxproj)、C++源文件与头文件、HTML页面、JavaScript脚本、CSS样式及PNG/JPG图片资源,目录结构清晰,便于直接打开工程对照学习。已有447人学习/下载。示例工程演示了如何利用CHtmlView加载本地HTML页面,并通过ExecWB执行JS脚本、借助IDocHostUIHandler和ActiveX实现C++与JavaScript的双向调用,同时包含登录/注册页面、图表统计等典型交互场景,代码中附有MFC对话框集成CHtmlView的完整实现,适合希望快速掌握Web技术在MFC中落地的开发者参考与二次开发。

1. CHTMLDome2 到底是什么:先说结论再拆细节

很多开发者在服务端处理 HTML 时,第一反应是上 Python 或 Node,但业务一旦部署到只有 C 编译器、内存按兆计算的环境里,这条路直接断掉。CHTMLDome2 就是为这类场景准备的:一个用 C 实现的 HTML DOM 解析库,能把 HTML 文本解析成可遍历、可修改、可重新序列化的节点树。它的价值不在“能读标签”,而在于让没有运行时、没有脚本引擎的进程也具备完整的 HTML 文档操作能力。适合两类人:一类是在受限环境里做 HTML 结构化读取的嵌入式与服务端开发者,另一类是研究和实现 HTML 解析器的协议分析型开发者,后者还能从它的节点组织方式里学到不少东西。

2. 架构与设计逻辑:从文本到可修改的 DOM 树

2.1 三条实现路线对比,为什么最终选了状态机加节点栈

实现一个 HTML 解析器,常见路线有三条:正则逐行匹配、SAX 风格回调、构建完整的 DOM 树。正则适合从固定格式文本里抽字段,HTML 的嵌套标签一多,正则的匹配深度和维护成本立刻失控;SAX 回调流式处理大文件很高效,但它是单遍扫描,拿到的是一个事件流,不是一棵能任意改动的树。CHTMLDome2 选择了第三条路:先把整段 HTML 当作字节流扫描一遍,同时用一棵节点树把结构保存下来。

实现路线内存占用结构化查询修改后重新输出适用场景
正则逐行匹配低差困难抽文本、简单替换
SAX 事件回调低无不支持流式过滤、大文件分析
DOM 树构建中强支持需要增删改查再序列化

选择 DOM 树意味着要付出额外的内存和构建时间,但换回来的是对节点的随机访问、修改和重新输出能力。对于服务端缓存页面的字段抽取、模板片段替换这类任务,这是唯一不别扭的方案。

2.2 解析流程:一次扫描加一个节点栈

CHTMLDome2 的解析过程分三步:词法扫描、结构建树、后处理。词法扫描把字符流切成 Token,核心是一个状态机:标签名、属性名、属性值、文本、注释、DOCTYPE 都有对应状态。HTML 和 XML 最大的不同是标签名大小写不敏感、属性值引号可以被省略、部分结束标签可以缺省,所以词法阶段处理起来比 JSON 解析繁琐得多。

结构建树阶段维护一个节点栈:遇到开始标签就压栈,遇到结束标签就弹栈,文本节点挂在栈顶元素下。这里有个 HTML 特有的隐含闭合逻辑,例如<p>后跟另一个<p>,前面那个会触发自动闭合;<td>遇到下一个<td>或</tr>时也要提前关闭。CHTMLDome2 在内部放了一张小规则表,不依赖外部 doctype 声明,默认按 HTML5 语法处理。后处理阶段负责把 script、style 标签内的内容整体标记为文本块,避免特殊字符干扰结构分析。

2.3 节点模型:为什么用索引而不是指针

很多手写解析器喜欢用struct node *parent这类指针把树串起来,CHTMLDome2 没这么干。它把所有节点放在一个连续数组里,父子、兄弟关系全部用数组下标表示。这么做有三个好处:节点内存连续分配,CPU 缓存命中率更高;序列化时按数组顺序遍历就能稳定输出;整个文档可以整体拷贝或整体释放,不容易出现指针悬挂。

typedef struct ch_dom_node { uint32_t type; /* 节点类型:元素/文本/注释/CDATA */ uint32_t flags; /* 解析标志:隐式闭合、自闭合、容错位 */ uint32_t parent; /* 父节点在节点数组中的索引 */ uint32_t next_sibling; /* 下一个兄弟节点索引 */ uint32_t first_child; /* 第一个子节点索引 */ uint32_t text_off; /* 文本内容在文档缓冲区中的偏移 */ uint32_t text_len; /* 文本字节长度 */ uint32_t attr_slot; /* 属性表在属性池里的槽位 */ } ch_dom_node_t;

这段结构体是理解整棵树的关键。next_sibling和first_child组合成孩子链表,配合parent可以不用递归也能回溯祖先链。text_off指向文档缓冲区里的绝对偏移,而不是独立的字符串指针,这样文本节点不用再单独 malloc,整个文档的文本数据都集中在同一块缓冲区里,解析和释放都省事。属性表也用类似思路,统一存在独立的属性池,节点只保存一个槽位号。

对第一次接触这种布局的读者,我建议把它当成“数组版二叉树”来理解:虽然树本身是逻辑结构,但在内存里是扁平的。后续做遍历、查找、子树替换时,操作的是整数索引,而不是容易失效的裸指针,排查问题时会轻松很多。

3. 编译和跑通最小示例:从源码到能解析的静态库

3.1 构建命令与调试开关

CHTMLDome2 不依赖第三方库,标准 C 编译器就能编过。拿到源码后进入项目根目录,先做一次干净构建。我习惯把构建参数显式写出来,而不是直接敲make,这样出问题能一眼看出是哪个宏导致的。

make clean make BUILD=release \ CC=gcc \ CFLAGS="-O2 -Wall -Wextra -fno-omit-frame-pointer" \ CH_DOM_DEBUG=0 ar rcs build/libchtml.dome2.a build/*.o

这里BUILD=release会关掉断言和内部详细日志;CH_DOM_DEBUG=0是配套开关,调试阶段把它设为 1,会强制开启越界检查和节点一致性校验。-fno-omit-frame-pointer是我强烈建议保留的编译参数,它能保证后续用 perf 或 gdb 分析时栈回溯信息完整,尤其在定位递归遍历导致的栈溢出时,没有帧指针基本没法定位问题。

构建产物是一个静态库libchtml.dome2.a,头文件在include/chtml/dome2.h。接入你自己的项目时,只需要把这个头文件路径加入-I,再链接静态库即可。如果目标环境是交叉编译,把CC换成对应的交叉编译器前缀,一般不需要改动源码。

3.2 最小解析与遍历示例

下面这段代码是 CHTMLDome2 最常见的入门用法:解析一段 HTML,遍历整棵节点树,打印每个节点的标签名和文本内容。

#include "chtml/dome2.h" #include <stdio.h> /* 遍历回调:每遇到一个节点都会调用一次 */ static int dump_node(ch_dom_node_t *node, void *ctx) { const char *tag = ch_dom_tag_name(node); const char *text = ch_dom_text(node); printf("type=%u tag=%s text=%s\n", node->type, tag ? tag : "", text ? text : ""); return 0; } int main(void) { const char *page = "<main><p>你好</p><a href='/go'>Go</a></main>"; ch_dom_doc_t *doc = ch_dom_parse(page, strlen(page), CH_PARSE_STRICT); if (!doc) { fprintf(stderr, "parse failed\n"); return 1; } ch_dom_walk_preorder(doc->root, dump_node, NULL); ch_dom_doc_free(doc); return 0; }

ch_dom_parse的第一个参数是 HTML 字符串,第二个是字节长度,第三个是解析模式。CH_PARSE_STRICT表示遇到明显结构错误时直接中断并返回空,适合处理自己生成的受控文档;处理线上不可控页面时,我一般改用CH_PARSE_RELAXED,它会跳过错误节点继续解析,不至于一起步就失败。ch_dom_walk_preorder是前序遍历,默认实现是递归的,节点深度不大没问题,深度上千层时请切换到迭代版本,后面避坑那章会讲。

3.3 修改节点并重新序列化

解析只是第一步,CHTMLDome2 真正的优势在于能改完再吐出来。下面的例子找到第一个<a>标签,修改它的href属性和文本,然后序列化成新的 HTML 字符串。

ch_dom_node_t *node = ch_dom_find_by_tag(doc->root, "a"); if (node) { /* 第二个参数是属性名,第三个是新值,第四个是覆盖标志 */ ch_dom_set_attr(node, "href", "/new-path", 1); ch_dom_set_text(node, "点这里"); } char *out = ch_dom_serialize(doc, 0); if (out) { fputs(out, stdout); free(out); /* 序列化结果由库内分配,必须用 free 释放 */ }

ch_dom_set_attr的第四个参数含义是“是否覆盖已有同名属性”,填 0 时如果属性已存在会返回错误码,这个设计是为了让调用者显式决定行为,避免不小心把原有配置项冲掉。ch_dom_serialize的第二个参数是格式化标志:0 输出紧凑模式,1 输出带缩进的模式。缩进模式方便人工排查,但体积更大,生产环境建议用紧凑模式。改完文本后不需要手动标记脏节点,序列化时会自动读取最新内容。

4. 用 CHTMLDome2 的避坑与排查:五个高频翻车现场

4.1 未闭合标签被吞:先看容错模式

现象:解析<table><tr><td>内容这类片段时,<td>后面的节点全部挂到了错误的位置,甚至整棵子树直接消失。

原因:默认的CH_PARSE_STRICT不允许缺失闭合标签,遇到结构错误就中断解析,剩下的内容被当作垃圾丢弃。

解决:处理不完整的文档片段时,改用CH_PARSE_RELAXED,或者通过解析选项控制容错级别。

ch_dom_parse_opts_t opts = CH_PARSE_OPTS_DEFAULT; opts.relaxed = 1; /* 允许缺失闭合标签,自动补齐 */ opts.decode_entities = 1; /* 仍要解码 &amp; 这类实体 */ ch_dom_doc_t *doc = ch_dom_parse_ex(html, len, &opts);

注意,CH_PARSE_RELAXED会尝试用 HTML5 的隐式闭合规则补全结构,但补出来的树不保证和你脑子里“正确的 DOM”一致。比如<p>one<p>two会被解析成两个相邻的<p>,而不是嵌套结构,这是规范行为,不是 bug。确认业务预期的嵌套层次后再决定要不要开容错。

4.2 属性实体被二次解码:&amp;变成&

现象:从 HTML 里抽出一个跳转地址,拼到 URL 上之后参数丢失了,排查发现属性值里的&amp;已经被转成&。

原因:解析器默认对文本和属性值做了实体解码,这是正确行为。但很多人在拿到属性值后再次进行 URL 解析,把&当成了参数分隔符。

解决:读取属性原始值时用ch_dom_attr_raw()接口,它返回未解码的文本;需要真正展示时再自己解码一次。如果整个项目不需要解码实体,可以在opts里把decode_entities置 0。一进一出要统一,混用解码和未解码的取值接口是这类问题最常见的根源。

4.3 修改 DOM 后再序列化:属性顺序漂移

现象:用ch_dom_set_attr给节点加了一组新属性,序列化后发现输出的属性顺序和写入顺序不一致,导致某些严格比较哈希的逻辑失败。

原因:内部属性表不是数组,而是类似哈希桶的结构,遍历顺序由哈希值决定,不保序。

解决:需要保持原有书写顺序时,解析阶段设置opts.preserve_attr_order = 1,这会触发属性池走顺序列表模式。代价是每次属性查找从 O(1) 变成 O(n),属性数量少的场景完全无所谓,属性动辄几十个的文档才需要评估。还有个备用方案:序列化后不做字符串比对,先解析成树再比对结构。

4.4 替换节点后内存只增不减:旧子树没有回池

现象:一个长时间运行的进程里频繁调用子树替换接口,RSS 持续上涨,但进程退出时内存能正常释放。

原因:替换节点时,旧子树只是从树上摘下来了,它的节点对象和文本缓冲没有回到空闲链表里,导致内存池里的复用机制失效。

解决:替换前调用ch_dom_node_invalidate(node)把整棵子树标记为可回收,再执行替换。编译时开启CH_DOM_USE_MEMPOOL宏,节点对象会从内存池分配,容量紧张时会自动复用空闲槽位。这个坑最容易在压测时漏掉:功能测试跑一两次退出,看不出问题;连着跑几十万次请求,内存曲线立刻暴露。

4.5 深度嵌套导致栈溢出:递归遍历的锅

现象:处理几千层嵌套div的 HTML 时,程序在遍历阶段崩溃,gdb 看到调用栈全是自身递归。

原因:ch_dom_walk_preorder默认递归实现,深度过高会打爆线程栈。一个节点的栈帧即使只有几十字节,几千层下来也很可观。

解决:调用ch_dom_walk_iter()显式使用迭代版遍历,它内部维护一个显式栈,深度上限受堆内存限制,不再受线程栈约束。另一个治本思路是调大线程栈,但线上服务线程很多,每线程多分配几百 KB 代价不小,能用迭代版就不改栈大小。这条对所有类似结构的树型解析库都成立,遇到崩溃先确认是不是递归,别急着怀疑内存问题。

5. 进阶用法与验证方法:让 CHTMLDome2 跑得更快、更稳

5.1 批量解析场景下复用上下文

批量处理大量 HTML 页面时,反复创建和销毁解析上下文很浪费,内部缓冲区每次都要重新分配。

ch_dom_ctx_t *ctx = ch_dom_ctx_new(CH_CTX_REUSE_BUFFERS); for (int i = 0; i < page_count; ++i) { ch_dom_doc_t *doc = ch_dom_parse_ctx(ctx, pages[i], lens[i], &opts); process_doc(doc); ch_dom_doc_free(doc); /* 缓冲回收到 ctx,而不是还给操作系统 */ } ch_dom_ctx_free(ctx);

实现了文档对象和解析缓冲的分离,缓冲区在ctx里循环使用,内存分配次数大幅下降。在线程安全上也要注意:一个ctx同时只能有一个解析任务,多线程场景给每个线程单独建一个。

5.2 流式增量解析分段到达的 HTML

从网络接收响应体时,完整文档往往要等一会儿才能收齐。与其收完后一次性解析,不如边收边喂给流式解析器。

ch_dom_doc_t *doc = NULL; ch_dom_stream_t *stream = ch_dom_stream_begin(&doc, &opts); ch_dom_stream_push(stream, chunk1, len1); ch_dom_stream_push(stream, chunk2, len2); ch_dom_stream_end(stream); /* 所有数据到齐,完成建树 */

注意跨 chunk 边界的标签不会被截断,库内部会暂存不完整的 Token,等后续数据到来后再拼接,所以外部无需处理边界。代价是如果数据长期不完整,暂存的 Token 会占住内存,流式场景应有超时兜底。

5.3 回归验证脚本:把畸形 HTML 也写进测试集

给 CHTMLDome2 做回归测试时,标准 HTML 用例远远不够,真正容易踩雷的是断标签、属性不带引号、实体混合、UTF-8 边界这类畸形输入。我的做法是维护一个测试用例目录,每个 HTML 配一个期望输出文件,批量跑比对。

for f in tests/cases/*.html; do ./bin/htmlcheck --strict "$f" > /tmp/got.txt diff -q /tmp/got.txt "tests/expect/$(basename "$f").txt" >/dev/null \ || echo "[FAIL] $f" done

跑完看输出的 FAIL 列表就能定位回归点。加入解析模式参数、实体解码开关、属性顺序模式三个维度的组合用例,比单纯堆几百个正常 HTML 更能暴露问题。

我在这套库上吃过最大的亏,是生产环境用了默认严格模式,导致一批线上页面解析中断,后来才意识到接入新输入源时第一件事不是调 API,而是先确认输入类型、容错级别、实体解码三个参数。从那以后,每次接手新的解析任务,我都先把这三个参数写进配置而不是散落在代码里。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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