Vue.js 1.0 的 DOM 編譯過程解析
注意:這裡對 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 是一個樹形結構,如圖:
連結
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。
編譯階段的主要流程見下圖: 到這裡就完整的實現了雙向繫結。
相關文章
- Vue.js從Virtual DOM對映到真實DOM的過程Vue.js
- 編譯器的編譯基本過程編譯
- 編譯過程編譯
- Javac編譯過程Java編譯
- 編譯核心過程編譯
- 編譯器的工作過程編譯
- EVC編譯TCPMP的過程編譯TCP
- 編譯連結過程編譯
- 編譯過程簡介編譯
- C++ 編譯過程C++編譯
- JavaScript的預編譯過程分析JavaScript編譯
- 編譯C++ 程式的過程編譯C++
- ios底層 編譯過程iOS編譯
- .NET 程式碼編譯過程編譯
- glade 編譯過程 (轉)編譯
- vlc-android 的編譯過程Android編譯
- 編譯器的工作過程和原理編譯
- GCC編譯過程(預處理->編譯->彙編->連結)GC編譯
- 聊聊Vue.js的template編譯Vue.js編譯
- GCC編譯和連結過程GC編譯
- go語言編譯過程概述Go編譯
- 預編譯過程(AO+GO)編譯Go
- C程式編譯過程淺析C程式編譯
- Android 專案編譯過程Android編譯
- Android Makefile 編譯過程分析Android編譯
- Hive SQL 編譯過程詳解HiveSQL編譯
- C語言編譯全過程C語言編譯
- 儲存過程編譯時卡死儲存過程編譯
- iOS編譯過程的原理和應用iOS編譯
- 初探 Go 的編譯命令執行過程Go編譯
- ASPNet_Compiler的編譯過程Compile編譯
- Vue.js原始碼角度:剖析模版和資料渲染成最終的DOM的過程Vue.js原始碼
- CMM編譯器和C編譯器過程呼叫實現的比較編譯
- 揭秘Vue從Virtual DOM生成真實DOM的過程Vue
- Hive SQL的底層編譯過程詳解HiveSQL編譯
- 淺談Android的資源編譯過程Android編譯
- [轉]:xmake編譯配置過程詳解編譯
- 詳解Linux 程式編譯過程Linux編譯