AST解析基礎: 如何寫一個簡單的html文法分析庫

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

前言

虛擬文法樹(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# ORyarn add html5parser 

或者到GitHub查看原始碼: acrazing/html5parser.

目前對正常的HTML解析已完全通過測試, 已知的BUG包括對注釋的解析, 以及未正常結束的
輸入的解析處理(均在文法分析層面, token分析已通過測試).

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.