為了深入介紹響應式系統的內部實現原理,我們花了一整節的篇幅介紹了資料(包括
data, computed,props
)如何初始化成為響應式物件的過程。有了響應式資料物件的知識,上一節的後半部分我們還在保留原始碼結構的基礎上構建了一個以data
為資料的響應式系統,而這一節,我們繼續深入響應式系統內部構建的細節,詳細分析Vue
在響應式系統中對data,computed
的處理。
7.8 相關概念
在構建簡易式響應式系統的時候,我們引出了幾個重要的概念,他們都是響應式原理設計的核心,我們先簡單回顧一下:
Observer
類,例項化一個Observer
類會通過Object.defineProperty
對資料的getter,setter
方法進行改寫,在getter
階段進行依賴的收集,在資料發生更新階段,觸發setter
方法進行依賴的更新watcher
類,例項化watcher
類相當於建立一個依賴,簡單的理解是資料在哪裡被使用就需要產生了一個依賴。當資料發生改變時,會通知到每個依賴進行更新,前面提到的渲染wathcer
便是渲染dom
時使用資料產生的依賴。Dep
類,既然watcher
理解為每個資料需要監聽的依賴,那麼對這些依賴的收集和通知則需要另一個類來管理,這個類便是Dep
,Dep
需要做的只有兩件事,收集依賴和派發更新依賴。
這是響應式系統構建的三個基本核心概念,也是這一節的基礎,如果還沒有印象,請先回顧上一節對極簡風響應式系統的構建。
7.9 data
7.9.1 問題思考
在開始分析data
之前,我們先丟擲幾個問題讓讀者思考,而答案都包含在接下來內容分析中。
-
前面已經知道,
Dep
是作為管理依賴的容器,那麼這個容器在什麼時候產生?也就是例項化Dep
發生在什麼時候? -
Dep
收集了什麼型別的依賴?即watcher
作為依賴的分類有哪些,分別是什麼場景,以及區別在哪裡? -
Observer
這個類具體對getter,setter
方法做了哪些事情? -
手寫的
watcher
和頁面資料渲染監聽的watch
如果同時監聽到資料的變化,優先順序怎麼排? -
有了依賴的收集是不是還有依賴的解除,依賴解除的意義在哪裡?
帶著這幾個問題,我們開始對data
的響應式細節展開分析。
7.9.2 依賴收集
data
在初始化階段會例項化一個Observer
類,這個類的定義如下(忽略陣列型別的data
):
// initData
function initData(data) {
···
observe(data, true)
}
// observe
function observe(value, asRootData) {
···
ob = new Observer(value);
return ob
}
// 觀察者類,物件只要設定成擁有觀察屬性,則物件下的所有屬性都會重寫getter和setter方法,而getter,setting方法會進行依賴的收集和派發更新
var Observer = function Observer (value) {
···
// 將__ob__屬性設定成不可列舉屬性。外部無法通過遍歷獲取。
def(value, '__ob__', this);
// 陣列處理
if (Array.isArray(value)) {
···
} else {
// 物件處理
this.walk(value);
}
};
function def (obj, key, val, enumerable) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable, // 是否可列舉
writable: true,
configurable: true
});
}
複製程式碼
Observer
會為data
新增一個__ob__
屬性, __ob__
屬性是作為響應式物件的標誌,同時def
方法確保了該屬性是不可列舉屬性,即外界無法通過遍歷獲取該屬性值。除了標誌響應式物件外,Observer
類還呼叫了原型上的walk
方法,遍歷物件上每個屬性進行getter,setter
的改寫。
Observer.prototype.walk = function walk (obj) {
// 獲取物件所有屬性,遍歷呼叫defineReactive###1進行改寫
var keys = Object.keys(obj);
for (var i = 0; i < keys.length; i++) {
defineReactive###1(obj, keys[i]);
}
};
複製程式碼
defineReactive###1
是響應式構建的核心,它會先例項化一個Dep
類,即為每個資料都建立一個依賴的管理,之後利用Object.defineProperty
重寫getter,setter
方法。這裡我們只分析依賴收集的程式碼。
function defineReactive###1 (obj,key,val,customSetter,shallow) {
// 每個資料例項化一個Dep類,建立一個依賴的管理
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;
// 這一部分的邏輯是針對深層次的物件,如果物件的屬性是一個物件,則會遞迴呼叫例項化Observe類,讓其屬性值也轉換為響應式物件
var childOb = !shallow && observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,s
get: function reactiveGetter () {
var value = getter ? getter.call(obj) : val;
if (Dep.target) {
// 為當前watcher新增dep資料
dep.depend();
if (childOb) {
childOb.dep.depend();
if (Array.isArray(value)) {
dependArray(value);
}
}
}
return value
},
set: function reactiveSetter (newVal) {}
});
}
複製程式碼
主要看getter
的邏輯,我們知道當data
中屬性值被訪問時,會被getter
函式攔截,根據我們舊有的知識體系可以知道,例項掛載前會建立一個渲染watcher
。
new Watcher(vm, updateComponent, noop, {
before: function before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate');
}
}
}, true /* isRenderWatcher */);
複製程式碼
與此同時,updateComponent
的邏輯會執行例項的掛載,在這個過程中,模板會被優先解析為render
函式,而render
函式轉換成Vnode
時,會訪問到定義的data
資料,這個時候會觸發gettter
進行依賴收集。而此時資料收集的依賴就是這個渲染watcher
本身。
程式碼中依賴收集階段會做下面幾件事:
- 為當前的
watcher
(該場景下是渲染watcher
)新增擁有的資料。 - 為當前的資料收集需要監聽的依賴
如何理解這兩點?我們先看程式碼中的實現。getter
階段會執行dep.depend()
,這是Dep
這個類定義在原型上的方法。
dep.depend();
Dep.prototype.depend = function depend () {
if (Dep.target) {
Dep.target.addDep(this);
}
};
複製程式碼
Dep.target
為當前執行的watcher
,在渲染階段,Dep.target
為元件掛載時例項化的渲染watcher
,因此depend
方法又會呼叫當前watcher
的addDep
方法為watcher
新增依賴的資料。
Watcher.prototype.addDep = function addDep (dep) {
var id = dep.id;
if (!this.newDepIds.has(id)) {
// newDepIds和newDeps記錄watcher擁有的資料
this.newDepIds.add(id);
this.newDeps.push(dep);
// 避免重複新增同一個data收集器
if (!this.depIds.has(id)) {
dep.addSub(this);
}
}
};
複製程式碼
其中newDepIds
是具有唯一成員是Set
資料結構,newDeps
是陣列,他們用來記錄當前watcher
所擁有的資料,這一過程會進行邏輯判斷,避免同一資料新增多次。
addSub
為每個資料依賴收集器新增需要被監聽的watcher
。
Dep.prototype.addSub = function addSub (sub) {
//將當前watcher新增到資料依賴收集器中
this.subs.push(sub);
};
複製程式碼
getter
如果遇到屬性值為物件時,會為該物件的每個值收集依賴
這句話也很好理解,如果我們將一個值為基本型別的響應式資料改變成一個物件,此時新增物件裡的屬性,也需要設定成響應式資料。
- 遇到屬性值為陣列時,進行特殊處理,這點放到後面講。
通俗的總結一下依賴收集的過程,每個資料就是一個依賴管理器,而每個使用資料的地方就是一個依賴。當訪問到資料時,會將當前訪問的場景作為一個依賴收集到依賴管理器中,同時也會為這個場景的依賴收集擁有的資料。
7.9.3 派發更新
在分析依賴收集的過程中,可能會有不少困惑,為什麼要維護這麼多的關係?在資料更新時,這些關係會起到什麼作用?帶著疑惑,我們來看看派發更新的過程。
在資料發生改變時,會執行定義好的setter
方法,我們先看原始碼。
Object.defineProperty(obj,key, {
···
set: function reactiveSetter (newVal) {
var value = getter ? getter.call(obj) : val;
// 新值和舊值相等時,跳出操作
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
···
// 新值為物件時,會為新物件進行依賴收集過程
childOb = !shallow && observe(newVal);
dep.notify();
}
})
複製程式碼
派發更新階段會做以下幾件事:
- 判斷資料更改前後是否一致,如果資料相等則不進行任何派發更新操作。
- 新值為物件時,會對該值的屬性進行依賴收集過程。
- 通知該資料收集的
watcher
依賴,遍歷每個watcher
進行資料更新,這個階段是呼叫該資料依賴收集器的dep.notify
方法進行更新的派發。
Dep.prototype.notify = function notify () {
var subs = this.subs.slice();
if (!config.async) {
// 根據依賴的id進行排序
subs.sort(function (a, b) { return a.id - b.id; });
}
for (var i = 0, l = subs.length; i < l; i++) {
// 遍歷每個依賴,進行更新資料操作。
subs[i].update();
}
};
複製程式碼
- 更新時會將每個
watcher
推到佇列中,等待下一個tick
到來時取出每個watcher
進行run
操作
Watcher.prototype.update = function update () {
···
queueWatcher(this);
};
複製程式碼
queueWatcher
方法的呼叫,會將資料所收集的依賴依次推到queue
陣列中,陣列會在下一個事件迴圈'tick'
中根據緩衝結果進行檢視更新。而在執行檢視更新過程中,難免會因為資料的改變而在渲染模板上新增新的依賴,這樣又會執行queueWatcher
的過程。所以需要有一個標誌位來記錄是否處於非同步更新過程的佇列中。這個標誌位為flushing
,當處於非同步更新過程時,新增的watcher
會插入到queue
中。
function queueWatcher (watcher) {
var id = watcher.id;
// 保證同一個watcher只執行一次
if (has[id] == null) {
has[id] = true;
if (!flushing) {
queue.push(watcher);
} else {
var i = queue.length - 1;
while (i > index && queue[i].id > watcher.id) {
i--;
}
queue.splice(i + 1, 0, watcher);
}
···
nextTick(flushSchedulerQueue);
}
}
複製程式碼
nextTick
的原理和實現先不講,概括來說,nextTick
會緩衝多個資料處理過程,等到下一個事件迴圈tick
中再去執行DOM
操作,它的原理,本質是利用事件迴圈的微任務佇列實現非同步更新。
當下一個tick
到來時,會執行flushSchedulerQueue
方法,它會拿到收集的queue
陣列(這是一個watcher
的集合),並對陣列依賴進行排序。為什麼進行排序呢?原始碼中解釋了三點:
- 元件建立是先父後子,所以元件的更新也是先父後子,因此需要保證父的渲染
watcher
優先於子的渲染watcher
更新。- 使用者自定義的
watcher
,稱為user watcher
。user watcher
和render watcher
執行也有先後,由於user watchers
比render watcher
要先建立,所以user watcher
要優先執行。- 如果一個元件在父元件的
watcher
執行階段被銷燬,那麼它對應的watcher
執行都可以被跳過。
function flushSchedulerQueue () {
currentFlushTimestamp = getNow();
flushing = true;
var watcher, id;
// 對queue的watcher進行排序
queue.sort(function (a, b) { return a.id - b.id; });
// 迴圈執行queue.length,為了確保由於渲染時新增新的依賴導致queue的長度不斷改變。
for (index = 0; index < queue.length; index++) {
watcher = queue[index];
// 如果watcher定義了before的配置,則優先執行before方法
if (watcher.before) {
watcher.before();
}
id = watcher.id;
has[id] = null;
watcher.run();
// in dev build, check and stop circular updates.
if (has[id] != null) {
circular[id] = (circular[id] || 0) + 1;
if (circular[id] > MAX_UPDATE_COUNT) {
warn(
'You may have an infinite update loop ' + (
watcher.user
? ("in watcher with expression \"" + (watcher.expression) + "\"")
: "in a component render function."
),
watcher.vm
);
break
}
}
}
// keep copies of post queues before resetting state
var activatedQueue = activatedChildren.slice();
var updatedQueue = queue.slice();
// 重置恢復狀態,清空佇列
resetSchedulerState();
// 檢視改變後,呼叫其他鉤子
callActivatedHooks(activatedQueue);
callUpdatedHooks(updatedQueue);
// devtool hook
/* istanbul ignore if */
if (devtools && config.devtools) {
devtools.emit('flush');
}
}
複製程式碼
flushSchedulerQueue
階段,重要的過程可以總結為四點:
- 對
queue
中的watcher
進行排序,原因上面已經總結。- 遍歷
watcher
,如果當前watcher
有before
配置,則執行before
方法,對應前面的渲染watcher
:在渲染watcher
例項化時,我們傳遞了before
函式,即在下個tick
更新檢視前,會呼叫beforeUpdate
生命週期鉤子。- 執行
watcher.run
進行修改的操作。- 重置恢復狀態,這個階段會將一些流程控制的狀態變數恢復為初始值,並清空記錄
watcher
的佇列。
new Watcher(vm, updateComponent, noop, {
before: function before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate');
}
}
}, true /* isRenderWatcher */);
複製程式碼
重點看看watcher.run()
的操作。
Watcher.prototype.run = function run () {
if (this.active) {
var value = this.get();
if ( value !== this.value || isObject(value) || this.deep ) {
// 設定新值
var oldValue = this.value;
this.value = value;
// 針對user watcher,暫時不分析
if (this.user) {
try {
this.cb.call(this.vm, value, oldValue);
} catch (e) {
handleError(e, this.vm, ("callback for watcher \"" + (this.expression) + "\""));
}
} else {
this.cb.call(this.vm, value, oldValue);
}
}
}
};
複製程式碼
首先會執行watcher.prototype.get
的方法,得到資料變化後的當前值,之後會對新值做判斷,如果判斷滿足條件,則執行cb
,cb
為例項化watcher
時傳入的回撥。
在分析get
方法前,回頭看看watcher
建構函式的幾個屬性定義
var watcher = function Watcher(
vm, // 元件例項
expOrFn, // 執行函式
cb, // 回撥
options, // 配置
isRenderWatcher // 是否為渲染watcher
) {
this.vm = vm;
if (isRenderWatcher) {
vm._watcher = this;
}
vm._watchers.push(this);
// options
if (options) {
this.deep = !!options.deep;
this.user = !!options.user;
this.lazy = !!options.lazy;
this.sync = !!options.sync;
this.before = options.before;
} else {
this.deep = this.user = this.lazy = this.sync = false;
}
this.cb = cb;
this.id = ++uid$2; // uid for batching
this.active = true;
this.dirty = this.lazy; // for lazy watchers
this.deps = [];
this.newDeps = [];
this.depIds = new _Set();
this.newDepIds = new _Set();
this.expression = expOrFn.toString();
// parse expression for getter
if (typeof expOrFn === 'function') {
this.getter = expOrFn;
} else {
this.getter = parsePath(expOrFn);
if (!this.getter) {
this.getter = noop;
warn(
"Failed watching path: \"" + expOrFn + "\" " +
'Watcher only accepts simple dot-delimited paths. ' +
'For full control, use a function instead.',
vm
);
}
}
// lazy為計算屬性標誌,當watcher為計算watcher時,不會理解執行get方法進行求值
this.value = this.lazy
? undefined
: this.get();
}
複製程式碼
方法get
的定義如下:
Watcher.prototype.get = function get () {
pushTarget(this);
var value;
var vm = this.vm;
try {
value = this.getter.call(vm, vm);
} catch (e) {
···
} finally {
···
// 把Dep.target恢復到上一個狀態,依賴收集過程完成
popTarget();
this.cleanupDeps();
}
return value
};
複製程式碼
get
方法會執行this.getter
進行求值,在當前渲染watcher
的條件下,getter
會執行檢視更新的操作。這一階段會重新渲染頁面元件
new Watcher(vm, updateComponent, noop, { before: () => {} }, true);
updateComponent = function () {
vm._update(vm._render(), hydrating);
};
複製程式碼
執行完getter
方法後,最後一步會進行依賴的清除,也就是cleanupDeps
的過程。
關於依賴清除的作用,我們列舉一個場景: 我們經常會使用
v-if
來進行模板的切換,切換過程中會執行不同的模板渲染,如果A模板監聽a資料,B模板監聽b資料,當渲染模板B時,如果不進行舊依賴的清除,在B模板的場景下,a資料的變化同樣會引起依賴的重新渲染更新,這會造成效能的浪費。因此舊依賴的清除在優化階段是有必要。
// 依賴清除的過程
Watcher.prototype.cleanupDeps = function cleanupDeps () {
var i = this.deps.length;
while (i--) {
var dep = this.deps[i];
if (!this.newDepIds.has(dep.id)) {
dep.removeSub(this);
}
}
var tmp = this.depIds;
this.depIds = this.newDepIds;
this.newDepIds = tmp;
this.newDepIds.clear();
tmp = this.deps;
this.deps = this.newDeps;
this.newDeps = tmp;
this.newDeps.length = 0;
};
複製程式碼
把上面分析的總結成依賴派發更新的最後兩個點
- 執行
run
操作會執行getter
方法,也就是重新計算新值,針對渲染watcher
而言,會重新執行updateComponent
進行檢視更新 - 重新計算
getter
後,會進行依賴的清除
7.10 computed
計算屬性設計的初衷是用於簡單運算的,畢竟在模板中放入太多的邏輯會讓模板過重且難以維護。在分析computed
時,我們依舊遵循依賴收集和派發更新兩個過程進行分析。
7.10.1 依賴收集
computed
的初始化過程,會遍歷computed
的每一個屬性值,併為每一個屬性例項化一個computed watcher
,其中{ lazy: true}
是computed watcher
的標誌,最終會呼叫defineComputed
將資料設定為響應式資料,對應原始碼如下:
function initComputed() {
···
for(var key in computed) {
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
computedWatcherOptions
);
}
if (!(key in vm)) {
defineComputed(vm, key, userDef);
}
}
// computed watcher的標誌,lazy屬性為true
var computedWatcherOptions = { lazy: true };
複製程式碼
defineComputed
的邏輯和分析data
的邏輯相似,最終呼叫Object.defineProperty
進行資料攔截。具體的定義如下:
function defineComputed (target,key,userDef) {
// 非服務端渲染會對getter進行快取
var shouldCache = !isServerRendering();
if (typeof userDef === 'function') {
//
sharedPropertyDefinition.get = shouldCache
? createComputedGetter(key)
: createGetterInvoker(userDef);
sharedPropertyDefinition.set = noop;
} else {
sharedPropertyDefinition.get = userDef.get
? shouldCache && userDef.cache !== false
? createComputedGetter(key)
: createGetterInvoker(userDef.get)
: noop;
sharedPropertyDefinition.set = userDef.set || noop;
}
if (sharedPropertyDefinition.set === noop) {
sharedPropertyDefinition.set = function () {
warn(
("Computed property \"" + key + "\" was assigned to but it has no setter."),
this
);
};
}
Object.defineProperty(target, key, sharedPropertyDefinition);
}
複製程式碼
在非服務端渲染的情形,計算屬性的計算結果會被快取,快取的意義在於,只有在相關響應式資料發生變化時,computed
才會重新求值,其餘情況多次訪問計算屬性的值都會返回之前計算的結果,這就是快取的優化,computed
屬性有兩種寫法,一種是函式,另一種是物件,其中物件的寫法需要提供getter
和setter
方法。
當訪問到computed
屬性時,會觸發getter
方法進行依賴收集,看看createComputedGetter
的實現。
function createComputedGetter (key) {
return function computedGetter () {
var watcher = this._computedWatchers && this._computedWatchers[key];
if (watcher) {
if (watcher.dirty) {
watcher.evaluate();
}
if (Dep.target) {
watcher.depend();
}
return watcher.value
}
}
}
複製程式碼
createComputedGetter
返回的函式在執行過程中會先拿到屬性的computed watcher
,dirty
是標誌是否已經執行過計算結果,如果執行過則不會執行watcher.evaluate
重複計算,這也是快取的原理。
Watcher.prototype.evaluate = function evaluate () {
// 對於計算屬性而言 evaluate的作用是執行計算回撥
this.value = this.get();
this.dirty = false;
};
複製程式碼
get
方法前面介紹過,會呼叫例項化watcher
時傳遞的執行函式,在computer watcher
的場景下,執行函式是計算屬性的計算函式,他可以是一個函式,也可以是物件的getter
方法。
列舉一個場景避免和
data
的處理脫節,computed
在計算階段,如果訪問到data
資料的屬性值,會觸發data
資料的getter
方法進行依賴收集,根據前面分析,data
的Dep
收集器會將當前watcher
作為依賴進行收集,而這個watcher
就是computed watcher
,並且會為當前的watcher
新增訪問的資料Dep
回到計算執行函式的this.get()
方法,getter
執行完成後同樣會進行依賴的清除,原理和目的參考data
階段的分析。get
執行完畢後會進入watcher.depend
進行依賴的收集。收集過程和data
一致,將當前的computed watcher
作為依賴收集到資料的依賴收集器Dep
中。
這就是computed
依賴收集的完整過程,對比data
的依賴收集,computed
會對運算的結果進行快取,避免重複執行運算過程。
7.10.2 派發更新
派發更新的條件是data
中資料發生改變,所以大部分的邏輯和分析data
時一致,我們做一個總結。
- 當計算屬性依賴的資料發生更新時,由於資料的
Dep
收集過computed watch
這個依賴,所以會呼叫dep
的notify
方法,對依賴進行狀態更新。 - 此時
computed watcher
和之前介紹的watcher
不同,它不會立刻執行依賴的更新操作,而是通過一個dirty
進行標記。我們再回頭看依賴更新
的程式碼。
Dep.prototype.notify = function() {
···
for (var i = 0, l = subs.length; i < l; i++) {
subs[i].update();
}
}
Watcher.prototype.update = function update () {
// 計算屬性分支
if (this.lazy) {
this.dirty = true;
} else if (this.sync) {
this.run();
} else {
queueWatcher(this);
}
};
複製程式碼
由於lazy
屬性的存在,update
過程不會執行狀態更新的操作,只會將dirty
標記為true
。
- 由於
data
資料擁有渲染watcher
這個依賴,所以同時會執行updateComponent
進行檢視重新渲染,而render
過程中會訪問到計算屬性,此時由於this.dirty
值為true
,又會對計算屬性重新求值。
7.11 小結
我們在上一節的理論基礎上深入分析了Vue
如何利用data,computed
構建響應式系統。響應式系統的核心是利用Object.defineProperty
對資料的getter,setter
進行攔截處理,處理的核心是在訪問資料時對資料所在場景的依賴進行收集,在資料發生更改時,通知收集過的依賴進行更新。這一節我們詳細的介紹了data,computed
對響應式的處理,兩者處理邏輯存在很大的相似性但卻各有的特性。原始碼中會computed
的計算結果進行快取,避免了在多個地方使用時頻繁重複計算的問題。由於篇幅有限,對於使用者自定義的watcher
我們會放到下一小節分析。文章還留有一個疑惑,依賴收集時如果遇到的資料是陣列時應該怎麼處理,這些疑惑都會在之後的文章一一解開。
- 深入剖析Vue原始碼 - 選項合併(上)
- 深入剖析Vue原始碼 - 選項合併(下)
- 深入剖析Vue原始碼 - 資料代理,關聯子父元件
- 深入剖析Vue原始碼 - 例項掛載,編譯流程
- 深入剖析Vue原始碼 - 完整渲染過程
- 深入剖析Vue原始碼 - 元件基礎
- 深入剖析Vue原始碼 - 元件進階
- 深入剖析Vue原始碼 - 響應式系統構建(上)
- 深入剖析Vue原始碼 - 響應式系統構建(中)
- 深入剖析Vue原始碼 - 響應式系統構建(下)
- 深入剖析Vue原始碼 - 來,跟我一起實現diff演算法!
- 深入剖析Vue原始碼 - 揭祕Vue的事件機制
- 深入剖析Vue原始碼 - Vue插槽,你想了解的都在這裡!
- 深入剖析Vue原始碼 - 你瞭解v-model的語法糖嗎?
- 深入剖析Vue原始碼 - Vue動態元件的概念,你會亂嗎?
- 徹底搞懂Vue中keep-alive的魔法(上)
- 徹底搞懂Vue中keep-alive的魔法(下)