大白話Vue源碼系列(03):生成AST

来源:http://www.cnblogs.com/iovec/archive/2017/12/22/vue_03.html
-Advertisement-
Play Games

Angular 是 Google 親兒子,React 是 Facebook 小正太,那咱為啥偏偏選擇了 Vue 下手,一句話,Vue 是咱見過的最對脾氣的 MVVM 框架。之前也使用過 knockout,angular,react 這些框架,但都沒有讓咱產生 follow 的衝動。直到見到 Vue,... ...


閱讀目錄

本篇探討 Vue 根據 html 模板片段構建出 AST 的具體過程。這對 Vue 的使用通常沒什麼幫助,但熟悉這個過程會對 Vue 的內部工作原理有更清晰的認識。

主代碼位置:Vue 項目的 src/compiler/parser/html-parser.js 文件。

AST 節點定義

AST 是由一個個節點組成的,正如 DOM 樹是由 DOM 節點組成的一樣。

Vue 使用正則表達式匹配 html 標簽,並將標簽解析成 AST 節點,所以繼續下麵的內容之前最好對正則表達式有一定瞭解。

Vue 的 AST 節點數據結構定義如下:

// 節點包含 3 種類型:標簽元素、普通文本、插值表達式
declare type ASTNode = ASTElement | ASTText | ASTExpression;

declare type ASTElement = {
  type: 1;
  tag: string;
  attrsList: [];
  parent: ASTElement | void;    
  children: [];
}

declare type ASTExpression = {
  type: 2;
  expression: string;
  text: string;
}

declare type ASTText = {
  type: 3;
  text: string;
  isComment: boolean;
}
 

declare type 是 flow.js 的語法,用於靜態類型檢查。請留意 ASTElement 定義中的 parentchildren 欄位,它們將是用於建立父子關係從而構成一顆樹的依據。

接下來開始剖析代碼細節。

標簽的正則匹配

下麵是比較枯燥的正則式環節。

1、匹配標簽名

const tagName = '([a-zA-Z_][\\w\\-\\.]*)'
 

需要註意的是,不同於[a-zA-Z_],正則式 \w 用於匹配包括下劃線的任何單詞字元,包括中文字元。因此上面一行正則式的意思是匹配以英文字母或下劃線開頭([a-zA-Z_])接若幹個單詞字元或下劃線([\w\-\.]*)的字元串。

該正則式可匹配到 <div id="index">div 名稱部分。

2、匹配標簽屬性

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
 

這行正則式用於匹配 key = value 這種屬性鍵值對,雖然看起來挺複雜,但其實是挺簡單的匹配,主要是相容處理屬性值的雙引號,單引號和數字等寫法。

該正則式可匹配到 <div id="index">id="index" 屬性部分。

3、匹配開始標簽

const startTagOpen = new RegExp(`^<${tagName}`)
const startTagClose = /^\s*(\/?)>/
 

startTagOpen 用於匹配開始標簽的左邊開頭部分,即 <div id="index">{{msg}}</div><div 部分。
startTagClose 用於匹配開始標簽的右邊閉合部分,即 >{{msg}}</div> 左邊開頭的 > 部分,請註意這一點,因為 Vue 是用步步蠶食(也就是解析一點,剪掉一點)的方法一點一點進行解析的。

開始標簽?結束標簽?
在這裡把 <div></div><div> 叫做開始標簽(startTag),把 </div> 叫做結束標簽(endTag)。

4、匹配結束標簽

const endTag = new RegExp(`^<\\/${tagName}[^>]*>`)
 

註意正則式中 ^ 放在首位表示匹配行首。因此該正則式可匹配到 </div><h1></h1></div>

解析 html 模板主要就用到這 4 個關鍵的正則式,接下來開始正式解析。

解析用到的工具方法

1、advance 方法

該方法用於步步蠶食,也就是每解析一部分,就從待解析的模板片段中去掉一部分,直到解析完畢,html.length0

let index = 0;

function advance (n) {
    index += n
    html = html.substring(n)
}
 

比如 <div id="index"> 經過 advance(4) 就變成 id="index">index 變數也從 0 變成了 4,表示已經解析了 4 個字元。

