深入解析 cascadia:为 Go 的 x/net/html 节点树实现 CSS 选择器查询
2026/9/20 20:23:38 网站建设 项目流程
  • 云原生
  • CLI
  • 应用安全

【免费下载链接】slim

Slim(toolkit): Don't change anything in your container image and minify it by up to 30x (and for compiled languages even more) making it secure too! (free and open source)

项目地址:https://gitcode.com/gh_mirrors/slim/slim
点击查看免费下载

导读

本文围绕当前仓库中随附的第三方依赖 cascadia(github.com/andybalholm/cascadia,仓库中固定为 v1.3.2)展开,完整介绍如何用 CSS 选择器语法查询golang.org/x/net/html解析出的 HTML 节点树。你将掌握Parse/Query/QueryAll等核心 API、支持的完整选择器语法与组合符、底层匹配与特异性(Specificity)实现原理,并拿到一份可复制运行的定价页解析实战代码。在 Slim 项目中,该库以 vendor 方式随仓库分发,任何依赖 HTML 结构提取信息的 Go 代码都可以直接复用这套能力。

一、cascadia 是什么

cascadia 是一个纯 Go 实现的 CSS 选择器库,定位非常明确:html包(即golang.org/x/net/html)产生的解析树提供 CSS 选择器匹配能力。它不负责解析 HTML,只负责在已经解析好的*html.Node节点树上完成"选择器 → 命中节点集合"的转换。

在当前仓库中,该库位于 vendor/github.com/andybalholm/cascadia,从 go.sum 可以看到其锁定版本为github.com/andybalholm/cascadia v1.3.2(源码由 parser.go、selector.go、pseudo_classes.go、specificity.go、serialize.go 五个文件构成)。这意味着使用本项目构建的代码可以直接import "github.com/andybalholm/cascadia"而不需要额外下载依赖。

二、核心 API 一览

