Vue源碼學習之模板編譯器原理

在Vue中,從模板到頁面更新的流程大概是這樣的:模板編譯器將用戶提供的一個模板字符串(或dom節點id)解析生成抽象語法樹,再經由優化器優化,標記所有的靜態節點後,交由代碼生成器生成渲染代碼,再通過渲染函數構建器將渲染代碼構建成一個渲染函數,調用這個渲染函數,我們就可以得到目標模板的虛擬dom,經過patching算法的對比後,得到最少更改的虛擬dom,再根據這個虛擬dom實現頁面的更新。

萬丈高樓平地起,模板編譯器便是上述整個渲染過程的第一塊磚頭,可見其重要性(當然,你也可以直接使用現成的渲染函數,這樣就可以跳過模板編譯過程了,但我們絕大多數情況都是直接使用模板進行開發的)。

那麼,我們現在來看一下,模板編譯器是怎麼實現的吧。

注:本文只是介紹模板編譯器的原理與基本實現,有一些跟模板編譯器關聯性不是很大的代碼可能會被省略,如果想要詳細的瞭解這些未說明部分的原理和邏輯,可以看本人github最新分支(PS:不要看master分支,那個是第一版的代碼,只實現了數據響應化等基本邏輯。),截止至本文發佈,github已經更新值dev-0.0.1分支。附上github傳送門:kinerVue/dev-0.0.1

parseHTML的原理

既然是解析模板字符串,也就是解析一段html的字符串,那麼本質上其實就是對字符串的解析處理。在Vue中,對模板字符串的處理原理其實很簡單:

就是我每匹配到一個滿足要求的字符串

所謂的滿足要求即:

  • 是否是開始標籤,如:<div ...> ;
  • 是否是結束標籤,如:</div> ;
  • 是否是一段文本,如:<div>這是一段代碼</div>中的文字;
  • 是否是一個註釋,如:<!--這是一個註釋-->

如果滿足以上任何一種情況,他便會觸發對應的鉤子函數,通知調用paserHTML的地方去收集這些信息,然後根據這些信息生成抽象語法樹。

大概的示例代碼是這樣的:

let html = '<div><!--這是一個註釋--><span>這是一段文本</span></div>';

function parseHTML(html, options) {
    while (html) {
        // 首先通過正則匹配判斷標籤是否是註釋元素(註釋包括普通註釋和條件註釋)
        // 如果是的話,觸發註釋鉤子函數通知收集信息,並將當前匹配的文本從html中截取掉
        // options.comment(text /*註釋的文本*/ , curIndex /*註釋所在的開始位置*/ , endIndex /*註釋所在的結束位置*/ );
        // html = html.substring(endIndex);

        // 其次通過正則判斷是否是結束標籤
        // options.end(tag /*標籤名*/ , curIndex /*註釋所在的開始位置*/ , endIndex /*註釋所在的結束位置*/ );
        // html = html.substring(endIndex);

        // 其次通過正則判斷是否是開始標籤
        // options.start(tag /*標籤名*/ ,[]/*屬性列表*/, true/*是否是自閉標籤*/, curIndex /*註釋所在的開始位置*/ , endIndex /*註釋所在的結束位置*/ );
        // html = html.substring(endIndex);

        // 最後判斷是否是文本
        // options.char(text /*文本*/ , curIndex /*文本所在的開始位置*/ , endIndex /*文本所在的結束位置*/ );
        // html = html.substring(endIndex);
    }
}
parseHTML(html, {
    start(tag, attrs, isUnary, startIndex, endIndex) {
        // 收集整理信息
    },
    end(tag, startIndex, endIndex) {
        // 收集整理信息
    },
    chars(text, startIndex, endIndex) {
        // 收集整理信息
    },
    comment(text, startIndex, endIndex) {
        // 收集整理信息
    },
});

從上面的代碼可以看到,我們將模板字符串是否存在作爲while的終止條件,我們每匹配到 一種情況,就會將相應的文本從html中刪除掉,這樣不斷循環下去,就會不斷的觸發響應的鉤子函數收集數據,直至html變成空字符串退出循環,此時,我們已經將整個文本都解析完了。

那麼,我們再來看看我們再鉤子函數裏都要做什麼操作呢?

鉤子函數start

  • 根據傳過來的標籤名創建抽象語法樹元素節點
  • 檢查一些非法屬性或標籤,並對一些特殊情況做預處理
  • 解析attrs
  • 解析指令v-if,v-for,v-once等
  • 如果不是自閉標籤的話,將當前元素加入到棧中,用於維護元素間的父子關係

鉤子函數end

  • 將棧頂元素彈出(因爲當前標籤已經結束後了,棧頂存的就是當前標籤)
  • 重新更正父級標籤(因爲當前標籤已經結束,說明他的子節點也都解析完了,父標籤不在是當前標籤了,父級標籤有重新變回當前標籤的父級標籤)
  • 關閉標籤,此時對if條件分支進行一些補充以及進行一些收尾工作等

鉤子函數chars

  • 創建抽象語法樹文本節點
  • 將這個文本節點加入到父節點的children中

鉤子函數comment

  • 創建抽象語法樹註釋節點
  • 只要註釋節點存在父級,就把註釋節點加入到父級節點的children中