2、createASTElement 方法

這個方法用於構造一個 AST 元素節點(對應上面的 AST 節點類型定義),每解析一個標簽就要生成一個這樣的 AST 元素節點。註意傳入的 parent 參數,除了根元素,其它節點一般都有一個 parent 元素,還是那句話,多類比 DOM 樹。

function createASTElement (tag, attrs, parent){
    return {
        type: 1,
        tag,
        lowerCasedTag: tag.toLowerCase(),
        attrsList: attrs,
        parent,
        children: []
    }
}
 

解析開始標簽

接下來的內容就比較消耗腦細胞了,建議先仔細瞭解一下字元串的 match 方法,因為之後的解析里會多處用到。

老規矩,先看方法定義:

let root
let currentParent
let stack = []  // 標簽元素棧

function parseStartTag () {
    //-- 第一步 首先匹配開始標簽的左邊開頭部分 --
    const start = html.match(startTagOpen)
    if (start) {
        const match = {
            tagName: start[1],
            attrs: [],
            start: index
        }
        advance(start[0].length)

        //-- 第二步 迴圈解析開始標簽上的每一個屬性鍵值對 --
        let end, attr
        while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
            advance(attr[0].length)
            match.attrs.push({
                name: attr[1],
                value: attr[3]
            })
        }

        //-- 第三步 匹配到開始標簽的閉合部分,至此開始標簽解析結束 --
        if (end) {
            match.unarySlash = end[1]
            advance(end[0].length)
        }

        // 解析完標簽創建一個 AST 節點
        let element = createASTElement(match.tagName, match.attrs, currentParent)

        if(!root){
            root = element
        }

        if(currentParent){
            currentParent.children.push(element);
        }

        // 自閉合就不用壓入棧中了
        if (!match.unarySlash) {
            stack.push(element)
            currentParent = element
        }

    }
}
 

為了在解析到結束標簽時找到與之對應的開始標簽,Vue 通過維護一個標簽棧 stack 來匹配對應的標簽。currentParent 用於指向棧頂的 AST 節點。

以解析 <div id="index" class="content"> 為例,

經過第一步解析標簽名,解析的結果如下:

match = {
    tagName: "div",
    attrs: [],
    start: 0
}
 

此時 html 也經 advance 成了 id="index" class="content">

接著經過第二步解析屬性鍵值對,解析的結果變成:

match = {
    tagName: "div",
    attrs: [
        {
            "name": "id",
            "value": "index"
        },
        {
            "name": "class",
            "value": "content"
        }
    ],
    start: 0
}
 

此時 html 經過多次 advance 成了 >

然後經過第三步解析開始標簽閉合部分,並且生成了一個 AST 節點,最終的變數狀態如下:

match = {
    tagName: "div",
    attrs: [
        {
            "name": "id",
            "value": "index"
        },
        {
            "name": "class",
            "value": "content"
        }
    ],
    start: 0,
    end: 32,
    unarySlash: "",
}

root = element
stack = [element]
currentParent = element
 

此時 html 經過 advance 已經變成了空字元串,解析完畢。

什麼是棧?
類似於數組,是一種常用的線性表數據結構,可以使用數組輕鬆地實現。後進先出的操作方式特別適合 html 標簽的這種嵌套語法結構。

解析結束標簽

解析結束標簽的關鍵點是找到與之對應的開始標簽。

先看方法定義:

function parseEndTag () {
    const end = html.match(endTag);
    if (end) {
        advance(end[0].length)

        let tagName = end[1], lowerCasedTagName = tagName.toLowerCase()
        let pos

        // 從棧頂往棧底找,直到找到棧中離的最近的同類型標簽
        for (pos = stack.length - 1; pos >= 0; pos--) {
            if (stack[pos].lowerCasedTag === lowerCasedTagName) {
                break
            }
        }

        // 如果找到了就取出對應的開始標簽
        if (pos >= 0) {
            stack.length = pos
            currentParent = stack[stack.length - 1]
        }
    }
}
 

