這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
前言
虛擬文法樹(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定義
為了跟蹤每個節點的位置屬性, 首先定義一個基礎節點, 所有的結點都繼承於此結點:
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# ORyarn add html5parser
或者到GitHub查看原始碼: acrazing/html5parser.
目前對正常的HTML解析已完全通過測試, 已知的BUG包括對注釋的解析, 以及未正常結束的
輸入的解析處理(均在文法分析層面, token分析已通過測試).