當執行完整個邏輯,我們就會得到一個抽象語法樹的根節點,通過這個根節點可以找到他下面的所有子節點及其相應的屬性、文本等。

以下爲parseHTML涉及到的主要邏輯代碼,如果想要看全部代碼,可以觀看本人github:kinerVue/dev-0.0.1

// compiler/parse.js 定義了用於真正解析html模板和解析文本(包括靜態文本和帶參數的動態文本)的方法

import {
    cached,
    canBeLeftOpenTag,
    decodingMap,
    isNonPhrasingTag,
    isPlainTextElement,
    isUnaryTag, makeMap,
    noop
} from "../shared/utils.js";
import {
    attribute,
    comment,
    conditionalComment, defaultTagRE,
    doctype,
    dynamicArgAttribute, encodedAttr, encodedAttrWithNewLines,
    endTag, regexEscapeRE,
    startTagClose,
    startTagOpen
} from "../shared/RE.js";
import SimpleStack from "../shared/SimpleStack.js";
import {parseFilter} from "./filter-paser.js";


// #5992 忽略pre和textarea標籤的第一個換行符
const isIgnoreNewlineTag = makeMap('pre,textarea', true);
const shouldIgnoreFirstNewline = (tag, html) => tag && isIgnoreNewlineTag(tag) && html[0] === '\n';

/**
 * 解析html模板,並通過不斷觸發鉤子函數通知調用者手機相關信息創建抽象語法樹
 * @param html
 * @param options
 */
