雖然工作中一直使用Vue作為基礎庫,但是對於其實現機理僅限於道聽途說,這樣對長期的技術發展很不利。所以最近攻讀了其原始碼的一部分,先把雙向資料繫結這一塊的內容給整理一下,也算是一種學習的反芻。
本篇文章的Vue原始碼版本為v2.2.0開發版
。
Vue原始碼的整體架構無非是初始化Vue物件,掛載資料data/props
等,在不同的時期觸發不同的事件鉤子,如created() / mounted() / update()
等,後面專門整理各個模組的文章。這裡先講雙向資料繫結的部分,也是最主要的部分。
設計思想:觀察者模式
Vue的雙向資料繫結的設計思想為觀察者模式
,為了方便,下文中將被觀察的物件稱為觀察者,將觀察者物件觸發更新的稱為訂閱者。主要涉及到的概念有:
-
Dep物件:Dependency依賴的簡寫,包含有三個主要屬性
id, subs, target
和四個主要函式addSub, removeSub, depend, notify
,是觀察者的依賴集合,負責在資料發生改變時,使用notify()
觸發儲存在subs
下的訂閱列表,依次更新資料和DOM。id: 每個觀察者(依賴物件)的唯一標識。 subs: 觀察者物件的訂閱者列表。 target: 全域性唯一的訂閱者物件,因為只能同時計算和更新一個訂閱者的值。 addSub(): 使用`push()`方法新增一個訂閱者。 removeSub(): 使用`splice()`方法移除一個訂閱者。 depend(): 將自己新增到當前訂閱者物件的依賴列表。 notify(): 在資料被更新時,會遍歷subs物件,觸發每一個訂閱者的更新。
-
Observer物件:即觀察者,包含兩個主要屬性
value, dep
。做法是使用getter/setter方法覆蓋預設的取值和賦值操作,將物件封裝為響應式物件,每一次呼叫時更新依賴列表,更新值時觸發訂閱者。繫結在物件的__ob__
原型鏈屬性上。value: 原始值。 dep: 依賴列表。
原始碼實戰解析
有過Vue開發基礎的應該都瞭解其怎麼初始化一個Vue物件:
new Vue({
el: `#container`,
data: {
count: 100
},
...
});
那麼我們就從這個count說起,看它是怎麼完成雙向資料繫結的。
下面的程式碼片段中英文註釋為尤雨溪所寫,中文註釋為我所寫,英文註釋更能代表開發者的清晰思路。
首先從全域性的初始化函式呼叫:initMixin(Vue$3);
,這裡的Vue$3
物件就是全域性的Vue物件,在此之前已經掛載了Vue的各種基本資料和函式。這個函式體就是初始化我們上面宣告Vue語句的過程化邏輯,取主體程式碼來看:
// 這裡的options就是上面宣告Vue物件的json物件
Vue.prototype._init = function (options) {
...
var vm = this;
...
initLifecycle(vm);
initEvents(vm);
initRender(vm);
callHook(vm, `beforeCreate`);
// 這裡就是我們接下來要跟進的初始化Vue引數
initState(vm);
initInjections(vm);
callHook(vm, `created`);
...
};
這裡主要完成了初始化事件、渲染、引數、注入等過程,並不斷呼叫事件鉤子的回撥函式。下面來到如何初始化引數:
function initState (vm) {
vm._watchers = [];
var opts = vm.$options;
if (opts.props) { initProps(vm, opts.props); }
if (opts.methods) { initMethods(vm, opts.methods); }
// 我們的count在這裡初始化
if (opts.data) {
initData(vm);
} else {
observe(vm._data = {}, true /* asRootData */);
}
if (opts.computed) { initComputed(vm, opts.computed); }
if (opts.watch) { initWatch(vm, opts.watch); }
}
這裡依次檢測引數中包含的props/methods/data/computed/watch
並進入不同的函式進行初始化,這裡我們只關心initData:
function initData (vm) {
var data = vm.$options.data;
data = vm._data = typeof data === `function`
? data.call(vm)
: data || {};
if (!isPlainObject(data)) {
data = {};
}
...
// observe data
observe(data, true /* asRootData */);
可以看到Vue的data
引數支援物件和回撥函式,但最終返回的一定是物件,否則使用空物件。接下來就是重頭戲了,我們如何將data引數設定為響應式的:
/**
* Attempt to create an observer instance for a value,
* returns the new observer if successfully observed,
* or the existing observer if the value already has one.
*/
function observe (value, asRootData) {
if (!isObject(value)) {
return
}
var ob;
if (hasOwn(value, `__ob__`) && value.__ob__ instanceof Observer) {
ob = value.__ob__;
} else if (
/* 為了防止value不是單純的物件而是Regexp或者函式之類的,或者是vm例項再或者是不可擴充套件的 */
observerState.shouldConvert &&
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
ob = new Observer(value);
}
if (asRootData && ob) {
ob.vmCount++;
}
return ob
}
這裡的英文註釋非常清晰,就是為了給該物件新建一個觀察者類,如果存在則返回已存在的(比如互相引用或依賴重複),可以看到這個觀察者列表放置在物件的__ob__
屬性下。下面我們看下這個Observer觀察者類:
/**
* Observer class that are attached to each observed
* object. Once attached, the observer converts target
* object`s property keys into getter/setters that
* collect dependencies and dispatches updates.
*/
var Observer = function Observer (value) {
this.value = value;
this.dep = new Dep();
this.vmCount = 0;
// def函式是defineProperty的簡單封裝
def(value, `__ob__`, this);
if (Array.isArray(value)) {
// 在es5及更低版本的js裡,無法完美繼承陣列,這裡檢測並選取合適的函式
// protoAugment函式使用原型鏈繼承,copyAugment函式使用原型鏈定義(即對每個陣列defineProperty)
var augment = hasProto
? protoAugment
: copyAugment;
augment(value, arrayMethods, arrayKeys);
this.observeArray(value);
} else {
this.walk(value);
}
};
在Observer類的註釋裡也清楚的說明,它會被關聯到每一個被檢測的物件,使用getter/setter
修改其預設讀寫,用於收集依賴和釋出更新。其中出現了三個我們需要關心的東西Dep類/observeArray/walk
,我們先看observeArray的原始碼:
/**
* Observe a list of Array items.
*/
Observer.prototype.observeArray = function observeArray (items) {
for (var i = 0, l = items.length; i < l; i++) {
observe(items[i]);
}
};
它不過是在Observer類和observe方法中間的一層遞迴,因為我們觀察的只能是物件,而不能是數字、字串或者陣列(陣列的觀察比較特殊,事實上是重構了方法來觸發更新,後面會講到)。那我們接下來看下Dep類是做什麼用的:
/**
* A dep is an observable that can have multiple
* directives subscribing to it.
*/
var Dep = function Dep () {
this.id = uid$1++;
this.subs = [];
};
註釋裡告訴我們Dep類是一個會被多個指令訂閱的可被觀察的物件,這裡的指令就是我們在html程式碼裡書寫的東西,如:class={active: hasActive}
或{{ count }} {{ count * price }}
,而他們就會訂閱hasActive/count/price
這些物件,而這些訂閱他們的物件就會被放置在Dep.subs
列表中。每一次新建Dep物件,就會全域性uid遞增,然後傳給該Dep物件,保證唯一性id。
我們接著看剛才的walk函式做了什麼:
/**
* Walk through each property and convert them into
* getter/setters. This method should only be called when
* value type is Object.
*/
Observer.prototype.walk = function walk (obj) {
var keys = Object.keys(obj);
for (var i = 0; i < keys.length; i++) {
defineReactive$$1(obj, keys[i], obj[keys[i]]);
}
};
看來和名字一樣,它只是走了一遍,那我們來看下defineReactive$$1
做了什麼:
/**
* Define a reactive property on an Object.
*/
function defineReactive$$1 (obj, key, val, customSetter) {
var dep = new Dep();
var property = Object.getOwnPropertyDescriptor(obj, key);
if (property && property.configurable === false) {
return
}
// cater for pre-defined getter/setters
var getter = property && property.get;
var setter = property && property.set;
var childOb = observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
var value = getter ? getter.call(obj) : val;
if (Dep.target) {
dep.depend();
if (childOb) {
childOb.dep.depend();
}
if (Array.isArray(value)) {
dependArray(value);
}
}
return value
},
set: function reactiveSetter (newVal) {
var value = getter ? getter.call(obj) : val;
// 髒檢查,排除了NaN !== NaN的影響
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
childOb = observe(newVal);
dep.notify();
}
});
}
終於找到重頭戲了,這裡真正使用了getter/setter
代理了物件的預設讀寫。我們首先新建一個Dep物件,利用閉包準備收集依賴,然後我們使用observe觀察該物件,注意此時與上面相比少了一個asRootData = true
的引數。
我們先來看取值的代理get,這裡用到了Dep.target屬性和depend()方法
,我們來看看它是做什麼的:
// the current target watcher being evaluated.
// this is globally unique because there could be only one
// watcher being evaluated at any time.
Dep.target = null;
Dep.prototype.depend = function depend () {
if (Dep.target) {
Dep.target.addDep(this);
}
};
Dep.prototype.notify = function notify () {
// stablize the subscriber list first
var subs = this.subs.slice();
for (var i = 0, l = subs.length; i < l; i++) {
subs[i].update();
}
};
註釋看的出來Dep.target
是全域性唯一的watcher
物件,也就是當前正在指令計算的訂閱者,它會在計算時賦值成一個watcher物件,計算完成後賦值為null。而depend
是用於對該訂閱者新增依賴,告訴它你的值依賴於我,每次更新時應該來找我。另外還有notify()
的函式,用於遍歷所有的依賴,通知他們更新資料。這裡多看一下addDep()
的原始碼:
/**
* Add a dependency to this directive.
*/
Watcher.prototype.addDep = function addDep (dep) {
var id = dep.id;
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id);
this.newDeps.push(dep);
if (!this.depIds.has(id)) {
// 使用push()方法新增一個訂閱者
dep.addSub(this);
}
}
};
可以看到它有去重的機制,當重複依賴時保證相同ID的依賴只有一個。訂閱者包含3個屬性newDepIds/newDeps/depIds
分別儲存依賴資訊,如果之前就有了這個依賴,那麼反過來將該訂閱者加入到這個依賴關係中去。
接著看get方法中的dependArray()
:
/**
* Collect dependencies on array elements when the array is touched, since
* we cannot intercept array element access like property getters.
*/
function dependArray (value) {
for (var e = (void 0), i = 0, l = value.length; i < l; i++) {
e = value[i];
e && e.__ob__ && e.__ob__.dep.depend();
if (Array.isArray(e)) {
dependArray(e);
}
}
}
可以看到我們不能像物件一樣監聽陣列的變化,所以如果獲取一個陣列的值,那麼就需要將陣列中所有的物件的觀察者列表都加入到依賴中去。
這樣get方法讀取值就代理完成了,接下來我們看set方法代理賦值的實現,我們先獲取原始值,然後與新賦的值進行比較,也叫髒檢查,如果資料發生了改變,則對該資料進行重新建立觀察者,並通知所有的訂閱者更新。
接下來我們看下陣列的更新檢測是如何實現的:
/*
* not type checking this file because flow doesn`t play well with
* dynamically accessing methods on Array prototype
*/
var arrayProto = Array.prototype;
var arrayMethods = Object.create(arrayProto);
[`push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse`].forEach(function (method) {
// cache original method
var original = arrayProto[method];
def(arrayMethods, method, function mutator () {
var arguments$1 = arguments;
// avoid leaking arguments:
// http://jsperf.com/closure-with-arguments
var i = arguments.length;
var args = new Array(i);
while (i--) {
args[i] = arguments$1[i];
}
var result = original.apply(this, args);
var ob = this.__ob__;
var inserted;
switch (method) {
case `push`:
inserted = args;
break
case `unshift`:
inserted = args;
break
case `splice`:
inserted = args.slice(2);
break
}
if (inserted) { ob.observeArray(inserted); }
// notify change
ob.dep.notify();
return result
});
});
看的出來我們模擬了一個陣列物件,代理了push/pop/shift/unshift/splice/sort/reverse
方法,用於檢測陣列的變化,並通知所有訂閱者更新。如果有新建元素,會補充監聽新物件。
這就是從程式碼上解釋為什麼Vue不支援陣列下標修改和長度修改的原因,至於為什麼這麼設計,我後面會再次更新或再開篇文章,講一些通用的設計問題以及Js機制和缺陷。
總結
從上面的程式碼中我們可以一步步由深到淺的看到Vue是如何設計出雙向資料繫結的,最主要的兩點:
- 使用
getter/setter
代理值的讀取和賦值,使得我們可以控制資料的流向。 - 使用
觀察者模式
設計,實現了指令和資料的依賴關係以及觸發更新。 - 對於陣列,
代理
會修改原陣列物件的方法,並觸發更新。
明白了這些原理,其實你也可以實現一個簡單的資料繫結,造一個小輪子,當然,Vue的強大之處不止於此,我們後面再來聊一聊它的元件和渲染,看它是怎麼一步一步將我們從DOM物件的魔爪裡拯救出來的。
參考資料
- 資料的響應化:https://github.com/Ma63d/vue-…
- Vue v2.2.0 原始碼檔案
- es6 Proxy: http://es6.ruanyifeng.com/#do…