cascadia 的公开接口集中定义在 selector.go 中,核心抽象是两层接口:

  • Matcher(selector.go#L11-L15):只要求Match(n *html.Node) bool,是最基本的匹配能力接口;
  • Sel(selector.go#L17-L27):在Matcher之上追加Specificity()String()PseudoElement(),是所有解析产物的统一形态。

围绕这两个接口,库提供了以下常用函数:

函数作用
Parse(sel string) (Sel, error)解析单个 CSS 选择器,不支持伪元素
ParseWithPseudoElement(sel string) (Sel, error)解析单个选择器,支持::before等伪元素
ParseGroup(sel string) (SelectorGroup, error)解析逗号分隔的选择器组
ParseGroupWithPseudoElements(sel string) (SelectorGroup, error)解析选择器组并支持伪元素
Compile(sel string) (Selector, error)兼容旧接口,返回可直接调用的Selector函数
MustCompile(sel string) SelectorCompile,出错时直接 panic
Query(n *html.Node, m Matcher) *html.Node返回节点n后代中第一个命中m的节点
QueryAll(n *html.Node, m Matcher) []*html.Node返回节点n后代中所有命中m的节点
MatchAll/MatchFirstn自身及其后代中查找命中节点
Filter(nodes []*html.Node, m Matcher) []*html.Node从已有节点切片中筛出命中节点

需要特别注意的是Query/QueryAllMatchAll/MatchFirst的差异:前者的查询范围是n子孙后代,不含n自身;后两者则从n自身开始判断。从源码看,Query的实现(selector.go#L177-L188)从n.FirstChild开始深度优先遍历,因此对文档根节点执行Query(doc, sel)是最常见的用法。

另外,Parse系列函数对输入有严格校验:解析结束后若字符串还有剩余字节,会返回parsing %q: %d bytes left over错误(selector.go#L38-L40),这保证了非法或截断的选择器不会被静默接受。

三、完整实战示例:解析定价页

下面这段代码是 cascadia README 自带的完整示例,展示了从ParseQueryAll/Query的完整调用链,可直接复制运行。它的场景是:有一段包含三档订阅计划的 HTML 片段(Free / Pro / Enterprise),用 CSS 选择器把每档计划的名称、价格、人数、存储空间和详情链接全部抽取出来。

package main import ( "fmt" "log" "strings" "github.com/andybalholm/cascadia" "golang.org/x/net/html" ) var pricingHtml string = ` <div class="card mb-4 box-shadow"> <div class="card-header"> <h4 class="my-0 font-weight-normal">Free</h4> </div> <div class="card-body"> <h1 class="card-title pricing-card-title">$0/mo</h1> <ul class="list-unstyled mt-3 mb-4"> <li>10 users included</li> <li>2 GB of storage</li> <li><a href="https://example.com">See more</a></li> </ul> </div> </div> <div class="card mb-4 box-shadow"> <div class="card-header"> <h4 class="my-0 font-weight-normal">Pro</h4> </div> <div class="card-body"> <h1 class="card-title pricing-card-title">$15/mo</h1> <ul class="list-unstyled mt-3 mb-4"> <li>20 users included</li> <li>10 GB of storage</li> <li><a href="https://example.com">See more</a></li> </ul> </div> </div> <div class="card mb-4 box-shadow"> <div class="card-header"> <h4 class="my-0 font-weight-normal">Enterprise</h4> </div> <div class="card-body"> <h1 class="card-title pricing-card-title">$29/mo</h1> <ul class="list-unstyled mt-3 mb-4"> <li>30 users included</li> <li>15 GB of storage</li> <li><a>See more</a></li> </ul> </div> </div> ` func Query(n *html.Node, query string) *html.Node { sel, err := cascadia.Parse(query) if err != nil { return &html.Node{} } return cascadia.Query(n, sel) } func QueryAll(n *html.Node, query string) []*html.Node { sel, err := cascadia.Parse(query) if err != nil { return []*html.Node{} } return cascadia.QueryAll(n, sel) } func AttrOr(n *html.Node, attrName, or string) string { for _, a := range n.Attr { if a.Key == attrName { return a.Val } } return or } func main() { doc, err := html.Parse(strings.NewReader(pricingHtml)) if err != nil { log.Fatal(err) } fmt.Printf("List of pricing plans:\n\n") for i, p := range QueryAll(doc, "div.card.mb-4.box-shadow") { planName := Query(p, "h4").FirstChild.Data price := Query(p, ".pricing-card-title").FirstChild.Data usersIncluded := Query(p, "li:first-child").FirstChild.Data storage := Query(p, "li:nth-child(2)").FirstChild.Data detailsUrl := AttrOr(Query(p, "li:last-child a"), "href", "(No link available)") fmt.Printf( "Plan #%d\nName: %s\nPrice: %s\nUsers: %s\nStorage: %s\nDetails: %s\n\n", i+1, planName, price, usersIncluded, storage, detailsUrl, ) } }

运行后输出如下:

List of pricing plans: Plan #1 Name: Free Price: $0/mo Users: 10 users included Storage: 2 GB of storage Details: https://example.com Plan #2 Name: Pro Price: $15/mo Users: 20 users included Storage: 10 GB of storage Details: https://example.com Plan #3 Name: Enterprise Price: $29/mo Users: 30 users included Storage: 15 GB of storage Details: (No link available)

这个例子虽然短小,却覆盖了 cascadia 的几类典型用法:

  • 复合类选择器div.card.mb-4.box-shadow:要求元素同时具备标签div与三个 class,多个简单选择器在同一序列内是"与"的关系;
  • 后代组合符li:last-child a:匹配li内部的a元素;
  • 结构伪类:first-child:nth-child(2):last-child:按兄弟节点顺序定位元素;
  • 属性读取:示例通过AttrOr遍历n.Attr自行读取href,并且对缺少href的链接(Enterprise 卡的<a>See more</a>)给出默认值,这也是html.Node原始结构最直接的使用方式。

四、支持的选择器语法全景

结合 parser.go 的解析器实现,cascadia 支持的语法可以分为以下几类。

1. 基础选择器

  • 类型选择器:如divh4,底层由tagSelector实现,要求n.Type == html.ElementNode && n.Data == tag(selector.go#L214-L217);
  • ID 选择器:如#pricing,由idSelector精确匹配id属性;
  • 类选择器:如.card,由classSelector按空白分隔的词列表匹配class属性,天然支持class="card mb-4"这种多类名场景。

2. 属性选择器

attrSelector(selector.go#L265-L296)支持以下全部操作符:

操作符语义实现函数
[attr]属性存在即匹配matchAttribute
[attr=val]属性值完全相等(可忽略大小写)matchInsensitiveValue
[attr!=val]属性值不等于 valattributeNotEqualMatch
[attr~=val]属性是空白分隔词列表且包含 valmatchInclude
[attr|=val]等于 val,或以 "val-" 开头attributeDashMatch
[attr^=val]属性值以 val 开头attributePrefixMatch
[attr$=val]属性值以 val 结尾attributeSuffixMatch
[attr*=val]属性值包含 valattributeSubstringMatch
[attr#=regex]属性值匹配正则表达式attributeRegexMatch

其中#=是 cascadia 特有的扩展操作符,它允许在属性选择器中直接书写正则(解析器通过 parseRegex 编译),使模糊匹配能力大幅增强。前缀、后缀、子串匹配在忽略大小写模式下会先做strings.ToLower再比较(selector.go#L370-L411)。

3. 组合符(Combinator)

combinedSelector的匹配逻辑(selector.go#L490-L508)实现了 CSS 标准的四种组合关系:

组合符语义底层函数
空格A B后代选择器(descendant)descendantMatch:沿n.Parent向上回溯
A > B子选择器(child)childMatch:要求n.Parent命中 A
A + B相邻兄弟(adjacent sibling)siblingMatchadjacent=true,跳过文本/注释节点
A ~ B一般兄弟(general sibling)siblingMatchadjacent=false,向前遍历所有兄弟

4. 伪类(Pseudo-classes)

伪类实现在 pseudo_classes.go 中,覆盖面很广,包括:

  • 结构伪类:first-child:last-child:nth-child(an+b):nth-last-child():nth-of-type():nth-last-of-type():only-child:empty:root
  • 关系伪类:has(...)(相对选择器,判断是否存在匹配的子/后代节点)、:contains(text)(文本包含,以及:containsRegex变体);
  • 表单与状态伪类:input:checked:enabled:disabled,其中:disabled还处理了"位于 disabled fieldset 内"等复杂 DOM 场景(pseudo_classes.go#L429-L438);
  • 其他:link(带href<a>)、:lang(code)等。

nth-child的通用形式支持完整的an+b表达式,由 parseNth 负责解析,示例中的li:nth-child(2)就是b=2, a=0的特例。

5. 选择器组与伪元素

  • 选择器组ParseGroup支持h1, h2, .title这类逗号分隔语法,SelectorGroup.Match只要命中其中任一选择器即返回 true(selector.go#L574-L586);
  • 伪元素:默认的Parse不接受伪元素,需要调用ParseWithPseudoElement/ParseGroupWithPseudoElements。伪元素通过PseudoElement()暴露(如::before),供调用方自行处理,因为它对应的是内容渲染层面而非真实节点。

五、底层原理:匹配、特异性与序列化

1. 匹配是如何发生的

所有选择器最终都被编译为实现了Matcher接口的节点匹配器。QueryAll通过 queryInto 对节点树做先序遍历:先判断当前子节点是否命中,再递归进入其子树,命中结果依次追加到切片中。组合选择器的匹配则依赖"自底向上"的回溯:例如后代选择器A B在判断节点n时,先确认n命中B,再沿n.Parent链向上逐个检查是否存在命中A的祖先。这种设计让匹配逻辑与 CSS 标准的定义保持一一对应,容易验证正确性。

2. 特异性(Specificity)

specificity.go 定义了Specificity [3]int,分别对应 ID 数、类/属性/伪类数、类型/伪元素数。每种选择器都实现了Specificity()方法,例如tagSelector返回{0, 0, 1}(selector.go#L219-L221)、classSelector返回{0, 1, 0}idSelector返回{1, 0, 0};复合选择器会累加各子选择器的特异性。Less方法按数组顺序逐位比较,可用于实现"同优先级时谁先定义谁生效"的 CSS 级联规则。这一特性让 cascadia 不仅能做查询,还能胜任需要排序/去重选择器的场景。

3. 序列化

serialize.go 为每种选择器实现了String()方法,可以把编译后的选择器重新输出为合法的 CSS 文本,并对标识符中的特殊字符(如空格、引号、特殊符号)做转义。这意味着你可以先ParseString(),得到一份规范化、可安全嵌入其他 CSS 上下文的选择器表达式。

六、在当前仓库中的使用前提

cascadia 在本仓库中作为 vendored 依赖随源码分发,路径为 vendor/github.com/andybalholm/cascadia,go.sum中锁定的版本是v1.3.2。因此:

  • 项目代码可以不加任何额外下载直接import "github.com/andybalholm/cascadia",配合golang.org/x/net/html使用;
  • 在编写自己的 Go 程序时,只要采用"html.Parse解析 →cascadia.Parse编译选择器 →Query/QueryAll抽取节点"这条链路,即可获得与示例一致的 CSS 查询能力;
  • 该库只依赖golang.org/x/net/html的节点模型,不引入任何 CGO 或外部运行时,适合嵌入各种解析与抓取工具链。

七、小结

cascadia 用不到十个源文件,把 CSS 选择器的解析、匹配、特异性计算与序列化完整地实现了一遍,是理解"选择器引擎"这一经典主题的极佳范本。结合本文的完整示例,你可以快速上手用 Go 完成结构化的 HTML 数据抽取:先掌握Parse/Query/QueryAll三件套,再按需使用属性操作符、四种组合符与丰富的伪类,最后利用SpecificityString()处理更精细的选择器逻辑。无论是要从网页中批量提取定价信息、抓取文档结构,还是在 Slim 项目的扩展工具中做 HTML 内容分析,这套能力都能直接复用。

  • 云原生
  • CLI
  • 应用安全

【免费下载链接】slim

Slim(toolkit): Don't change anything in your container image and minify it by up to 30x (and for compiled languages even more) making it secure too! (free and open source)

项目地址:https://gitcode.com/gh_mirrors/slim/slim
点击查看免费下载
上一篇:免费完整的PingFangSC字体包指南:六种字重双格式,三分钟告别中文字体翻车
下一篇:Dawarich 版本演进全指南:从 CHANGELOG 读懂自托管 Google Timeline 替代品的核心能力与升级路径

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询