export const parseHTML = (html, options) => {
    let {
        start: startHook = noop,
        end: endHook = noop,
        chars: charsHook = noop,
        comment: commentHook = noop,
        shouldKeepComment = true,// 是否需要保留註釋
        shouldDecodeNewlinesForHref = false,// 是否應該對a標籤的href進行一次編碼
        shouldDecodeNewlines = false// 是否應該對屬性值進行一次編碼
    } = options;
    let lastTag, last;
    let endChars;// 截止字符串
    let index = 0;// 當前指針所在的位置
    const stack = new SimpleStack();// 用於存儲標籤信息的棧,通過將標籤信息存儲再棧中方便標籤的匹配處理和父級標籤的尋找


    while (html) {
        last = html;
        if (!lastTag || !isPlainTextElement(lastTag)) {
            // 父元素爲正常元素
            let textEnd = html.indexOf('<');
            if (textEnd === 0) {

                // 首先判斷標籤是否是註釋元素
                if (comment.test(html)) {
                    // 找出第一個註釋結束標籤的索引
                    endChars = '-->';
                    const commentEnd = html.indexOf(endChars);
                    if (commentEnd >= 0) {
                        // 看一下配置是否需要保留註釋,如果不需要保留註釋,則不觸發鉤子函數,否則觸發
                        if (shouldKeepComment) {
                            // 觸發鉤子函數
                            // 參數有三個:
                            // 1、註釋文本
                            // 2、指針開始位置,即上一個節點的結束位置
                            // 3、指針結束位置,即註釋節點的結束位置
                            // 截取註釋文本 <!-- 註釋文本 -->
                            commentHook(html.substring(4, commentEnd), index, index + commentEnd + endChars.length);

                            // 指針向前,指向註釋標籤的後面一個節點
                            advance(commentEnd + endChars.length);

                            // 本次處理完畢,繼續下一次的字符串切割處理
                            continue;
                        }
                    }
                }

                // 如果不是普通註釋,再看看是不是條件註釋
                if (conditionalComment.test(html)) {
                    endChars = ']>';
                    // 找到條件註釋的截止位置
                    const commentEnd = html.indexOf(endChars);

                    if (commentEnd >= 0) {
                        // 條件註釋無需觸發commentHook鉤子函數,直接跳過即可
                        advance(commentEnd + endChars.length);

                        // 本次處理完畢,繼續下一次的字符串切割處理
                        continue;
                    }
                }

                // 如果是文檔類型標籤,如:<!DOCTYPE html>
                const docTypeMatch = html.match(doctype);
                if (docTypeMatch) {
                    // 如果是文檔類型標籤,也直接跳過
                    advance(docTypeMatch[0].length);
                    // 本次處理完畢,繼續下一次的字符串切割處理
                    continue;
                }

                // 如果是結束標籤,如</div>
                const endTagMatch = html.match(endTag);
                if (endTagMatch) {
                    // 記錄結束標籤開始位置
                    const curIndex = index;
                    // 遊標移動到結束標籤終止位置
                    advance(endTagMatch[0].length);
                    // 處理結束標籤
                    parseEndTag(endTagMatch[1], curIndex, index);
                    // 本次處理完畢,繼續下一次的字符串切割處理
                    continue;
                }

                // 解析開始標籤,如<div>
                const startTagMatch = parseStartTag();
                if (startTagMatch) {
                    // 處理開始標籤
                    handleStartTag(startTagMatch);
                    if (shouldIgnoreFirstNewline(startTagMatch.tag, html)) {
                        // 如果在pre和textarea內第一個字符是換行符的話,需要忽略這個換行符,否則在解析文本的時候會出問題
                        advance(1);
                    }
                    // 本次處理完畢,繼續下一次的字符串切割處理
                    continue;

                }
            }
            // 解析文本
            // 若textEnd>0,說明html字符串不是以標籤開頭,而是以文本開頭
            // e.g.
            // 這是一段文本<</div>
            let text, rest, next;
            if (textEnd > 0) {
                // 將以<開頭的字符串取出來,看一下這個字符串是不是開始標籤、結束標籤、註釋、條件註釋,
                // 如果都不是,就把他當做是普通文本,然後繼續往下找下一個<,知道匹配到開始標籤、結束標籤、註釋、條件註釋位置
                rest = html.slice(textEnd);

                while (
                    !endTag.test(rest) && // 是不是終止標籤
                    !startTagOpen.test(rest) && // 是不是開始標籤
                    !comment.test(rest) && // 是不是註釋標籤
                    !conditionalComment.test(rest) // 是不是條件註釋
                    ) {
                    // 能進入這裏,說明rest裏面的那個<不是開始標籤、結束標籤、註釋、條件註釋中的一種,那我們就把他當做是普通的文本
                    // 然後繼續找下一個<
                    next = rest.indexOf('<',1);
                    // 如果找不到下一個<了,說明剩餘的這個html就是以一段文字作爲結尾的,如:這是一段文本<- _ ->
                    // 那麼我們就不需要再往下尋找了,直接退出循環即可
                    if(next<0) break;
                    // <的位置變了,所以要更新一下textEnd,不然textEnd還是指向上一個<
                    textEnd += next;
                    // 以新的<作爲起始再次截取字符串,進入下一個循環,看看這一次的<是不是開始標籤、結束標籤、註釋、條件註釋中的一種
                    rest = html.slice(textEnd);
                }
                // 當循環結束之後,textEnd就已經指向了文本節點的最後的位置了
                text = html.substring(0, textEnd);
            }

            // 如果textEnd<0,那麼說明我們的html中根本就沒有開始標籤、結束標籤、註釋、條件註釋,他就是一個純文本
            if(textEnd<0){
                text = html;
            }

            // 我們已經將當前的文本獲取到了,如果有文本的話,那麼我們就將遊標移動到文本末尾,準備進行下一輪的查找
            if(text){
                advance(text.length);
                // 文本節點已經獲取到了,觸發文本節點鉤子
                charsHook(text, index - text.length, index);
            }

        } else {
            // 父級元素是script、style、textarea
            // TODO 特殊標籤邏輯暫不處理
        }
    }


    /**
     * 輔助函數,用於讓切割文本的指針按照所傳的步數不斷向前,並在向前的同時不斷的切割文本從而清理掉已經處理過或不需要處理的文本,
     * 讓指針始終指向待處理的文本
     * @param step  要向前移動幾步
     */
    function advance(step) {
        index += step;
        html = html.substring(step);
    }

    /**
     * 用於解析開始標籤及其屬性等
     * @returns {{tag: *, attrs: Array, startIndex: number}}
     */
    function parseStartTag() {
        // 解析開始標籤
        const start = html.match(startTagOpen);
        if (start) {
            let match = {
                tag: start[1],
                attrs: [],
                startIndex: index
            };
            // 找到開始標籤的標籤名了,往後走再看看他都有哪些舒緩型
            advance(start[0].length);

            let tagEnd,// 開始標籤是否遇到結束符>,如果遇到了結束符,說明開始標籤已經解析完畢了
                attr;// 暫存當前的屬性描述字符串,可能是常規的html標籤屬性,也可能是vue自帶的動態屬性,如:@click="a"、v-html="b"、:title="title"等

            // 循環只有當遇到了結束符或者是已經再也解析不出屬性來的時候纔會結束
            while (!(tagEnd = html.match(startTagClose)) && (attr = (html.match(attribute) || html.match(dynamicArgAttribute)))) {
                // 記錄每一個屬性的開始位置
                attr.start = index;
                // 指針右移到屬性末尾的位置
                advance(attr[0].length);
                // 記錄屬性的結束位置
                attr.end = index;
                // 將找到的屬性添加到match中的屬性列表中
                match.attrs.push(attr);
            }
            // 當到達結束位置時,看一下這個標籤是不是自閉標籤,如果是的話,儲存他的自閉分隔符/,方便只有用來判斷該標籤是否自閉
            if (tagEnd) {
                // 存儲自閉符號
                match.unarySlash = tagEnd[1];
                // 指針右移至開始標籤最後
                advance(tagEnd[0].length);
                // 記錄下開始標籤的結束位置
                match.endIndex = index;
            }
            // 開始標籤解析完成,返回匹配結果
            return match;
        }
    }

    /**
     * 處理開始標籤的屬性等
     * @param match
     */
    function handleStartTag(match) {
        const {tag, unarySlash, attrs, startIndex, endIndex} = match;

        // 如果當前標籤的上一個標籤是一個p標籤,並且當前正在解析的標籤不是一個段落元素標籤,那麼我們就直接調用parseEndTag將p標籤結束掉
        // 因爲在HTML標準中,p標籤只能嵌套段落元素,其他元素如果嵌套在p標籤中會被自動解析到p標籤外面
        // e.g.
        // <p><span>這是內聯元素</span><div>這是塊級元素</div></p>
        // 在瀏覽器中會被解析成:
        // <p><span>這是內聯元素</span></p><div>這是塊級元素</div><p></p>
        // html5標籤相關文檔鏈接:https://html.spec.whatwg.org/multipage/indices.html#elements-3
        // 段落標籤相關文檔鏈接:https://html.spec.whatwg.org/multipage/dom.html#phrasing-content
        if (lastTag === "p" && isNonPhrasingTag(tag)) {
            parseEndTag(lastTag);
        }
        // 如果標籤的上一個標籤跟當前解析的標籤名相同並且當前標籤屬於"可省略閉合標籤",那麼,直接調用parseEndTag把上一個標籤結束掉
        // e.g.
        // <ul>
        //      <li> 選項1
        //      <li> 選項2
        //      <li> 選項3
        //      <li> 選項4
        // </ul>
        // ???如果加上這個判斷的話,回到值觸發兩次end回調,因爲在瀏覽器中出現上述情況時,我們再獲取outerHTML的時候瀏覽器已經把元素轉換爲:
        //
        // <ul>
        //     <li> a
        //     </li><li> b
        //     </li><li> c
        //     </li><li> d
        // </li></ul>

        if (canBeLeftOpenTag(tag) && tag === lastTag) {
            parseEndTag(lastTag);
        }

        // 當前解析的標籤是否爲自閉標籤
        // 自閉標籤分爲兩種情況,一種是html內置的自閉標籤,一種是用戶自定義標籤或組件時自閉的
        const unaryTag = isUnaryTag(tag) || !!unarySlash;


        // 由於在不同的瀏覽器中,對標籤屬性的處理有所區別,如在IE瀏覽器中,會將所有的屬性值進行一次編碼,如:
        // <div name="\n"/>         =>      <div name="&#10;"/>
        // 再如在chrome瀏覽器中,會對a標籤的href屬性進行一次編碼
        // <a href="\n"/>           =>      <a href="&#10;"/>
        // 因此,我們需要對屬性值做一下處理,對這些屬性進行解碼
        let len = attrs.length;
        let newAttrs = new Array(len);

        for (let i = 0; i < len; i++) {
            let attrMatch = attrs[i];
            const value = attrMatch[3] || // 匹配屬性格式:name="kiner"
                attrMatch[4] ||  // 匹配屬性格式:name='kiner'
                attrMatch[5] ||  // 匹配屬性格式:name=kiner
                "";

            // 若解析的標籤是a標籤且當前屬性名是href,則根據當前瀏覽器環境看是否需要對\n換行符進行解碼(有些瀏覽器會對屬性值進行編碼處理)
            const theShouldDecodeNewlines = tag === 'a' && attrMatch[1] === 'href'
                ? shouldDecodeNewlinesForHref
                : shouldDecodeNewlines;

            // 將處理過的屬性放到newAttrs中
            newAttrs[i] = {
                name: attrMatch[1],
                value: decodeAttr(value, theShouldDecodeNewlines)
            }
        }
        // 判斷當前標籤是否爲自閉標籤,若不是自閉標籤,則需要將解析出來的當前標籤的信息壓入棧中,方便後續用來匹配標籤以及查找父級使用
        if (!unaryTag) {
            stack.push({
                tag: tag,
                lowerCaseTag: tag.toLowerCase(),
                attrs: newAttrs,
                startIndex: match.startIndex,
                endIndex: match.endIndex
            });
            // 將當前標籤名賦值給lastTag,方便後續的對比操作
            lastTag = tag;
        }

        // 開始標籤的信息已經解析完畢,通知鉤子函數
        startHook(tag, newAttrs, unaryTag, startIndex, endIndex);


    }

    /**
     * 對一些已經被編碼的屬性值進行解碼
     * @param val
     * @param shouldDecodeNewlines
     * @returns {string | * | void}
     */
    function decodeAttr(val, shouldDecodeNewlines) {
        const reg = shouldDecodeNewlines ? encodedAttrWithNewLines : encodedAttr;
        return val.replace(reg, match => decodingMap[match]);
    }

    function parseEndTag(tag, startIndex = index, endIndex = index) {
        let pos,// 用於查找當前結束標籤對應開始標籤的遊標變量
            lowerCaseTagName;// 當前結束標籤的小寫標籤名

        if (tag) {
            lowerCaseTagName = tag.toLowerCase();
            // 通過結束標籤名在標籤棧中從上往下查找最近的一個匹配標籤,並返回標籤的遊標索引
            pos = stack.findIndex(tag => tag.lowerCaseTag === lowerCaseTagName);
        } else {
            pos = 0;
        }

        if (pos >= 0) {
            let popElems = stack.popItemByStartIndex(pos);
            popElems.forEach(elem=>{
                //找到了開始標籤了說明這個結束標籤是有效的,觸發endHook
                endHook(elem.tag, startIndex, endIndex);
            });
            let top = stack.top();
            (top&&(lastTag = top.tag));
        } else if (lowerCaseTagName === "br") {
            // br是一個自閉的標籤,有三種寫法:<br/> 或 <br> 或 </br>
            // 這裏就是匹配第三中寫法的,雖然這種寫法很少見,而且不太推薦使用,
            // 但在html中這麼使用確實是不會報錯,所以還是要兼容一下
            // 因爲br是自閉標籤,也沒沒有什麼其他情況需要處理的,我們指直接觸發他的startHook就可以了
            startHook(tag, [], true, startIndex, endIndex);

        } else if (lowerCaseTagName === "p") {
            // 由於通過pos沒能在標籤棧中找到與當前p標籤匹配的開始標籤,因此,這個標籤應該是一個 </p> 的一個單獨的標籤
            // 因爲在html解析的時候,遇到這樣一個單獨的閉合p標籤,會自動解析爲<p></p>,因此,此時既要觸發startHook也要出發endHook
            startHook(tag, [], false, startIndex, endIndex);
            endHook(tag, startIndex, endIndex);
        }

    }
};


