AST解析基础: 如何写一个简单的html语法分析库

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
简介:

前言

虚拟语法树(Abstract Syntax Tree, AST)是解释器/编译器进行语法分析的基础, 也是众多前端编译工具的基础工具, 比如webpack, postcss, less等. 对于ECMAScript, 由于前端轮子众多, 人力过于充足, 早已经被人们玩腻了. 光是语法分析器就有 uglify , acorn , bablyon , typescript , esprima 等等若干种. 并且也有了AST的社区标准: ESTree。

这篇文章主要介绍如何去写一个AST解析器, 但是并不是通过分析JavaScript, 而是通过分析 html5 的语法树来介绍, 使用 html5 的原因有两点: 一个是其语法简单, 归纳起来只有两种: Text 和 Tag , 其次是因为JavaScript的语法分析器已经有太多太多, 再造一个轮子毫无意义, 而对于 html5 , 虽然也有不少的AST分析器, 比如 htmlparser2 , parser5 等等, 但是没有像 ESTree 那么标准, 同时, 这些分析器都有一个问题: 那就是定义的语法树中无法对标签属性进行操作. 所以为了解决这个问题, 才写了一个html的语法分析器, 同时定义了一个完善的AST结构, 然后再有的这篇文章。

AST解析基础: 如何写一个简单的html语法分析库

AST定义