可以看到,在解析結束標簽時,會去找棧中離的最近的同類型標簽。在找到後會取出找到的節點並更新 currentParent 指向,也就是說假設 stack 現在為 ['div', 'p', 'a'],經過 parseEndTag 之後可能就會變成 ['div', 'p']currentParent 也從指向 a 變成了指向棧頂的 p

解析文本

文本為什麼需要解析?別忘了,Vue 是支持在文本中插值的,即 <div>hello, {{msg}}</div>{{msg}}。文本解析就是解析這些混在文本中的表達式。

建議先瞭解一下正則式的 exec 方法,本段代碼在遍歷時使用了它,註意它與字元串的 match 方法不同。

先看方法定義:

const defaultTagRE = /\{\{((?:.|\n)+?)\}\}/g

function parseText(text){
    if (defaultTagRE.test(text)) {
        // tokens 用於分割普通文本和插值文本
        const tokens = []
        let lastIndex = defaultTagRE.lastIndex = 0
        let match, index
        while ((match = defaultTagRE.exec(text))) {
            index = match.index

            // push 普通文本
            if (index > lastIndex) {
                tokens.push(JSON.stringify(text.slice(lastIndex, index)))
            }
            // push 插值表達式
            tokens.push(`_s(${match[1].trim()})`)

            // 游標前移
            lastIndex = index + match[0].length
        }

        // 將剩餘的普通文本壓入 tokens 中
        if (lastIndex < text.length) {
            tokens.push(JSON.stringify(text.slice(lastIndex)))
        }

        // 生成 ASTExpression 節點
        currentParent.children.push({
            type: 2,
            expression: tokens.join('+'),
            text
        })
    }else{
        // 生成 ASTText 節點
        currentParent.children.push({
            type: 3,
            text
        });
    }
}
 

可以看到,並沒有什麼特別的地方,只是遍歷傳入的字元串並將所有插值摘出來。例如 hello, {{msg}} 會被分割成 ['"hello"', '_s(msg)'],註意普通文本是被 JSON.stringify 了的,這樣在後面 tokens.join('+') 時才會變成 "hello"+_s(msg) 這種所期望的格式,也就是最簡單的字元串和變數拼接。

文本通常就是葉子節點了,因此文本和表達式的節點定義(ASTText和ASTExpression)中並沒有 parentchildren 欄位。

解析整塊 HTML 模板

終於到最後了,這是咱這幾年寫過的最長文章了o(╥﹏╥)o

html 文檔的結構基本上就是 <tag>text</tag> 這類標簽的各種嵌套,套來套去套出一個頁面。上面解析各部分(開始標簽、結束標簽、文本)的方法都已經有了,接下來就是使用上面的方法將整塊 html 模板一層一層剝開,從而構建出整棵 AST。

先看方法定義:

let html

function parseHTML(_html){
    html = _html

    while (html) {
        let textEnd = html.indexOf('<')
        if (textEnd === 0) {

            //-- 匹配開始標簽 --
            const startTagMatch = html.match(startTagOpen)
            if (startTagMatch) {
                parseStartTag()
                continue
            }

            //-- 匹配結束標簽 --
            const endTagMatch = html.match(endTag)
            if (endTagMatch) {
                parseEndTag()
                continue
            }
        }

        //-- 匹配文本 --
        let text, rest
        if (textEnd >= 0) {
            rest = html.slice(textEnd)
            text = html.substring(0, textEnd)
            advance(textEnd)
        }
        if (textEnd < 0) {
            text = html
            html = ''
        }
        text && parseText(text)
    }

    return root
}
 

可以看到,parseHTML 是迴圈一截一截把整塊 html 蠶食掉的。返回值 root 就是對生成的 AST 的引用,其實就是一個被精心組織的 JSON 對象,上篇已經提到,使用 JSON 描述樹形結構具有天然優勢。

現在看看忙活了半天的成果:

let tpl = `<div id="index"><p>hello, {{msg}}</p> by DOM哥</div>`
console.info(parseHTML(tpl))
 

控制台輸出截圖如下:

parseHTML 執行結果

Vue 解析 HTML 的主流程基本上就是這樣,由於是基於 HTML,還是比較簡單的。

戳這兒查看本文的完整代碼

未提及的細節