// compiler/compile-tpl-to-ast.js 本文件用於將一個html模板生成一個抽象語法樹

import {parseHTML, parseText} from "./parse.js";
import SimpleStack from "../shared/SimpleStack.js";
import {cached, isForbiddenTag, isIE, isPreTag, isTextTag, warn} from "../shared/utils.js";
import {createASTComment, createASTElement, createASTExpression, createASTText} from "./Ast.js";
import {invalidAttributeRE, lineBreakRE, whitespaceRE} from "../shared/RE.js";
import {
    addIfCondition,
    preTransformNode, processElement,
    processFor,
    processIf, processIfConditions,
    processOnce,
    processPre,
    processRawAttrs
} from "./helper.js";
import {AST_ITEM_TYPE} from "../shared/constants.js";

// 第三方html編碼解碼庫
import he from "../shared/he.js";
// 將解碼方法加入到緩存中
const decodeHTMLCached = cached(he.decode);

/**
 * 根據模板與選項轉換成抽象語法樹
 * @param tpl
 * @param options
 * @returns {{type: string, tag: *, attrList: *, parent: Window, children: Array}|*}
 */
export const parse = (tpl, options) => {

    tpl = tpl.trim();
    console.log('待轉換模板:', tpl);

    return compilerHtml(tpl, options);
};
let currentParent = null;
let nodeStack = new SimpleStack();
let inVPre = false;// 是否標記了v-pre,若標記了,則編譯時可以跳過內部文本的編譯工作,加快編譯效率
let inPre = false;// 當前標籤是否爲pre標籤
let root;// 根節點

