html4cj访问者模式详解:用NodeVisitor与NodeFilter实现自定义节点遍历和过滤
【免费下载链接】html4cj一个HTML格式解析库项目地址: https://gitcode.com/Cangjie-TPC/html4cj
html4cj 是一个功能完整的 HTML 解析库,可将网页 HTML 解析为 DOM 树并支持 CSS 选择器查询。本文面向新手,完整讲解 html4cj 中访问者模式的两大核心接口 NodeVisitor 与 NodeFilter:如何自定义节点遍历、如何过滤与删除节点,以及它们在库内部的实际应用,帮你快速上手这套树遍历机制。
一、为什么需要访问者模式?🌳
html4cj 解析 HTML 后,会得到一棵 DOM 树:文档、元素、文本、注释都是节点(Node)的实例。而很多需求都绕不开"把树上的节点逐个处理一遍":
- 统计页面中所有
img标签 - 清理 HTML 中的
script、style标签 - 把整个文档序列化回 HTML 字符串(
outerHtml) - CSS 选择器查询
doc.select("div p")
如果每次都写一套递归逻辑,代码就会大量重复。html4cj 的解决方案是访问者模式(Visitor Pattern):由库负责"怎么走树",你只需要提供"走到每个节点时做什么"的回调。整个流程如图所示——解析器先构建 DOM 树,之后的所有遍历都发生在这棵树上:
这套机制的核心由三个文件组成:
- 访问者接口:node_visitor.cj
- 过滤器接口:node_filter.cj
- 遍历引擎:node_traversor.cj
二、NodeVisitor:两行回调实现自定义节点遍历
NodeVisitor是一个极简接口,只有两个回调方法:
| 方法 | 触发时机 | 说明 |
|---|---|---|
head(node, depth) | 首次访问节点时 | 必须实现,参数为当前节点和相对根节点的深度 |
tail(node, depth) | 访问完所有子孙节点后 | 有默认空实现,只关心head时可不重写 |
使用方式非常直接:任何节点(Node、Element、Document)和元素列表(Elements)都内置了traverse()方法,例如 node.cj 和 elements.cj。一个典型的"统计所有元素数量"的访问者大致如下:
class CountVisitor <: NodeVisitor { public override func head(node: Node, depth: Int64): Unit { if (node is Element) { count++ } } } // 使用:doc.traverse(CountVisitor())遍历引擎 NodeTraversor 内部采用深度优先策略:先向下钻进第一个子节点,遇到叶子后再沿兄弟节点横移,没有兄弟时逐级回退并触发tail回调。整个过程你无需关心,只需专注回调逻辑。
💡 小细节:
traverse是安全的动态遍历——即使你在head回调中删除或替换了当前节点,引擎也能正确继续(它会在访问前预先记录父节点和兄弟位置),所以访问者里做增删改是安全的。
三、NodeFilter:用5种过滤决策控制遍历走向 ✂️
如果只"看"不够,还想改变遍历路径或直接修改树,就轮到NodeFilter上场了。它与NodeVisitor结构相同(同样是head/tail两个回调),区别在于回调返回的不是 Unit,而是一个FilterResult决策值,共 5 种,定义见 node_filter.cj:
| 决策值 | 效果 | 典型用途 |
|---|---|---|
CONTINUE | 继续正常遍历(默认) | 不干预 |
SKIP_CHILDREN | 跳过该节点的子树,但仍会调用tail | 只处理某层标签本身 |
SKIP_ENTIRELY | 跳过子树且不再调用tail | 快速略过无关区域 |
REMOVE | 删除该节点及其所有子节点 | 清理 script/style 标签 |
STOP | 立即终止整个遍历 | 找到第一个目标后停止 |
使用方式同样是内置的filter()方法,见 node.cj。一个"删除所有 script 标签"的过滤器:
class ScriptCleaner <: NodeFilter { public override func head(node: Node, depth: Int64): FilterResult { if (node is Element && (node as Element)().normalName() == "script") { return FilterResult.REMOVE } return FilterResult.CONTINUE } } // 使用:doc.filter(ScriptCleaner())引擎对REMOVE的处理非常讲究:会先找到父节点/兄弟节点再执行删除(见 node_traversor.cj),避免删除后游标悬空导致遍历错乱——这也是你使用REMOVE时不需要任何额外处理的原因。
四、对比速查:遍历 vs 过滤怎么选?⚖️
| 维度 | NodeVisitor(traverse) | NodeFilter(filter) |
|---|---|---|
| 回调返回值 | 无(Unit) | FilterResult决策 |
| 能否修改树 | ✅ 支持删除/替换,引擎自动容错 | ✅ 支持REMOVE剪枝 |
| 能否改变遍历路径 | ❌ 固定深度优先走完全树 | ✅ 可跳过、可提前STOP |
| 典型场景 | 收集信息、生成输出 | 清洗 HTML、查找首个匹配 |
| 入口方法 | node.traverse(visitor) | node.filter(nodeFilter) |
一句话总结:只读分析用 Visitor,需要修剪或提前终止用 Filter。
五、实战彩蛋:html4cj 内部就是这么用的 🔍
这套模式不是摆设,库内部的核心功能全部基于它构建,值得对照源码学习:
- HTML 序列化:
outerHtml()使用的OuterHtmlVisitor,把每个节点的 HTML 片段按顺序追加到字符串缓冲中,见 node.cj - CSS 选择器查询:
doc.select("a")背后的 Collector 就是一个NodeVisitor——每个head回调里用选择器求值器测试节点,匹配则收入结果集 - selectFirst 提前终止:
FirstFinder是一个NodeFilter,命中第一个匹配元素立即返回STOP,避免扫完全树,见 collector.cj - 文本提取:
Element.text()通过TextNodeVisitor遍历收集文本节点,见 element.cj
六、新手常见坑点提醒 ⚠️
- 不要手动递归替代 traverse:手写递归很难处理"遍历中节点被删除/替换"的边界情况,
NodeTraversor已替你处理 SKIP_CHILDREN与SKIP_ENTIRELY的区别在于是否仍触发tail,若你的tail里有清理逻辑要选对REMOVE是连子节点一起删,只想删标签外壳保留内容时应改用unwrap()这类 APISTOP只终止当前过滤,对Elements批量过滤时,会在命中STOP后停止处理后续元素,见 node_traversor.cj
结语
html4cj 通过NodeVisitor+NodeFilter这两个小接口,把"树怎么走"和"节点做什么"彻底解耦:几行回调即可实现自定义遍历、节点清洗、提前终止查询等操作,这正是访问者模式在解析库中的教科书式落地。想进一步熟悉全部 API,可查阅官方接口文档 doc/feature_api.md,配合 node_traversor.cj 的源码,基本就能玩转 html4cj 的树遍历体系了。
【免费下载链接】html4cj一个HTML格式解析库项目地址: https://gitcode.com/Cangjie-TPC/html4cj
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考