Vue 的實際實現做了大量的相容性處理,有針對某些瀏覽器(IE:看我乾什麼)的,也有針對 HTML 標簽的,比如 <p> 標簽既可以有結束標簽,也可以沒有結束標簽,因此需要特殊處理。另外還要考慮註釋的解析,特殊 html 標簽如 Doctype 的處理。總之需要考慮的地方很多,因此實際實現比上面要複雜的多,但處理的思路基本上是一樣的。

Vue 代碼分割的很嚴重,因此上面的實現代碼不可能全部集成在一個文件里,而是分成了好幾個小模塊,比如生成 AST 節點的模塊是抽出來的,處理文本的模塊也是單獨抽出來的。

如果想要錙銖必較地咀嚼每一行代碼,這是非常困難的,而且寸步難行,甚至最後會半途而廢。研究源碼最主要的是去學習其中的思路,而不要糾結在一字一句。

還記得 Vue 編譯器編譯成 render 函數的 3 個步驟嗎,生成 AST,優化 AST,生成 render 函數。本篇暫告一段落,將在下篇繼續研究 Vue 是如何優化 AST 的以及如何根據 AST 生成 render 函數。

大白話 Vue 源碼系列目錄

本系列會以每周一篇的速度持續更新,喜歡的小伙伴記得點關註哦。
您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 通過多次爬坑,發現了這些監聽滾動來載入更多的組件的共同點, 因為這些載入更多的方法是綁定在需要載入更多的內容的元素上的, 所以是進入頁面則直接觸發一次,當監聽到滾動事件之後,繼續載入更多, 所以對於無限滾動載入不需要寫首次載入列表的函數, 代碼如下: html: vue.js data: metho ...
  • WebSocket解析 轉載請註明出處: "WebSocket解析" 現在,很多網站為了實現推送技術,所用的技術都是輪詢。輪詢是指在特定的時間間隔(如每一秒),由瀏覽器對伺服器發起HTTP請求,然後由伺服器返回數據給瀏覽器 。由於HTTP協議是惰性的,只有客戶端發起請求,伺服器才會返回數據。輪詢技術 ...
  • 1、static(靜態定位):預設值。沒有定位,元素出現在正常的流中(忽略 top, bottom, left, right 或者 z-index 聲明)。 2、relative(相對定位):生成相對定位的元素,通過top,bottom,left,right的設置相對於其正常(原先本身)位置進行定位 ...
  • 1.首先要註冊高德地圖,完後成為開發者 2.控制台里獲取自己的key值 3.在要顯示地圖的頁面添加如下的代碼 <script type="text/javascript" src="https://cache.amap.com/lbs/static/addToolbar.js"></script>< ...
  • 轉自腳本之家: 這篇文章主要介紹了JS去掉字元串前後空格或去掉所有空格的用法,需要的朋友可以參考下: 代碼如下: 說明: 如果使用jQuery直接使用$.trim(str)方法即可,str表示要去掉前後所有空格的字元串。 2、 去掉字元串中所有空格(包括中間空格,需要設置第2個參數為:g) 3、現在 ...
  • 其實很簡單,就是title這個屬性:(字元多餘的剪切,title顯示完整的字元) 下麵是代碼: 以下是起因: 任務:頁面上顯示個備註.某某某. 某某某:知道了(不就是怎麼用textarea 填上去的怎麼顯示。我還要textarea就行) 過了一天.... 出現了一個問題:這個textarea文本域怎 ...
  • js中的不同的數據類型之間的比較轉換規則如下: 1. 對象和布爾值比較 對象和布爾值進行比較時,對象先轉換為字元串,然後再轉換為數字,布爾值直接轉換為數字 2. 對象和字元串比較 對象和字元串進行比較時,對象轉換為字元串,然後兩者進行比較。 3. 對象和數字比較 對象和數字進行比較時,對象先轉換為字 ...
  • 本文分為三個部分 一、JS數字精度丟失的一些典型問題 1. 兩個簡單的浮點數相加 1 0.1 + 0.2 != 0.3 // true 1 0.1 + 0.2 != 0.3 // true 1 0.1 + 0.2 != 0.3 // true 1 0.1 + 0.2 != 0.3 // true 0 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...