/**
 * 編譯html模板,編譯完成返回抽象語法樹的根節點
 * @param tpl
 * @param options
 * @returns {*}
 */
export const compilerHtml = (tpl, options) => {

    parseHTML(tpl, {
        ...options,
        start(tag, attrs, isUnary, startIndex, endIndex) {
            // 當解析到標籤開始位置時會執行這個鉤子函數,將標籤名和對應的屬性傳過來

            let elem = createASTElement(tag, attrs, currentParent);


            // 檢測非法屬性並提示
            attrs.forEach(attr => {
                if (invalidAttributeRE.test(attr.name)) {
                    warn(`屬性名:${attr.name}中不能包含空格、雙引號、單引號、<、>、\/、= 這些字符`);
                }
            });

            // 如果當前標籤是一個<style>...</style>或<script></script>、<script type="type/javascript"></script>的話
            // 提示用戶這是一個在模板中被禁止使用的標籤,因爲模板僅僅只是用來描述狀態與頁面的呈現的,不應該包含樣式和腳本標籤
            if (isForbiddenTag(elem)) {
                elem.forbidden = true;
                warn(`模板文件只是用來建立狀態與UI之間的關係,不應該包含樣式與腳本標籤,當前使用的標籤:${elem.tag}是被禁止的,我們不會對他進行便編譯`);
            }

            // 處理checkbox、radio等需要預處理的標籤
            preTransformNode(elem);

            // 如果inVPre爲false,可能還沒有解析當前標籤是否標記了v-pre
            if (!inVPre) {
                // 解析一下
                processPre(elem);
                // 如果解析過後發現elem上標記有pre=true,說明標籤確實標記了v-pre
                if (elem.pre) {
                    // 修正inVPre
                    inVPre = true;
                }
            }

            // 當然,除了vue的指令v-pre之外,我們html也自帶一個pre標籤,
            // 如果標籤名是pre,那也要將inPre標記爲true
            isPreTag(elem.tag) && (inPre = true);


            if (inVPre) {
                // 如果一個標籤被標記了v-pre,那我們只需要把attrList中剩餘的屬性複製到elem的attrs中去即可
                // 因爲attrList中的其他屬性都在剛剛進行預處理的時候已經處理並從attrList中刪除了
                processRawAttrs(elem);
            } else if (!elem.processed) {
                // 如果還有沒有處理的結構指令,如v-for、v-if等,就處理一下
                processFor(elem);
                processIf(elem);
                processOnce(elem);
            }

            // 如果不存在根節點,則當前節點就是根節點
            !root && (root = elem);

            // 判斷當前節點是不是一個自閉標籤,如果是一個自閉標籤,那麼直接結束當前標籤解析
            // 如果是不是自閉標籤,我們需要記錄下當前節點當做是下個節點的父級元素,並加這個元素壓入棧中
            if (isUnary) {
                closeElement(elem);
            } else {
                currentParent = elem;
                nodeStack.push(elem);
            }

        },
        end(tag, startIndex, endIndex) {
            // TODO 觸發了兩次,未解決
            // console.log(`解析到終止標籤:${tag}`, startIndex, endIndex);
            // 當前標籤已經解析結束了,將標籤從棧中彈出
            let elem = nodeStack.pop();
            // 此時棧頂元素便是我們下一個元素的父級
            currentParent = nodeStack.top();
            // 關閉標籤
            closeElement(elem);
        },
        chars(text, startIndex, endIndex) {
            // console.log(`解析到文本:${text}`, startIndex, endIndex);
            // 如果不存在父級節點,那麼我們可以得知,
            // 這個解析出來的文本,要麼就是在根節點之外,要麼,壓根就沒有根節點,所給的tpl直接就是一段文本
            if (!currentParent) {
                // 如果解析出來的文本跟傳入的模板完全相同,那麼,說明直傳進來一個文本內容,警告提示
                if (text === tpl) {
                    warn(`組件模板需要一個根元素,而不僅僅是文本。`);
                } else if ((text = text.trim())) { // 文本定義在了根節點的外面,警告提示
                    warn(`定義在根節點之外的文本:${text}將會被忽略掉`);
                }
                // 沒有父節點的文本,壓根就沒有存在的意義,直接人道毀滅,不管他吧
                return;
            }

            // 在IE瀏覽器中的textarea的placeholder有一個bug,瀏覽器會將placeholder的內容會被作爲textarea的文本節點放入到textarea中
            // 如果是這種情況框,直接忽略他吧,IE太難伺候了
            if (isIE &&
                currentParent.tag === 'textarea' &&
                currentParent.attrsMap.placeholder === text
            ) {
                return;
            }

            const children = currentParent.children;
            // 如果當前文本在pre標籤裏或者是文本去掉前後空白後依然不爲空
            if (inPre || text.trim()) {
                // 如果父級標籤是純文本標籤,那麼解析出來的文本就是我們要的內容
                // 如果不是的話,需要進行一定的解碼,這裏使用的是一個第三方的html編解碼庫:he
                // he鏈接爲:https://www.npmjs.com/package/he
                text = isTextTag(currentParent) ? text : decodeHTMLCached(text);
            } else if (!children.length) {
                // 如果當前文本父級元素下面沒有子節點的話並且當前文本刪除前後空格之後爲空字符串的話,我們就清空文本
                // 請注意,判斷當前文本刪除前後空格之後是否爲空字符串是在上線的if語句中判斷的,我剛開始看的時候不理解,
                // 爲啥父元素沒有子節點就要清空文本呢,那要是他是<div>這是一段文字</div>呢?原來是因爲我漏了上面的if判斷裏面
                // 還有一個text.trim(),如果一個文本去除前後空白之後不爲空的話,那他就應該進入到if的分支,而不會進入到這裏。正是
                // 因爲他去除空白之後是空的,所以纔會進入到這個判斷邏輯,那麼,結果就很明顯了,現在正在判斷的情況是:
                // <div>        </div>,那麼我們直接把text清空就可以了。
                text = '';

            } else if (options.whitespaceOption) {// 根據不同的去空白選項將空白去掉
                //   ``` html
                //      <!-- source -->
                //      <div>
                //        <span>
                //          foo
                //        </span>   <span>bar</span>
                //      </div>
                //
                //      <!-- whitespace: 'preserve' -->
                //      <div> <span>
                //        foo
                //        </span> <span>bar</span> </div>
                //
                //      <!-- whitespace: 'condense' -->
                //      <div><span> foo </span> <span>bar</span></div>
                //   ```
                if (options.whitespaceOption === 'condense') {
                    text = lineBreakRE.test(text) ? '' : ' '
                } else {
                    text = ' ';
                }
            } else {// 其他情況:看看是不是需要保留空格
                text = options.preserveWhitespace ? ' ' : '';
            }

            if (text) {
                // 如果不是在pre標籤中且刪除空白的選項是condense,則刪除文本中的換行符
                if (!inPre && options.whitespaceOption === "condense") {
                    text = text.replace(whitespaceRE, '');
                }

                let res, elem;
                // 如果當前節點沒有v-pre屬性且是一個空白符並且可以解析出動態變量
                if (!inPre && text !== ' ' && (res = parseText(text, options.delimiters))) {
                    elem = createASTExpression(text, res.exp, res.tokens);
                } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
                    elem = createASTText(text);
                }
                // 將創建的文本節點或表達式節點加入到父級節點的children中
                if (elem) {
                    children.push(elem);
                }
            }

        },
        comment(text, startIndex, endIndex) {
            // 只有在根節點下創建註釋纔有效,只要不在根節點內部的註釋都會被忽略
            if (currentParent) {
                let elem = createASTComment(text);
                currentParent.children.push(elem);
            }
        }
    });

    return root;
};

