ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

轻量级无依赖HTML解析引擎CHTMLDome2的设计与实现

2026/9/7 8:35:48 拓冰建站 浏览量
轻量级无依赖HTML解析引擎CHTMLDome2的设计与实现 简介面向MFC桌面应用开发者CHTMLDome2工程演示了如何用CHtmlView在对话框内嵌入HTML页面并实现C与JavaScript的双向调用。它不只做网页展示还深入到IWebBrowser2接口、ExecWB命令执行、IDocHostUIHandler事件回调以及通过ActiveX把C函数暴露给JavaScript调用的完整链路适合需要给传统MFC程序补充Web前端交互能力的开发者参考。压缩包共57个文件、约645KB核心代码由8个C头文件和6个C源文件组成配有Visual Studio的sln与vcxproj工程文件前端资源包含6个JS脚本、3个HTML页面和2个CSS样式另有18个PNG和3张JPG图片用于界面素材。整个示例构成了一个带登录、注册与数据管理界面的可运行工程代码结构清晰便于逐个文件对照理解浏览器控件事件流转及C与JavaScript互相调用的落地方式。整体已有447人学习适合MFC方向中级开发者作为综合参考。 去年做静态站生成器的时候我实在受够了解析HTML模板这件事。正则表达式匹配标签看起来挺美好一旦碰到嵌套引号、属性值里的转义字符直接崩给你看换成jsdom又觉得杀鸡用牛刀第三方依赖几百个启动一次要几十毫秒在一个只为了把模板字符串变成节点树的场景里这开销实在说不过去。后来我索性自己写了一个解析引擎这就有了CHTMLDome2——一个无依赖、单文件可嵌入、专门处理轻量级HTML类文档的解析与DOM操作引擎。这个项目包含三块核心内容CHTML自定义标记语言的语法定义、字符级词法分析器、DOM树构建与选择器引擎。它不是要取代浏览器也不是要跟jsdom掰手腕而是想解决在非浏览器环境里快速解析类HTML结构这个具体问题。对做静态站工具、爬虫数据清洗、桌面端内嵌预览组件的开发者来说CHTMLDome2 能省掉不少折腾。1. 为什么会有这个项目被正则和jsdom同时折磨之后1.1 找了好几个替代方案没有一个顺手先说清楚我踩过的三个大坑你就明白这个项目的出发点是什么了。第一个方案当然是正则。很多教程教人用/[^]/g匹配标签这招对付简单字符串够用但HTML的折磨点恰恰在边界。属性值里有一个字符比如>function tokenize(input) { const tokens []; let state Data; let buffer ; let i 0; while (i input.length) { const ch input[i]; if (state Data) { if (ch ) { flushText(tokens, buffer); buffer ; state TagOpen; } else buffer ch; } else if (state TagOpen) { if (ch /) state EndTagOpen; else if (isAlpha(ch)) { state TagName; buffer ch; } // 其他分支... } // TagName、AttributeName、AttributeValue 等状态按同样模式继续 i; } flushText(tokens, buffer); return tokens; }写完这个状态机我最大的体会是状态不要贪多宁可拆细一点。第一版我把属性名和属性值合在了一个状态里判断结果引号和空格的处理互相干扰调试了两天才发现是状态划分的问题。做状态机这件事省状态的冲动永远是bug的来源。3.2 解析容错策略栈式构建与自动闭合Token流生成之后构建DOM树我用了一个显式栈。遇到开标签就入栈遇到闭合标签就检查栈顶是否匹配匹配则弹出不匹配就视情况忽略。这个逻辑非常直观是v2容错设计的核心。自动闭合的规则只有两条当开标签的父级已经关闭时该标签自动视作已闭合。遇到空元素自闭合时立即闭合。碰到p一段文字div块/div/p这种跨元素嵌套v2的应对是在div入栈时发现它跟当前栈顶p不匹配且p不是合法的父容器就先把p自动闭合再处理div。这个策略比HTML5的复杂规则简单得多好解释也好调试。代价是遇到特别诡异的页面结构时解析结果跟浏览器会有差异但对于CHTML面向的结构良好文档这个容错等级已经够用。迭代加显式栈的另一个好处是不会栈溢出。第一版的递归下降解析在处理3000层嵌套标签时直接爆栈v2改用循环加栈结构后这个上限变成了内存上限十万层的恶意输入也能稳定跑完。3.3 选择器引擎从右到左匹配的意义DOM树构建好之后查询能力是使用体验的关键。v2实现的选择器支持标签名、#id、.class、[attr]、[attrvalue]、空格后代选择器、子代选择器。不支持伪类、不支持分组选择器逗号之外的复杂语法这是刻意做的减法。选择器匹配用的是从右往左先对右半部分做一次遍历收集候选节点再沿祖先链验证左边条件。为什么从右往左因为候选集通常会随着匹配的深化快速收缩。比如div .item a这个选择器如果从左往右先找所有div再逐层下探可能要遍历整棵树从右往左先在全部节点里找出所有a标签数量往往远小于div的数量再逐个沿祖先链检查是否符合前面两级条件整体性能明显更好。选择器引擎里最容易被忽略的是属性值比较。[typetext]里的text要不要做大小写归一化CHTML的选择器规范里属性值统一做区分大小写比较但type这种枚举属性的值除外。这个特例是我从实际使用中总结出来的毕竟HTML的type属性标准值都是小写真到了页面上被人写成Text浏览器也会识别不做归一化就跟浏览器行为不一致了。4. API设计我砍掉了jQuery八成的功能4.1 节点模型先定下来API设计的前提是节点模型清晰。v2的节点类型只有四种Document、Element、Text、Comment。每个节点统一维护三个字段parent、children、attributes后两者对Text节点是空。Element节点额外有tagName和classList。我没有单独做ClassList对象而是在节点上直接挂了一个classNames数组用addClass、removeClass、hasClass三个方法操作。为什么不做成浏览器那样的可迭代对象因为引擎的目标是轻量、直观提供三个常用方法比模拟整套DOMTokenList API的性价比高得多。4.2 节点操作API的设计取舍v2提供的方法集中在增删查改四个维度querySelector、querySelectorAll、append、remove、attr、text、html。这些方法名直接借鉴jQuery的语义用过的开发者上手零成本。text和html这两个方法有一个双向语义的设计不传参时读取内容传参时设置内容。这个设计沿用至今没有出过问题。但有个小坑是html方法设置的字符串会触发重新解析如果传入的内容和原节点存在引用关系比如parent.html(parent.html())第一次获取的字符串会被二次解析后替换原节点这对副作用敏感的调用方是个隐患。我在文档里明确建议读用html()写完再读用变量保存不要链式连续调用。4.3 事件与异步这个引擎不做的事我必须坦白v2不实现事件冒泡、捕获、也不做异步渲染调度。这跟浏览器环境的最大差别在于CHTMLDome2的DOM树是一个纯静态结构它描述的是文档而不是活生生的交互页面。事件系统只提供了一个微型的on/emit用来在组件间做解耦通知实现只有三十行跟浏览器EventTarget完全不是一回事。这个减法基于一个判断如果用户需要完整的浏览器事件模型他们应该用jsdom如果只是做文档转换、数据抽取、模板渲染事件系统是纯粹的负担。把不需要的功能从引擎里拿掉比往里面加功能难得多但这恰恰是v2能保持轻量的根本原因。5. 性能实测与三个关键优化5.1 和jsdom、正则方案的对比我用一份108KB的HTML页面做了个简单基准测试内容是典型的博客文章页包含嵌套列表、表格、大量a和span标签。测试机器是M1 MacBook AirNode.js 18。方案解析耗时峰值内存占用备注正则提取链接3ms8MB只能提取链接无法构建结构jsdom 默认配置240ms210MB功能完整代价沉重CHTMLDome2 v228ms42MB完整DOM树查询能力数据最能说明问题。CHTMLDome2的解析耗时大约是jsdom的九分之一内存是五分之一而查询能力覆盖了我在业务里百分之九十的使用场景。这不是说jsdom不好而是场景不同——jsdom是直升机CHTMLDome2是越野车你不能开着直升机去挤小巷子也不能让越野车去执行空投。5.2 三个从性能剖析里捞出来的优化第一版跑完基准测试我做了三次明显的优化每次实测都能省掉一半以上的时间。第一次优化是字符串拼接。Tokenzier里大量使用buffer ch这种逐字符拼接V8在字符串不长的时候会走优化路径但一旦单个文本节点超过几十KB反复拼接就会触发大量的内存复制。改法是用数组暂存字符片段最后join()一次成型长文本场景下的耗时直接降了约60%。第二次优化是跳过无用文本。源文档里标签之间有大量空白字符这对解析器来说是纯噪音。我在Tokenizer里加了一个标记位当文本节点整体由空白组成且不在pre标签内时直接丢弃不生成Text节点。这个优化让最终DOM树的节点数量减少了约四成选择器匹配和遍历的收益很可观。第三次优化是对属性存储结构的调整。第一版用对象字面量存属性属性数量多时哈希表的开销不小。v2改成两个平行数组attrNames和attrValues属性名到值的查找改用线性扫描属性超过二十个的场景才需要O(n)的复杂度日常三到五个属性的场景反而更快。6. 重写过程中踩过的坑6.1 自闭合标签的边界情况还是出了事故规则定得再清楚实现时也会有疏漏。v2上线后的第一个bug就是svg标签。SVG里有大量自闭合语法比如circle cx1 cy2 r3 /我把svg当成普通自定义标签处理结果里面的path /全部被解析成了开标签文本节点渲染出来完全不对。修复方案是我重新给svg和math两个命名空间下的所有标签单独建了一张自闭合白名单。这个补丁虽然只解决特定场景但它暴露了一个设计层面的问题自闭合规则永远不能只靠自定义标签一律不允许一句话覆盖必须给命名空间留出可扩展的位置。6.2 属性值里的引号转义第二个坑出现在属性值解析的细节。titleIt\s a test这种写法单引号包裹的属性值里出现了反斜杠转义的单引号。HTML规范在这一点上有个反直觉的行为它不会把\当转义处理而是把这个位置当作属性值结束后面跟着的s a test直接变成垃圾token。让我意外的是实际收到的业务文档里真有这种写法而且不是手写的是有个旧系统导出时生成的。v2的处理是在单引号值模式下遇到\时保留反斜杠并且把当作普通字符消费掉。虽然这偏离了HTML规范的严格行为但兼容真实输入本来就是解析器存在的意义。6.3 删除节点时的循环引用最后一个坑是内存泄漏。remove方法最初只做了父节点的children数组弹出被移除的子树上还持有指向父节点的parent引用在事件监听器或缓存里被间接引用时整棵子树都无法被垃圾回收。修复很直接移除节点时递归清空子树上所有节点的parent引用和children数组。对一棵深度几十的子树这个递归遍历的开销完全可以接受但它必须被严格执行漏掉任何一层泄漏就又悄悄回来了。做CHTMLDome2这一路下来我最大的感受是写解析器最难的从来不是代码本身而是做取舍的勇气。哪些语法要支持、哪些畸形输入要兼容、哪些性能优化值得投入每一个决定背后都对应着一类真实场景。如果你也想做类似的轻量解析工具我的建议是从语法边界定义开始写文档而不是先写代码——把规则想清楚了代码只是规则的自然翻译。v2的完整代码已经整理好放在仓库里用npm install chtmldome2就能直接安装遇到需要在Node里解析HTML类文档的场景不妨拿它试试水。本文还有配套的精品资源点击获取