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

前言

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

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

AST定义

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

export interface IBaseNode {
  start: number;  // 节点起始位置
  end: number;    // 节点结束位置
}

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

export enum SyntaxKind {
  Text = 'Text', // 文本类型
  Tag  = 'Tag',  // 标签类型
}

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

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

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

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

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

export interface IAttribute extends IBaseNode {
  name: IText;  // 名称
  value: IAttributeValue | void; // 值
}

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

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

Token解析

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

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

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

首先定义token的结构:

export interface IToken {
  start: number;    // 起始位置
  end: number;      // 结束位置
  value: string;    // token
  type: TokenKind;  // 类型
}

Token类型一共有以下几种:

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

Token分析时并没有考虑属性的键/值关系, 均统一视为属性中的一个片段, 同时, 视=为一个
特殊的独立段片段, 然后交给上层的parser去分析键值关系. 这么做的原因是为了在token分析
时避免上下文处理, 并简化状态机状态表. 状态列表如下:

enum State {
  Literal              = 'Literal',
  BeforeOpenTag        = 'BeforeOpenTag',
  OpeningTag           = 'OpeningTag',
  AfterOpenTag         = 'AfterOpenTag',
  InValueNq            = 'InValueNq',
  InValueSq            = 'InValueSq',
  InValueDq            = 'InValueDq',
  ClosingOpenTag       = 'ClosingOpenTag',
  OpeningSpecial       = 'OpeningSpecial',
  OpeningDoctype       = 'OpeningDoctype',
  OpeningNormalComment = 'OpeningNormalComment',
  InNormalComment      = 'InNormalComment',
  InShortComment       = 'InShortComment',
  ClosingNormalComment = 'ClosingNormalComment',
  ClosingTag           = 'ClosingTag',
}

整个解析采用函数式编程, 没有使用OO, 为了简化在函数间传递状态参数, 由于是一个同步操作,
这里利用了JavaScript的事件模型, 采用全局变量来保存状态. Token分析时所需要的全局变量列表如下:

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

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

function init(input: string) {
  state        = State.Literal
  buffer       = input
  bufSize      = input.length
  sectionStart = 0
  index        = 0
  tokens       = []
}

然后开始解析, 解析时需要遍历输入字符串中的所有字符, 并根据当前状态进行相应的处理
(改变状态, 输出token等), 解析完成后, 清空全局变量, 返回结束.

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

语法树解析

在获取到token列表之后, 需要根据上下文解析得到最终的节点树, 方式与tokenize相似,
均采用全局变量保存传递状态, 遍历所有的token, 不同之处在于这里没有一个全局的状态机.
因为状态完全可以通过正在解析的节点的类型来判断.

export function parse(input: string): INode[] {
  init(input)
  while (index < count) {
    token = tokens[index]
    switch (token.type) {
      case TokenKind.Literal:
        if (!node) {
          node = createLiteral()
          pushNode(node)
        } else {
          appendLiteral(node)
        }
        break
      case TokenKind.OpenTag:
        node = void 0
        parseOpenTag()
        break
      case TokenKind.CloseTag:
        node = void 0
        parseCloseTag()
        break
      default:
        unexpected()
        break
    }
    index++
  }
  const _nodes = nodes
  init()
  return _nodes
}

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

结语

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

npm install html5parser -S
# OR
yarn add html5parser 

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

目前对正常的HTML解析已完全通过测试, 已知的BUG包括对注释的解析, 以及未正常结束的
输入的解析处理(均在语法分析层面, token分析已通过测试).

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容