let warned = false;
/**
 * 輔助警告提示類,由於在生成模板過程是在循環體裏面,爲避免重複警告提示,定義這個只要提示一次就不再提示的警告方法
 * @param message
 */
function warnOnce(message) {
    if (!warned) {
        warn(message);
        warned = true;
    }
}

/**
 * 關閉標籤並做一些收尾工作
 * @param elem
 */
export const closeElement = elem => {
    if(!elem) return;
    // 若當前元素不是pre元素,則刪除元素尾部的空白文本節點
    trimEndingWhitespace(elem);

    // 如果當前標籤沒有v-pre並且沒有編譯過,則編譯一下
    if (!inVPre && !elem.processed) {
        processElement(elem);
    }

    // 當我們的元素存儲棧爲空並且當前元素不是根節點時
    // 即模板中的元素都是自閉標籤,如:
    // 正確的做法(由於加上了判斷,因此,同時只可能有一個元素被輸出):<input v-if="value===1"/><img v-else-if="value===2"/><br v-else="value===3"/>
    // 錯誤的做法(因爲vue模板始終需要一個根元素包裹,這裏已經有三個元素了):<input/><img/><br/>
    // 此時根節點root=input,但當前元素是br,由於元素都是自閉標籤,因此不存在父子關係,大家都是平級,
    // 因此,也就不會想用於維護層級關係的nodeStack中添加元素
    if (!nodeStack.size() && root !== elem) {
        if (root.if && (elem.elseIf || elem.else)) {
            addIfCondition(root, {
                exp: elem.elseIf,
                block: elem
            });
        } else {
            warnOnce(`模板必須保證只有一個根元素,如果你想用v-if動態渲染元素,請將其他元素也用v-else-if串聯成條件鏈`);
        }
    }

    // 如果不是根節點且不是script或style之類被禁止的標籤的話
    if (currentParent && !elem.forbidden) {
        // 如果當前標籤綁定有v-else-if或v-else,則需要解析一下
        if (elem.elseIf || elem.else) {
            processIfConditions(elem, currentParent);
        } else {
            // 如果當前標籤是一個作用域插槽
            if (elem.slotScope) {
                // 獲取插槽名稱
                const name = elem.slotTarget || '"default"';
                // 將它保留在子列表中,以便v-else(-if)條件可以
                // 找到它作爲prev節點。
                (currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = elem;
            }
            // 把當前元素加入到父級元素的子節點列表中,從而創建AST的父子層級關係
            currentParent.children.push(elem);
            // 同時也將當前節點的父級節點標記爲當前的父級節點
            elem.parent = currentParent
        }
    }

    // 最後,因爲作用域插槽並不是一個真實的標籤,我們需要把他從子節點中移除掉
    elem.children = elem.children.filter(item => !item.slotScope);

    // 因爲我們上線又操作過元素了,可能會在後面產生一些空白文本節點,我們再清理一下
    trimEndingWhitespace(elem);

    // 然後,因爲我們的inVPre和inPre是公共變量,一個標籤解析結束之後,需要重置一下,否則會影響下一個標籤的解析
    if (elem.pre) {
        inVPre = false;
    }
    if (isPreTag(elem.tag)) {
        inPre = false;
    }
    // 注:vue還有這樣一個不走,不過我看了一下,這個步驟好像只對weex環境纔有注入方法postTransforms,因此此處就不實現了
    // // apply post-transforms
    // for (let i = 0; i < postTransforms.length; i++) {
    //   postTransforms[i](element, options)
    // }
};

/**
 * 若當前元素不是pre元素,則刪除元素尾部的空白文本節點
 * @param elem
 */
function trimEndingWhitespace(elem) {
    if (inPre) {
        let lastNode;
        while (
            (lastNode = elem.children[elem.children.length - 1]) && // 節點存在
            lastNode.type === AST_ITEM_TYPE.TEXT && // 是文本節點
            lastNode.text === ' ' // 文本節點的內容是空白符
            ) {
            // 彈出該元素
            elem.children.pop();
        }
    }
}

 

parseText的原理

上面已經解釋了html的解析原理,但並沒有對文本,特別是帶有插值表達{{name}}這樣的文本進行解析,現在,咱們再來看一下如何解析特殊文本的。

/**
 * 構建動態文本轉化正則表達式
 * @type {function(*): *}
 */
const buildRegex = cached(delimiters => {
    // 將分割符變爲轉義字符
    // "{{".replace(/[-.*+?^${}()|[\]\/\\]/g,'\\$&');
    // \{\{
    const open = delimiters[0].replace(regexEscapeRE, '\\$&');

    // "}}".replace(/[-.*+?^${}()|[\]\/\\]/g,'\\$&');
    // \}\}
    const close = delimiters[1].replace(regexEscapeRE, '\\$&');

    return new RegExp(open + '((?:.|\\n)+?)' + close, 'g')
});

/**
 * 文本解析器,用於解析文本中的變量
 * @param {String} text
 * @param {Array} delimiters        分隔符,默認是:["{{","}}"]
 * @returns {Object}
 */
export const parseText = (text, delimiters) => {
    const expRE = delimiters ? buildRegex(delimiters) : defaultTagRE;
    if (!expRE.test(text)) return '';

    // 將正則的遊標移動到開始的位置
    let lastIndex = expRE.lastIndex = 0;

    let match, index, res = [],tokenValue = '',rawTokens = [],exp;

    while ((match = expRE.exec(text))) {

        index = match.index;

        // 將{{之前的文本加入到結果數組
        if (index > lastIndex) {
            rawTokens.push((tokenValue = text.slice(lastIndex, index)))
            res.push(JSON.stringify(tokenValue));
        }

        exp = match[1].trim();
        // 解析過濾器
        exp = parseFilter(exp);
        // 將解析出來的變量轉化爲調用方法的方式並加入結果數組如:_s(name)
        res.push(`_s(${exp})`);
        rawTokens.push({ '@binding': exp });

        // 設置lastIndex保證下一次循環不會重複匹配已經解析過的文本
        lastIndex = index + match[0].length;

    }

    // 將}}之後的文本加入到結果數組
    if (lastIndex < text.length) {
        rawTokens.push((tokenValue = text.slice(lastIndex)));
        res.push(JSON.stringify(tokenValue));
    }

    return {
        exp: res.join('+'),
        tokens: rawTokens
    };
};

從上面的代碼中,我們可以看到,其實對於文本的解析邏輯也並不難,就是通過正則匹配目標表達式,然後,然後將匹配到的所有表達式生成的代碼片段 `_s(${exp})` 和普通文本的加入到一個數組中 ,最後通過join('+')將這些字符串串聯起來。其中,_s是一個運行時纔會注入的工具方法,其實就把一個值轉換成字符串的方法。

構建一個具有父子層級關係的抽象語法樹

從上面解析html代碼的時候,我們可以看到,我們使用了一個叫做nodeStack的棧用來存儲標籤,我們之前也說了,通過這個棧,可以幫助我們維護標籤之前的父子關係,那麼,他到底是怎麼通過nodeStack維護父子關係的呢?nodeStack有到底是什麼呢?

我們先來解釋一下nodeStack吧,這個其實本質上就是一個普通的棧結構的對象,所謂棧,就是嚴格遵循後進先出的順序操作的數組。下面來看一下這個棧的具體實現:

// shared/SimpleStack.js 實現了一個簡單的棧

class SimpleStack {
    constructor() {
        this.stack = [];
    }

    push(item) {
        this.stack.push(item);
    }

    pop() {
        return this.stack.pop();
    }

    size(){
        return this.stack.length;
    }

    empty(){
        return this.size()===0;
    }

    top(){
        return this.stack[this.size()-1];
    }

    findIndex(handle){
        let pos = this.size() - 1;
        for(;pos>=0;pos--){
            let cur = this.stack[pos];
            if(cur&&handle(cur)){
                return pos;
            }
        }
        return -1;
    }
    get(pos){
        return this.stack[pos];
    }

    popItemByStartIndex(index){
        return this.stack.splice(index).reverse();
    }

    clear(){
        this.stack = [];
    }

    print(){
        console.log(`%c當前棧的數據結構是:`,'color: green', JSON.stringify(this.stack));
    }
}

export default SimpleStack;

需要注意的是,在vue源碼中其實並沒有這樣一個對象,Vue源碼中是直接使用數組來模擬棧結構的操作的,不過原理都是一樣的,我這邊對這個棧進行了封裝,方便理解與使用。從上面的代碼可以看出,其實底層原理使用的也是一個數組,我們所有的操作其實也都是基於數組的操作,我們將它封裝起來,只是爲了不讓外界隨意的訪問而已。

瞭解了棧是怎樣的,那麼我們再來看看如何通過這個棧來幫助我們解析節點之間的父子關係。

我們舉個例子來說明一下:

加入說我們的html結構是這樣的:

<div>
    <p>這是一段文字</p>
    <ul>
        <li>選項1</li>
        <li>選項2</li>
    </ul>
</div>

當我們解析到<div>時,將div加入到棧中,此時棧中只有div一個元素,然後繼續解析,解析到<p>的時候,我們再把p也加入到棧中,此時棧中有div和p兩個元素,棧頂的元素是p。再往下解析,解析到一段文字,觸發chars收集並生成抽象語法樹文本節點,那麼,此時,這個文本節點的父級是什麼呢?顯而易見,就是我們棧頂的元素p。所以我們將這個文本節點加入到p的children中即可。繼續解析,發現</p>結束標籤,我們將棧頂元素p彈出來,也就是說,現在我們的棧裏又只有一個div了,同理,下面的ul和li也是這樣操作。當最後解析到</div>時,棧裏也就只有一個div元素了,將棧頂的div彈出,棧空了,已經解析完了。流程如下:

  • 發現<div>    ==[入棧]=>  [div]        棧頂元素爲div
  • 發現<p>       ==[入棧]=>  [div,p]     棧頂元素爲p
  • 發現</p>      ==[出棧]=>  [div]        棧頂元素爲div
  • 發現<ul>      ==[入棧]=>  [div,ul]     棧頂元素爲ul
  • 發現<li>       ==[入棧]=>  [div,ul,li]   棧頂元素爲li
  • 發現</li>      ==[出棧]=>  [div,ul]      棧頂元素爲ul
  • 發現<li>       ==[入棧]=>  [div,ul,li]   棧頂元素爲li
  • 發現</li>      ==[出棧]=>  [div,ul]      棧頂元素爲ul
  • 發現</ul>     ==[出棧]=>  [div]         棧頂元素爲div
  • 發現</div>   ==[出棧]=>  []              棧爲空

因此,使用棧來輔助,我們要找到當前節點的父標籤其實很容易,棧頂元素就是了。

經過模板編譯器的編譯,我們就能得到一個抽象語法樹的根節點

待編譯模板
圖爲帶編譯模板
生成的ast樹
圖爲生成的抽象語法樹

 

好了,模板編譯器的相關原理就講到這裏吧,之後還會陸續更新優化器、代碼生成器等相關原理的文章,歡迎共同學習討論。

最後,再附上項目github:kinerVue/dev-0.0.1 ,歡迎star!

發佈了24 篇原創文章 · 獲贊 12 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章