虛擬語法樹(Abstract Syntax Tree, AST)是解釋器/編譯器進行語法分析的基礎, 也是衆多前端編譯工具的基礎工具, 好比webpack, postcss, less等. 對於ECMAScript, 因爲前端輪子衆多, 人力過於充足, 早已經被人們玩膩了. 光是語法分析器就有uglify
, acorn
, bablyon
, typescript
, esprima
等等若干種. 而且也有了AST的社區標準: ESTree.css
這篇文章主要介紹如何去寫一個AST解析器, 可是並非經過分析JavaScript, 而是經過分析html5
的語法樹來介紹, 使用html5
的緣由有兩點: 一個是其語法簡單, 概括起來只有兩種: Text
和Tag
, 其次是由於JavaScript的語法分析器已經有太多太多, 再造一個輪子毫無心義, 而對於html5
, 雖然也有很多的AST分析器, 好比htmlparser2
, parser5
等等, 可是沒有像ESTree
那麼標準, 同時, 這些分析器都有一個問題: 那就是定義的語法樹中沒法對標籤屬性進行操做. 因此爲了解決這個問題, 才寫了一個html的語法分析器, 同時定義了一個完善的AST結構, 而後再有的這篇文章.html
爲了跟蹤每一個節點的位置屬性, 首先定義一個基礎節點, 全部的結點都繼承於此結點:前端
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; // 引號類型, 多是', ", 或者沒有 }
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分析已經過測試).