为了跟踪每个节点的位置属性, 首先定义一个基础节点, 所有的结点都继承于此结点:


 
 
  1. export interface IBaseNode { 
  2.   start: number;  // 节点起始位置 
  3.   end: number;    // 节点结束位置 

如前所述, html5的语法类型最终可以归结为两种: 一种是 Text , 另一种是 Tag , 这里用一个枚举类型来标志它们.


 
 
  1. export enum SyntaxKind { 
  2.   Text = 'Text', // 文本类型 
  3.   Tag  = 'Tag',  // 标签类型 

对于文本, 其属性只有一个原始的字符串 value , 因此结构如下:


 
 
  1. export interface IText extends IBaseNode { 
  2.   type: SyntaxKind.Text; // 类型 
  3.   value: string;         // 原始字符串 

而对于 Tag , 则应该包括标签开始部分 open , 属性列表 attributes , 标签名称 name , 子标签/文本 body , 以及标签闭合部分 close :


 
 
  1. export interface ITag extends IBaseNode { 
  2.   type: SyntaxKind.Tag;  // 类型 
  3.   open: IText;           // 标签开始部分, 比如 <div id="1"
  4.   name: string;          // 标签名称, 全部转换为小写 
  5.   attributes: IAttribute[];  // 属性列表 
  6.   body: Array<ITag | IText> // 子节点列表, 如果是一个非自闭合的标签, 并且起始标签已结束, 则为一个数组 
  7.     | void                  // 如果是一个自闭合的标签, 则为void 0 
  8.     | null;                 // 如果起始标签未结束, 则为null 
  9.   close: IText              // 关闭标签部分, 存在则为一个文本节点 
  10.     | void                  // 自闭合的标签没有关闭部分 
  11.     | null;                 // 非自闭合标签, 但是没有关闭标签部分 

标签的属性是一个键值对, 包含名称 name 及值 value 部分, 定义结构如下:


 
 
  1. export interface IAttribute extends IBaseNode { 
  2.   name: IText;  // 名称 
  3.   value: IAttributeValue | void; // 值 

其中名称是普通的文本节点, 但是值比较特殊, 表现在其可能被单/双引号包起来, 而引号是无意义的, 因此定义一个标签值结构:


 
 
  1. export interface IAttributeValue extends IBaseNode { 
  2.   value: string; // 值, 不包含引号部分 
  3.   quote: '\'' | '"' | void; // 引号类型, 可能是', ", 或者没有 

Token解析

AST解析首先需要解析原始文本得到符号列表, 然后再通过上下文语境分析得到最终的语法树.

相对于JSON, html虽然看起来简单, 但是上下文是必需的, 所以虽然JSON可以直接通过token分析得到最终的结果, 但是html却不能, token分析是第一步, 这是必需的. (JSON解析可以参考我的另一篇文章: 徒手写一个JSON解析器(Golang) ).

token解析时, 需要根据当前的状态来分析token的含义, 然后得出一个token列表.

首先定义token的结构:


 
 
  1. export interface IToken { 
  2.   start: number;    // 起始位置 
  3.   end: number;      // 结束位置 
  4.   value: string;    // token 
  5.   type: TokenKind;  // 类型 

Token类型一共有以下几种:


 
 
  1. export enum TokenKind { 
  2.   Literal     = 'Literal',      // 文本 
  3.   OpenTag     = 'OpenTag',      // 标签名称 
  4.   OpenTagEnd  = 'OpenTagEnd',   // 开始标签结束符, 可能是 '/', 或者 '''--' 
  5.   CloseTag    = 'CloseTag',     // 关闭标签 
  6.   Whitespace  = 'Whitespace',   // 开始标签类属性值之间的空白 
  7.   AttrValueEq = 'AttrValueEq',  // 属性中的= 
  8.   AttrValueNq = 'AttrValueNq',  // 属性中没有引号的值 
  9.   AttrValueSq = 'AttrValueSq',  // 被单引号包起来的属性值 
  10.   AttrValueDq = 'AttrValueDq',  // 被双引号包起来的属性值 

Token分析时并没有考虑属性的键/值关系, 均统一视为属性中的一个片段, 同时, 视 = 为一个

特殊的独立段片段, 然后交给上层的 parser 去分析键值关系. 这么做的原因是为了在token分析

时避免上下文处理, 并简化状态机状态表. 状态列表如下:


 
 
  1. enum State { 
  2.   Literal              = 'Literal'
  3.   BeforeOpenTag        = 'BeforeOpenTag'
  4.   OpeningTag           = 'OpeningTag'
  5.   AfterOpenTag         = 'AfterOpenTag'
  6.   InValueNq            = 'InValueNq'
  7.   InValueSq            = 'InValueSq'
  8.   InValueDq            = 'InValueDq'
  9.   ClosingOpenTag       = 'ClosingOpenTag'
  10.   OpeningSpecial       = 'OpeningSpecial'
  11.   OpeningDoctype       = 'OpeningDoctype'
  12.   OpeningNormalComment = 'OpeningNormalComment'
  13.   InNormalComment      = 'InNormalComment'
  14.   InShortComment       = 'InShortComment'
  15.   ClosingNormalComment = 'ClosingNormalComment'
  16.   ClosingTag           = 'ClosingTag'

整个解析采用函数式编程, 没有使用OO, 为了简化在函数间传递状态参数, 由于是一个同步操作,

这里利用了JavaScript的事件模型, 采用全局变量来保存状态. Token分析时所需要的全局变量列表如下:


 
 
  1. let state: State          // 当前的状态 
  2. let buffer: string        // 输入的字符串 
  3. let bufSize: number       // 输入字符串长度 
  4. let sectionStart: number  // 正在解析的Token的起始位置 
  5. let index: number         // 当前解析的字符的位置 
  6. let tokens: IToken[]      // 已解析的token列表 
  7. let char: number          // 当前解析的位置的字符的UnicodePoint 

在开始解析前, 需要初始化全局变量:


 
 
  1. function init(input: string) { 
  2.   state        = State.Literal 
  3.   buffer       = input 
  4.   bufSize      = input.length 
  5.   sectionStart = 0 
  6.   index        = 0 
  7.   tokens       = [] 

然后开始解析, 解析时需要遍历输入字符串中的所有字符, 并根据当前状态进行相应的处理

(改变状态, 输出token等), 解析完成后, 清空全局变量, 返回结束.


 
 
  1. export function tokenize(input: string): IToken[] { 
  2.   init(input) 
  3.   while (index < bufSize) { 
  4.     char = buffer.charCodeAt(index
  5.     switch (state) { 
  6.     // ...根据不同的状态进行相应的处理 
  7.     // 文章忽略了对各个状态的处理, 详细了解可以查看源代码 
  8.     } 
  9.     index++ 
  10.   } 
  11.   const _nodes = nodes 
  12.   // 清空状态 
  13.   init(''
  14.   return _nodes 

语法树解析

在获取到token列表之后, 需要根据上下文解析得到最终的节点树, 方式与tokenize相似,均采用全局变量保存传递状态, 遍历所有的token, 不同之处在于这里没有一个全局的状态机。

因为状态完全可以通过正在解析的节点的类型来判断。


 
 
  1. export function parse(input: string): INode[] { 
  2.   init(input) 
  3.   while (index < count) { 
  4.     token = tokens[index
  5.     switch (token.type) { 
  6.       case TokenKind.Literal: 
  7.         if (!node) { 
  8.           node = createLiteral() 
  9.           pushNode(node) 
  10.         } else { 
  11.           appendLiteral(node) 
  12.         } 
  13.         break 
  14.       case TokenKind.OpenTag: 
  15.         node = void 0 
  16.         parseOpenTag() 
  17.         break 
  18.       case TokenKind.CloseTag: 
  19.         node = void 0 
  20.         parseCloseTag() 
  21.         break 
  22.       default
  23.         unexpected() 
  24.         break 
  25.     } 
  26.     index++ 
  27.   } 
  28.   const _nodes = nodes 
  29.   init() 
  30.   return _nodes 

不太多解释, 可以到GitHub查看源代码.

结语

项目已开源, 名称是 html5parser , 可以通过npm/yarn安装:


 
 
  1. npm install html5parser -S  
  2. OR  
  3. yarn add html5parser 

或者到GitHub查看源代码: acrazing/html5parser

目前对正常的HTML解析已完全通过测试, 已知的BUG包括对注释的解析, 以及未正常结束的

输入的解析处理(均在语法分析层面, token分析已通过测试).


作者:佚名

来源:51CTO

相关文章
|
24天前
|
XML 数据采集 数据格式
Python 爬虫必备杀器,xpath 解析 HTML
【11月更文挑战第17天】XPath 是一种用于在 XML 和 HTML 文档中定位节点的语言,通过路径表达式选取节点或节点集。它不仅适用于 XML,也广泛应用于 HTML 解析。基本语法包括标签名、属性、层级关系等的选择,如 `//p` 选择所有段落标签,`//a[@href=&#39;example.com&#39;]` 选择特定链接。在 Python 中,常用 lxml 库结合 XPath 进行网页数据抓取,支持高效解析与复杂信息提取。高级技巧涵盖轴的使用和函数应用,如 `contains()` 用于模糊匹配。
|
25天前
|
数据采集 JavaScript API
网页解析库:BeautifulSoup与Cheerio的选择
网页解析库:BeautifulSoup与Cheerio的选择
|
1月前
|
XML JavaScript 前端开发
如何解析一个 HTML 文本
【10月更文挑战第23天】在实际应用中,根据具体的需求和场景,我们可以灵活选择解析方法,并结合其他相关技术来实现高效、准确的 HTML 解析。随着网页技术的不断发展,解析 HTML 文本的方法也在不断更新和完善,
|
1月前
|
JavaScript API 开发工具
<大厂实战场景> ~ Flutter&鸿蒙next 解析后端返回的 HTML 数据详解
本文介绍了如何在 Flutter 中解析后端返回的 HTML 数据。首先解释了 HTML 解析的概念,然后详细介绍了使用 `http` 和 `html` 库的步骤,包括添加依赖、获取 HTML 数据、解析 HTML 内容和在 Flutter UI 中显示解析结果。通过具体的代码示例,展示了如何从 URL 获取 HTML 并提取特定信息,如链接列表。希望本文能帮助你在 Flutter 应用中更好地处理 HTML 数据。
119 1
|
29天前
|
存储 Go PHP
Go语言中的加解密利器:go-crypto库全解析
在软件开发中,数据安全和隐私保护至关重要。`go-crypto` 是一个专为 Golang 设计的加密解密工具库,支持 AES 和 RSA 等加密算法,帮助开发者轻松实现数据的加密和解密,保障数据传输和存储的安全性。本文将详细介绍 `go-crypto` 的安装、特性及应用实例。
72 0
|
2月前
|
XML 数据格式
HTML 实例解析
本文介绍了HTML中常见元素的使用方法,包括`&lt;p&gt;`、`&lt;body&gt;`和`&lt;html&gt;`等。详细解析了这些元素的结构和作用,并强调了正确使用结束标签的重要性。此外,还提到了空元素的使用及大小写标签的规范。
|
2月前
|
XML 前端开发 数据格式
Beautiful Soup 解析html | python小知识
在数据驱动的时代,网页数据是非常宝贵的资源。很多时候我们需要从网页上提取数据,进行分析和处理。Beautiful Soup 是一个非常流行的 Python 库,可以帮助我们轻松地解析和提取网页中的数据。本文将详细介绍 Beautiful Soup 的基础知识和常用操作,帮助初学者快速入门和精通这一强大的工具。【10月更文挑战第11天】
71 2
|
1月前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
76 2
|
2月前
|
缓存 Java 程序员
Map - LinkedHashSet&Map源码解析
Map - LinkedHashSet&Map源码解析
78 0
|
2月前
|
算法 Java 容器
Map - HashSet & HashMap 源码解析
Map - HashSet & HashMap 源码解析
63 0

推荐镜像

更多