Vue.js 1.0 的 DOM 編譯過程解析

wxlworkhard發表於2016-12-07

注意:這裡對 Vue 的原始碼做了刪減,有需要請看原始碼。

從一個?開始,程式碼如下:

<div id="app"></div>
<script>
    var vm = new Vue({
        el: '#app',
        template: 
            '<div>' +
                '{{message}}' +
                '<input v-model="message">' +
                '<i v-on:click="getMessage" v-show="message">{{message + "s"}}</i>' +
            '</div>',
        data: function () {
            return {
                message: 'a message yo',
            }
        },
        methods: {
            getMessage: function () {
                console.info(this.message);
            }
        }
    });
</script>

this.$mount(options.el); 以一個 DOM 物件為引數開始編譯過程,然後呼叫 Vue.prototype._compile 具體程式碼如下:

Vue.prototype._compile = function (el) {
    var options = this.$options;
    // 快取 DOM Tree 中的 DOM 物件,最後一次性替換,為什麼?
    var original = el;
    /**
     * 把 template 轉換成 DOM 物件並賦值給 el
     * 後續的操作都是針對該轉置的 DOM 物件(沒有 append 到 DOM Tree 中)
     */
    el = transclude(el, options);

    // 編譯根節點返回 linkFn
    var rootLinker = compileRoot(el, options, contextOptions);
    // 執行根節點的 linkFn
    var rootUnlinkFn = rootLinker(this, el, this._scope);
    /**
     * 對根節點的操作可以先跳過,
     * 先講解對其內容節點的操作,這也是編譯過程的入口,
     * 這裡有兩個函式的連續呼叫,compile 和 compile 返回的 linkFn
     */
    var contentUnlinkFn = compile(el, options)(this, el);

    // finally replace original 輸出到 DOM Tree 中
    if (options.replace) {
        replace(original, el);
    }
    this._isCompiled = true;
    this._callHook('compiled');
};

_compile 描述了編譯的總的流程:快取 DOM Tree 中的 DOM 物件,把轉置 template 得到 DOM 物件賦值給 el,先對 el 進行 compile 和 link,然後對 el 的內容節點進行 compile 返回 linkFn,以 vm 物件為引數呼叫 linkFn,到這裡響應的資料繫結(雙向繫結)就已經完成了,最後把 el replace 到 DOM Tree 中。
這裡我們把總流程分解成 transclude、compile、link 三個階段來具體講解。

轉置 transclude

transclude 是轉置的意思,主要是用來把模板轉換成 DOM 物件並返回,後面 el 指的都是該 DOM 物件,具體程式碼如下:

function transclude(el, options) {
    // ......
    if (options) {
        if (options._asComponent && !options.template) {
            options.template = '<slot></slot>';
        }
        if (options.template) {
            options._content = extractContent(el);
            // 直接到這裡
            el = transcludeTemplate(el, options);
        }
    }
    return el;
}

function transcludeTemplate(el, options) {
    var template = options.template;
    var frag = parseTemplate(template, true);
    if (frag) {
        // 這裡使用的是 frag 的第一個子節點
        var replacer = frag.firstChild;
        // 預設情況下 replace 為 true
        if (options.replace) {
            if (
                frag.childNodes.length > 1 ||
                /**
                 * 如果有多個子節點,直接返回 frag,el 上的屬性就會丟失
                 * 注意使用不當會導致 fragment instance 錯誤
                 * 還有很多條件這裡先忽略
                 */
                ) {
                return frag;
            } else {
                options._replacerAttrs = extractAttrs(replacer);
                // merge el 的屬性到 replacer 上
                mergeAttrs(el, replacer);
                return replacer;
            }
        }
    }
}

function parseTemplate(template, shouldClone, raw) {
    var node, frag;
    if (typeof template === 'string') {
        frag = stringToFragment(template, raw);
    }
    return frag;
}

/**
 * 把 template 字串通過 innerHTML 方法轉換成 DOM 物件
 * 並 append 到 frag 物件
 */
function stringToFragment(templateString, raw) {
    var frag = document.createDocumentFragment();
    var node = document.createElement('div');

    node.innerHTML = prefix + templateString + suffix;
    var child;
    while (child = node.firstChild) {
        frag.appendChild(child);
    }
    return frag;
}

轉置的過程比較簡單,這部分完了之後得到了編譯的 DOM 物件(el),我們切回主流程。從 compile(el, options)(this, el); 開始編譯階段的主要邏輯。

編譯階段 compile

編譯階段的入口函式是 compile 程式碼如下:

function compile(el, options, partial) {
    var nodeLinkFn = compileNode(el, options) : null;
    var childLinkFn = compileNodeList(el.childNodes, options) : null;

    return function compositeLinkFn(vm, el, host, scope, frag) {
        var dirs = linkAndCapture(function compositeLinkCapturer() {
            if (nodeLinkFn) nodeLinkFn(vm, el, host, scope, frag);
            if (childLinkFn) childLinkFn(vm, childNodes, host, scope, frag);
        }, vm);
        return makeUnlinkFn(vm, dirs);
    };
}

呼叫 compileNode 和 compileNodeList 得到兩個 link 函式,返回 compositeLinkFn,在連結階段呼叫該 compositeLinkFn 時可以呼叫得到的兩個 link 函式,該函式描述了編譯階段的總流程:返回兩個連結函式供下一個階段(連結階段)呼叫。
compileNodeList 其實是一個迭代函式,迭代呼叫 compileNode,所以該階段(編譯階段)的關鍵是 compileNode 函式,compileNode 根據 nodeType 的值(1 和 3)分別呼叫 compileElement 和 compileTextNode,其他情況返回 null,所以處理的只有兩種型別的 node。
因為這兩個方法的意圖(主要來獲取指令的 descriptor)一樣,這裡就只分析 compileElement 方法了,程式碼如下:

function compileElement(el, options) {
    var linkFn;
    var hasAttrs = el.hasAttributes();
    var attrs = hasAttrs && toArray(el.attributes);
    // ....
    linkFn = compileDirectives(attrs, options);
    return linkFn;
}    

function compileDirectives(attrs, options) {
    var i = attrs.length;
    // 注意這個陣列
    var dirs = [];
    var attr, name, value, rawName, rawValue, 
         dirName, arg, modifiers, dirDef, tokens, matched;

   // 遍歷 DOM 屬性,匹配 Vue 的內建指令
   while (i--) {
        attr = attrs[i];
        name = rawName = attr.name;
        value = rawValue = attr.value;
        tokens = parseText(value);
        //......
        // 這裡用 v-on 舉例
        if (onRE.test(name)) {
            arg = name.replace(onRE, '');
            // 匹配到 Vue 的內建指令後執行 pushDir
            pushDir('on', directives.on);
        } else
        //......
    }

    function pushDir(dirName, def, interpTokens) {
        // def 是內建指令的引用
        // dirs 中 push 一個能引用到內建指令的物件(指令 descriptor)
        dirs.push({
            name: dirName,
            attr: rawName,
            raw: rawValue,
            def: def,
            // ....
        });
    }
    if (dirs.length) {
        return makeNodeLinkFn(dirs);
    }
}

function makeNodeLinkFn(directives) {
    return function nodeLinkFn(vm, el, host, scope, frag) {
        var i = directives.length;
        while (i--) {
            vm._bindDir(directives[i], el, host, scope, frag);
        }
    };
}

刪減了程式碼,這樣跟下來就知道了 compile 階段其實就是根據 DOM 物件的屬性匹配 Vue 的內建指令(指令單例),匹配到就建立一個能引用到指令單例的物件(指令 descriptor)並 push 到 dirs 陣列中,返回 nodeLinkFn(compileTextNode 會建立 token 的陣列返回 textNodeLinkFn)即返回的這些 link 函式可以訪問到指令 descriptor。編譯是一個迭代的過程,最終返回的 linkFn 是一個樹形結構,如圖: enter image description here

連結

compile(el, options)(this, el) 編譯階段完成後接著呼叫返回的 linkFn,把 Vue 物件例項傳遞進去進行連結階段。 首先呼叫 compositeLinkFn,通過 linkAndCapture 呼叫 nodeLinkFn 和 childLinkFn 獲取指令 descriptor,然後通過 Vue.prototype. _bindDir 往 vm 的 _directives 陣列中 push 通過 new Directive(descriptor, this, .....) 建立的指令物件。在 linkAndCapture 函式中接著獲取到 vm._directives 中的指令物件,按優先順序 sort 後執行指令物件的 _bind 方法,程式碼如下:

Directive.prototype._bind = function() {
    var descriptor = this.descriptor;
    // 指令單例
    var def = descriptor.def;
    if (typeof def === 'function') {
        this.update = def;
    } else {
        // extend 指令單例物件的屬性
        extend(this, def);
    }

    if (this.bind) {
        // 執行指令單例的 bind 方法
        this.bind();
    }
    this._bound = true;

    var dir = this;
    if (this.update) {
        this._update = function(val, oldVal) {
            if (!dir._locked) {
                dir.update(val, oldVal);
            }
        };
    } else {
        this._update = noop$1;
    }

    var watcher = this._watcher = new Watcher(this.vm, this.expression, 
        this._update,
        {
            filters: this.filters,
            twoWay: this.twoWay,
            deep: this.deep,
            preProcess: preProcess,
            postProcess: postProcess,
            scope: this._scope
        });

    if (this.afterBind) {
        this.afterBind();
    } else if (this.update) {
        this.update(watcher.value);
    }
};

