造輪子系列(三): 一個簡單快速的html虛擬語法樹(AST)解析器

前言

虛擬語法樹(Abstract Syntax Tree, AST)是解釋器/編譯器進行語法分析的基礎, 也是衆多前端編譯工具的基礎工具, 好比webpack, postcss, less等. 對於ECMAScript, 因爲前端輪子衆多, 人力過於充足, 早已經被人們玩膩了. 光是語法分析器就有uglify, acorn, bablyon, typescript, esprima等等若干種. 而且也有了AST的社區標準: ESTree.css

這篇文章主要介紹如何去寫一個AST解析器, 可是並非經過分析JavaScript, 而是經過分析html5的語法樹來介紹, 使用html5的緣由有兩點: 一個是其語法簡單, 概括起來只有兩種: TextTag, 其次是由於JavaScript的語法分析器已經有太多太多, 再造一個輪子毫無心義, 而對於html5, 雖然也有很多的AST分析器, 好比htmlparser2, parser5等等, 可是沒有像ESTree那麼標準, 同時, 這些分析器都有一個問題: 那就是定義的語法樹中沒法對標籤屬性進行操做. 因此爲了解決這個問題, 才寫了一個html的語法分析器, 同時定義了一個完善的AST結構, 而後再有的這篇文章.html

AST定義

爲了跟蹤每一個節點的位置屬性, 首先定義一個基礎節點, 全部的結點都繼承於此結點:前端

export interface IBaseNode {
  start: number;  // 節點起始位置
  end: number;    // 節點結束位置
}

如前所述, html5的語法類型最終能夠歸結爲兩種: 一種是Text, 另外一種是Tag, 這裏用一個枚舉類型來標誌它們.html5

export enum SyntaxKind {
  Text = 'Text', // 文本類型
  Tag  = 'Tag',  // 標籤類型
}

對於文本, 其屬性只有一個原始的字符串value, 所以結構以下:node

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

而對於Tag, 則應該包括標籤開始部分open, 屬性列表attributes, 標籤名稱name, 子標籤/文本body, 以及標籤閉合部分close:webpack

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部分, 定義結構以下:git

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

其中名稱是普通的文本節點, 可是值比較特殊, 表如今其可能被單/雙引號包起來, 而引號是無心義的, 所以定義一個標籤值結構:github

export interface IAttributeValue extends IBaseNode {
  value: string; // 值, 不包含引號部分
  quote: '\'' | '"' | void; // 引號類型, 多是', ", 或者沒有
}

Token解析

AST解析首先須要解析原始文本獲得符號列表, 而後再經過上下文語境分析獲得最終的語法樹.web

相對於JSON, html雖然看起來簡單, 可是上下文是必需的, 因此雖然JSON能夠直接經過token分析獲得最終的結果, 可是html卻不能, token分析是第一步, 這是必需的. (JSON解析能夠參考個人另外一篇文章: 徒手寫一個JSON解析器(Golang)).typescript

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分析已經過測試).

相關文章
相關標籤/搜索