|
| 1 | ++++ |
| 2 | +title = "重新造轮子系列(三): HTML Selector" |
| 3 | +date = 2025-03-15T10:53:00-07:00 |
| 4 | +lastmod = 2025-03-15T14:31:01-07:00 |
| 5 | +tags = ["reinvent"] |
| 6 | +categories = ["ReInvent: 重新造轮子系列"] |
| 7 | +draft = false |
| 8 | +toc = true |
| 9 | ++++ |
| 10 | + |
| 11 | +项目 GitHub 地址: [Selector](https://github.com/ramsayleung/reinvent/tree/master/html_selector) [^fn:1] |
| 12 | + |
| 13 | + |
| 14 | +## <span class="section-num">1</span> 前言 {#前言} |
| 15 | + |
| 16 | +以前写爬虫的时候,必不可少的一个工具就是 HTML selector, 就是用于匹配指定的 HTML 标签。 |
| 17 | + |
| 18 | +毕竟爬虫的本质就是找出需要的标签里面的内容,然后解析出来。 |
| 19 | + |
| 20 | +而 selector 主要有两个流派,一个是 [CSS selector](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_selectors) [^fn:2], 另外一个是 [XPath selector](https://developer.mozilla.org/en-US/docs/Web/XML/XPath/Guides) [^fn:3] ,本质都是通过某种语法来匹配指定的标签,区别只是一个用的是 CSS 的语法,另外一个是 XML 语法. |
| 21 | + |
| 22 | +这次我们就来写个基于 CSS 语法的 Selector, 来深入理解下 HTML 的 DOM 模型 |
| 23 | + |
| 24 | + |
| 25 | +## <span class="section-num">2</span> DOM {#dom} |
| 26 | + |
| 27 | +写过前端的朋友应该都知道,前端代码主要是由所谓的三剑客组成的:HTML + CSS + JavaScript, 其中的三剑客各司其职,相互配合。 |
| 28 | + |
| 29 | +HTML 负责内容展示, CSS 负责布局和样式,而 JavaScript 是负责用户与页面之间的动态交互。 |
| 30 | + |
| 31 | +而对于如下的 HTML 代码: |
| 32 | + |
| 33 | +```html |
| 34 | +<html> |
| 35 | + <head> |
| 36 | + <title>Example</title> |
| 37 | + </head> |
| 38 | + <body> |
| 39 | + <h1>Title</h1> |
| 40 | + <blockquote id="important"> |
| 41 | + <p>Opening</p> |
| 42 | + <p>Explanation</p> |
| 43 | + <p class="highlight">Warning</p> |
| 44 | + </blockquote> |
| 45 | + <p>Closing</p> |
| 46 | + </body> |
| 47 | +</html> |
| 48 | +``` |
| 49 | + |
| 50 | +浏览器会将其进行解析,并生成名为 [Document Object Model](//developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model)(DOM) 的数据结构,听着好像很玄乎,但本质就是一棵多叉树 (Multiway Tree): |
| 51 | + |
| 52 | +{{< figure src="/ox-hugo/reinvent_dom_tree.jpg" >}} |
| 53 | + |
| 54 | +知道 `DOM` 是多叉树, 我们就可以写出简化版本 `DOM` 的数据结构了: |
| 55 | + |
| 56 | +```javascript |
| 57 | +export interface DomNode { |
| 58 | + type: string; |
| 59 | + name?: string; |
| 60 | + attribs?: { |
| 61 | + id?: string; |
| 62 | + class?: string; |
| 63 | + [key: string]: string | undefined; |
| 64 | + }; |
| 65 | + children?: DomNode[]; |
| 66 | + data?: string; |
| 67 | + parent?: DomNode; |
| 68 | +} |
| 69 | +``` |
| 70 | + |
| 71 | +一个节点可能有多个子节点 `(children?)` 或者一个父节点 `(parent?)`, 也可能都没有,所以标记成 `?(optional)`; |
| 72 | + |
| 73 | +一个节点可能有多个属性 `attribs`. |
| 74 | + |
| 75 | +而节点的=type= 可能是 `tag`, `text`, `comment`, `script`, `style`, 而对于 `text` 和 `comment` 类型的节点, `name` 也是为空的. |
| 76 | + |
| 77 | +这个 `DOM` 结构只是我们的简化版本,完整的 DOM 还有很多的属性和回调函数,详情可以查看文档: [Document Object Model (DOM)](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model) |
| 78 | + |
| 79 | + |
| 80 | +## <span class="section-num">3</span> BFS vs DFS {#bfs-vs-dfs} |
| 81 | + |
| 82 | +理解到 `DOM` 的本质是个多叉树之后,我们很快就能意识到, `selector` 本质也就是遍历多叉树,找到符合要求的所有节点, 比如按 `tag` 名来匹配,按 `id` 来匹配,按 `class` 来匹配等等。 |
| 83 | + |
| 84 | +而用于遍历多叉树的常用算法就是广度优先搜索(Breadth First Search, BFS)和深度优先搜索(Depth First Search, DFS) |
| 85 | + |
| 86 | +{{< figure src="/ox-hugo/reinvent_dfs_vs_bfs.jpg" >}} |
| 87 | + |
| 88 | +通常来说,BFS 和 DFS 都能完成多叉树遍历,时间复杂度也是相同的,BFS通常使用一个 `queue` 来记录遍历待节点,所以会使用更多的内存,但是能找到最短路径;而 DFS 通常使用递归,如果遇到个循环图,就会 StackOverflow,无法找到结果。 |
| 89 | + |
| 90 | +因为我们明确知道 DOM 是个多叉树(有向无环图),所以我们就使用 DFS 来遍历查找。 |
| 91 | + |
| 92 | + |
| 93 | +## <span class="section-num">4</span> Strategy 设计模式 {#strategy-设计模式} |
| 94 | + |
| 95 | +分析好问题之后,我们的实现也差不多能出来了, 按 tag 名来匹配,无非是 `domNode.name === tagName`; 按 `class` 来匹配, 即 `domNode.attribs.class=== class`. |
| 96 | + |
| 97 | +为了解耦和易于扩展,我们可以使用个策略设计模式([Strategy Design Pattern](https://refactoring.guru/design-patterns/strategy) [^fn:4]). |
| 98 | + |
| 99 | +```js |
| 100 | +interface Selector { |
| 101 | + match(node: DomNode): boolean; |
| 102 | +} |
| 103 | + |
| 104 | +const findByTagName = (tag: string): Selector => ({ |
| 105 | + match: (node: DomNode): boolean => { |
| 106 | + return node.name.toLowerCase() === tag.toLowerCase() |
| 107 | + } |
| 108 | +}); |
| 109 | + |
| 110 | +const findById = (id: string): Selector => ({ |
| 111 | + match: (node: DomNode): boolean => { |
| 112 | + return node.attribs.id === id; |
| 113 | + } |
| 114 | +}) |
| 115 | + |
| 116 | +const findByClass = (clazz: string): Selector => ({ |
| 117 | + match: (node: DomNode): boolean => { |
| 118 | + return node.attribs.class === clazz; |
| 119 | + } |
| 120 | +}); |
| 121 | +``` |
| 122 | + |
| 123 | +然后遍历节点的时候,只需要判断 `Selector` 是否符合要求,而具体的匹配条件则由 `selector` 决定: |
| 124 | + |
| 125 | +```js |
| 126 | +const isMatch = (node: DomNode, selectors: Selector[]): boolean => { |
| 127 | + return selectors.every(selector => selector.match(node)); |
| 128 | +} |
| 129 | +``` |
| 130 | + |
| 131 | +这样的话,要增加一个根据属性keyValue值的匹配条件也是非常容易的, 如 `div[align=center]`, 即匹配属性 `align` 和value 为 `center`: |
| 132 | + |
| 133 | +```js |
| 134 | +const findByAttributes = (key: string, value: string): Selector => ({ |
| 135 | + match: (node: DomNode): boolean => { |
| 136 | + return node.attribs[key] === value; |
| 137 | + } |
| 138 | +}) |
| 139 | +``` |
| 140 | + |
| 141 | + |
| 142 | +## <span class="section-num">5</span> 测试验证 {#测试验证} |
| 143 | + |
| 144 | +DFS + Strategy design pattern 就实现了一个基础的 CSS Selector, 我们自然需要测试验证下是否正确: |
| 145 | + |
| 146 | +```js |
| 147 | +describe('HTML selector testsuite', () => { |
| 148 | + const HTML = `<main> |
| 149 | + <p>text of first p</p> |
| 150 | + <p id="id-01">text of p#id-01</p> |
| 151 | + <p id="id-02">text of p#id-02</p> |
| 152 | + <p class="class-03">text of p.class-03</p> |
| 153 | + <div> |
| 154 | + <p>text of div / p</p> |
| 155 | + <p id="id-04">text of div / p#id-04</p> |
| 156 | + <p class="class-05">text of div / p.class-05</p> |
| 157 | + <p class="class-06">should not be found</p> |
| 158 | + </div> |
| 159 | + <div id="id-07"> |
| 160 | + <p>text of div#id-07 / p</p> |
| 161 | + <p class="class-06">text of div#id-07 / p.class-06</p> |
| 162 | + </div> |
| 163 | +</main>` |
| 164 | + |
| 165 | + it.each([ |
| 166 | + ['p', 'text of first p'], |
| 167 | + ['p#id-01', 'text of p#id-01'], |
| 168 | + ['p#id-02', 'text of p#id-02'], |
| 169 | + ['p.class-03', 'text of p.class-03'], |
| 170 | + ['div p', 'text of div / p'], |
| 171 | + ['div p#id-04', 'text of div / p#id-04'], |
| 172 | + ['div p.class-05', 'text of div / p.class-05'], |
| 173 | + ['div#id-07 p', 'text of div#id-07 / p'], |
| 174 | + ['div#id-07 p.class-06', 'text of div#id-07 / p.class-06'] |
| 175 | + ])('test select %s %s', async (selector, expected) => { |
| 176 | + const doc = htmlparser2.parseDOM(HTML)[0]; |
| 177 | + const node = select(doc, selector); |
| 178 | + const actual = getText(node); |
| 179 | + expect(actual).toBe(expected); |
| 180 | + }) |
| 181 | +}) |
| 182 | +``` |
| 183 | + |
| 184 | +使用 Jest 框架编写了如上的单元测试用例, unit test 都通过了,完工. |
| 185 | + |
| 186 | +值得一提的是,这种相同的验证逻辑, 但是输入多个不同的参数以验证不同case的做法,叫做 `Parameterized Test` |
| 187 | + |
| 188 | +我在《[测试技能进阶系列](https://ramsayleung.github.io/zh/categories/%E6%B5%8B%E8%AF%95%E6%8A%80%E8%83%BD%E8%BF%9B%E9%98%B6/)》的第二篇也曾经介绍过: [Parameterized Tests](https://ramsayleung.github.io/zh/post/2024/%E6%B5%8B%E8%AF%95%E6%8A%80%E8%83%BD%E8%BF%9B%E9%98%B6%E4%BA%8C_parameterized_tests/) |
| 189 | + |
| 190 | + |
| 191 | +## <span class="section-num">6</span> 总结 {#总结} |
| 192 | + |
| 193 | +这个简单的 CSS Selector 全部代码仅有 **103** 行, 但麻雀虽小,五脏俱全,功能还是齐备的: |
| 194 | + |
| 195 | +```sh |
| 196 | +> tokei simple-selectors.ts |
| 197 | +=============================================================================== |
| 198 | +Language Files Lines Code Comments Blanks |
| 199 | +=============================================================================== |
| 200 | +TypeScript 1 131 103 9 19 |
| 201 | +=============================================================================== |
| 202 | +Total 1 131 103 9 19 |
| 203 | +=============================================================================== |
| 204 | +``` |
| 205 | + |
| 206 | +所以标题也可以修改成 100 行代码实现一个简单的 CSS Selector :) |
| 207 | + |
| 208 | +如果细看实现,还是有不少的优化之处的,比如 `parseSelector` 函数可以实现得更优雅些,以便进一步扩展支持其他的语法。 |
| 209 | + |
| 210 | +另外就是目前支持的都是所有 selector 完全匹配的情况,即 `and`, 但是目前不支持 `or` 的功能,即类如: `h1,h2,h3`, 可以匹配 `h1`, `h2`, 或者 `h3`. |
| 211 | + |
| 212 | +--- |
| 213 | + |
| 214 | +如果想要看下较完整版本的 CSS Selector, 可以看下我六年多前我用 C++ 实现的[版本](https://github.com/ramsayleung/crawler), 实现从字符串解析并生成 `DOM`, 再实现 CSS 解析器,纯正的 OOP 风味。 |
| 215 | + |
| 216 | +当时初学 C++, 这个算是我早期写得比较大的 C++17 项目,核心代码大概1000行,还有几百行的单元测试。 |
| 217 | + |
| 218 | +现在再翻看自己的代码,会惊讶于当时自己代码写的工整,可谓是有板有眼,像极了书法初学者写的楷书。 |
| 219 | + |
| 220 | +> <span class="org-target" id="org-target--Unix----"></span>这本砖头书读过, 其他的C++书籍, 如<span class="org-target" id="org-target--C---Primer"></span>, <span class="org-target" id="org-target--Effective-C--"></span>, <span class="org-target" id="org-target--Modern-C--"></span>也读过, 感觉不把书中的内容实践下, 很容易遗忘。 |
| 221 | +> |
| 222 | +> 但是日常的工作内容并不会涉及底层网络服务, 一切底层细节内容都被框架给包掉了, 开发的主力语言是Java, 也不会使用到C++. |
| 223 | +> |
| 224 | +> 因此决定创造个机会实践下这些知识,最终决定只用C/C++内置函数库实现。 |
| 225 | +
|
| 226 | +的确所有工具都是用C/C++内置函数库实现的,甚至测试框架还是自己用宏实现的. |
| 227 | + |
| 228 | +只是我未曾想到的是,写了这段话后不足一年,C++就成为了我下一家公司干活的主力语言; 而现在,我又在重新写 Java, 着实是「白衣苍狗」。 |
| 229 | + |
| 230 | +[回到本系列的目录]({{< relref "reinvent_project" >}}) |
| 231 | + |
| 232 | +[^fn:1]: <https://github.com/ramsayleung/reinvent/tree/master/html_selector> |
| 233 | +[^fn:2]: <https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_selectors> |
| 234 | +[^fn:3]: <https://developer.mozilla.org/en-US/docs/Web/XML/XPath/Guides> |
| 235 | +[^fn:4]: <https://refactoring.guru/design-patterns/strategy> |
0 commit comments