在 _bind 方法中完善了指令物件的屬性(bind、update),執行指令單例物件的 bind(繫結 DOM 事件),建立 watcher 物件,執行 this.update(watcher.value) watcher.value 對映 vm 物件的屬性值,如{{message}}可以對映到 message = 'a message yo',執行指令單例的 update 更新 DOM。
這裡如果直接把資料通過指令單例輸出的 DOM 就不會有雙向繫結的效果了,這裡有個 watcher 承擔了 vm 物件和指令物件的通訊工作,在 vm 變化時通知指令物件,指令物件的 DOM 事件偵聽器通過 watcher 通知 vm。Watcher 的程式碼如下:

function Watcher(vm, expOrFn, cb, options) {
    this.vm = vm;
    vm._watchers.push(this);
    // watch 的訪問器屬性名
    this.expression = expOrFn;
    // 訪問器屬性變化通知到 watcher 時會呼叫
    this.cb = cb;
    /**
     * Parse an expression into re-written getter/setters.
     * 對映訪問器屬性的 get 和 set 方法
     */
    var res = parseExpression(expOrFn, this.twoWay);
    this.getter = res.get;
    this.setter = res.set;

    /**
     * 該方法呼叫完後 watcher 物件就 push 到了 dep.subs 中 
     */
    this.value = this.get();
    this.queued = this.shallow = false;
}

/**
 * Build a getter function. Requires eval.
 * 如 body = 'scope.message'
 * getter.call(vm, vm) 返回 vm.message,
 * 因為 message 是訪問器屬性,會呼叫 get 方法
 */
function makeGetterFn(body) {
    return new Function('scope', 'return ' + body + ';');
}

來看下 Watcher.prototype.get 是如何把 watcher 物件新增到訊息佇列(dep 的 subs)中的,程式碼如下:

Watcher.prototype.get = function() {
    // beforeGet 方法的程式碼
    // 把 Dep.target 執行 watcher 物件
    Dep.target = this;
    var scope = this.scope || this.vm;
    var value;
    // 對映到 vm 的訪問器屬性並調 get 方法
    value = this.getter.call(scope, scope);

    this.afterGet();
    // Dep.target = null;
    return value;
};

這段程式碼做的就是把 watcher 賦值給 Dep.target 並掉起 vm 相應的訪問器屬性的 get 方法。後續怎麼 push 到 dep.subs 中就很簡單了可以看下 function defineReactive(obj, key, val) 方法的程式碼,這裡略過。該方法還獲取到了 vm 物件的屬性值並返回,將作為指令物件的 update 方法的引數去更新 DOM。
到這裡只是完成了 vm 物件到 DOM 的過程是單向的繫結過程(單向繫結),那如何通過 DOM 如何改變 vm 物件?其實就在單向繫結的基礎上給可輸入元素(input、textare等)新增了change(input)事件,來動態修改 vm,具體如何繫結事件偵聽器可以看下 v-model 實現這裡略過。
通過指令的事件偵聽器呼叫 watcher 的 setter 對映 vm 的訪問器屬性的 set 方法,執行 dep.notify() 遍歷呼叫 dep.subs 中的 watcher 物件的 update 方法把 watcher push 到一個佇列,在 nextTick 時呼叫 Watcher.prototype.run 方法,該方法中會執行 this.cb.call(this.vm, value, oldValue);this.cb就是

this._update = function (val, oldVal) {
    if (!dir._locked) {
        dir.update(val, oldVal);
    }
};

會呼叫指令的 update 方法去更新 DOM(nextTick 的本質就是 setTimeout)。

編譯階段的意圖比較簡單,就是遍歷 DOM 屬性匹配指令單例並建立指令 descriptor,然後返回可以訪問到指令 descriptor 的 linkFn,注意 linkFn 的結構比較複雜,是一個樹形的結構。
與編譯階段相比,連結階段的意圖則複雜的多的多,先是遍歷執行編譯階段得到的 linkFn,通過指令 descriptor 建立指令物件(全部 push 到 vm._directives 陣列中),然後取到 _directives 陣列把指令物件按優先順序排序後執行每個指令物件的 _bind 方法,該 _bind 方法中做了主要做了三件事:

  • extend 指令單例,執行 extend 得到的 bind 方法,進行 DOM 事件繫結;
  • 建立 watcher,watcher 物件包含 getter 和 setter 方法對映對應的訪問器屬性的 get 和 set,同時 watcher 也被 push 到 dep 的訊息佇列中;
  • 把資料(vm 的屬性)通過指令物件的 update 方法(extend 指令單例得來)繫結到 DOM。

編譯階段的主要流程見下圖: enter image description here 到這裡就完整的實現了雙向繫結。

相關文章