上一節,我們深入分析了以
data,computed
為資料建立響應式系統的過程,並對其中依賴收集和派發更新的過程進行了詳細的分析。然而在使用和分析過程中依然存在或多或少的問題,這一節我們將針對這些問題展開分析,最後我們也會分析一下watch
的響應式過程。這篇文章將作為響應式系統分析的完結篇。
7.12 陣列檢測
在之前介紹資料代理章節,我們已經詳細介紹過Vue
資料代理的技術是利用了Object.defineProperty
,Object.defineProperty
讓我們可以方便的利用存取描述符中的getter/setter
來進行資料的監聽,在get,set
鉤子中分別做不同的操作,達到資料攔截的目的。然而Object.defineProperty
的get,set
方法只能檢測到物件屬性的變化,對於陣列的變化(例如插入刪除陣列元素等操作),Object.defineProperty
卻無法達到目的,這也是利用Object.defineProperty
進行資料監控的缺陷,雖然es6
中的proxy
可以完美解決這一問題,但畢竟有相容性問題,所以我們還需要研究Vue
在Object.defineProperty
的基礎上如何對陣列進行監聽檢測。
7.12.1 陣列方法的重寫
既然陣列已經不能再通過資料的getter,setter
方法去監聽變化了,Vue
的做法是對陣列方法進行重寫,在保留原陣列功能的前提下,對陣列進行額外的操作處理。也就是重新定義了陣列方法。
var arrayProto = Array.prototype;
// 新建一個繼承於Array的物件
var arrayMethods = Object.create(arrayProto);
// 陣列擁有的方法
var methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
];
複製程式碼
arrayMethods
是基於原始Array
類為原型繼承的一個物件類,由於原型鏈的繼承,arrayMethod
擁有陣列的所有方法,接下來對這個新的陣列類的方法進行改寫。
methodsToPatch.forEach(function (method) {
// 緩衝原始陣列的方法
var original = arrayProto[method];
// 利用Object.defineProperty對方法的執行進行改寫
def(arrayMethods, method, function mutator () {});
});
function def (obj, key, val, enumerable) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
});
}
複製程式碼
這裡對陣列方法設定了代理,當執行arrayMethods
的陣列方法時,會代理執行mutator
函式,這個函式的具體實現,我們放到陣列的派發更新中介紹。
僅僅建立一個新的陣列方法合集是不夠的,我們在訪問陣列時,如何不呼叫原生的陣列方法,而是將過程指向這個新的類,這是下一步的重點。
回到資料初始化過程,也就是執行initData
階段,上一篇內容花了大篇幅介紹過資料初始化會為data
資料建立一個Observer
類,當時我們只講述了Observer
類會為每個非陣列的屬性進行資料攔截,重新定義getter,setter
方法,除此之外對於陣列型別的資料,我們有意跳過分析了。這裡,我們重點看看對於陣列攔截的處理。
var Observer = function Observer (value) {
this.value = value;
this.dep = new Dep();
this.vmCount = 0;
// 將__ob__屬性設定成不可列舉屬性。外部無法通過遍歷獲取。
def(value, '__ob__', this);
// 陣列處理
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods);
} else {
copyAugment(value, arrayMethods, arrayKeys);
}
this.observeArray(value);
} else {
// 物件處理
this.walk(value);
}
}
複製程式碼
陣列處理的分支分為兩個,hasProto
的判斷條件,hasProto
用來判斷當前環境下是否支援__proto__
屬性。而陣列的處理會根據是否支援這一屬性來決定執行protoAugment, copyAugment
過程,
// __proto__屬性的判斷
var hasProto = '__proto__' in {};
複製程式碼
當支援__proto__
時,執行protoAugment
會將當前陣列的原型指向新的陣列類arrayMethods
,如果不支援__proto__
,則通過代理設定,在訪問陣列方法時代理訪問新陣列類中的陣列方法。
//直接通過原型指向的方式
function protoAugment (target, src) {
target.__proto__ = src;
}
// 通過資料代理的方式
function copyAugment (target, src, keys) {
for (var i = 0, l = keys.length; i < l; i++) {
var key = keys[i];
def(target, key, src[key]);
}
}
複製程式碼
有了這兩步的處理,接下來我們在例項內部呼叫push, unshift
等陣列的方法時,會執行arrayMethods
類的方法。這也是陣列進行依賴收集和派發更新的前提。
7.12.2 依賴收集
由於資料初始化階段會利用Object.definePrototype
進行資料訪問的改寫,陣列的訪問同樣會被getter
所攔截。由於是陣列,攔截過程會做特殊處理,後面我們再看看dependArray
的原理。
function defineReactive###1() {
···
var childOb = !shallow && 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() {}
}
複製程式碼
childOb
是標誌屬性值是否為基礎型別的標誌,observe
如果遇到基本型別資料,則直接返回,不做任何處理,如果遇到物件或者陣列則會遞迴例項化Observer
,會為每個子屬性設定響應式資料,最終返回Observer
例項。而例項化Observer
又回到之前的老流程:
新增__ob__
屬性,如果遇到陣列則進行原型重指向,遇到物件則定義getter,setter
,這一過程前面分析過,就不再闡述。
在訪問到陣列時,由於childOb
的存在,會執行childOb.dep.depend();
進行依賴收集,該Observer
例項的dep
屬性會收集當前的watcher
作為依賴儲存,dependArray
保證瞭如果陣列元素是陣列或者物件,需要遞迴去為內部的元素收集相關的依賴。
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);
}
}
}
複製程式碼
我們可以通過截圖看最終依賴收集的結果。
收集前
收集後
/img/7.2.png)7.12.3 派發更新
當呼叫陣列的方法去新增或者刪除資料時,資料的setter
方法是無法攔截的,所以我們唯一可以攔截的過程就是呼叫陣列方法的時候,前面介紹過,陣列方法的呼叫會代理到新類arrayMethods
的方法中,而arrayMethods
的陣列方法是進行重寫過的。具體我們看他的定義。
methodsToPatch.forEach(function (method) {
var original = arrayProto[method];
def(arrayMethods, method, function mutator () {
var args = [], len = arguments.length;
while ( len-- ) args[ len ] = arguments[ len ];
// 執行原陣列方法
var result = original.apply(this, args);
var ob = this.__ob__;
var inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args;
break
case 'splice':
inserted = args.slice(2);
break
}
if (inserted) { ob.observeArray(inserted); }
// notify change
ob.dep.notify();
return result
});
});
複製程式碼
mutator
是重寫的陣列方法,首先會呼叫原始的陣列方法進行運算,這保證了與原始陣列型別的方法一致性,args
儲存了陣列方法呼叫傳遞的引數。之後取出陣列的__ob__
也就是之前儲存的Observer
例項,呼叫ob.dep.notify();
進行依賴的派發更新,前面知道了。Observer
例項的dep
是Dep
的例項,他收集了需要監聽的watcher
依賴,而notify
會對依賴進行重新計算並更新。具體看Dep.prototype.notify = function notify () {}
函式的分析,這裡也不重複贅述。
回到程式碼中,inserted
變數用來標誌陣列是否是增加了元素,如果增加的元素不是原始型別,而是陣列物件型別,則需要觸發observeArray
方法,對每個元素進行依賴收集。
Observer.prototype.observeArray = function observeArray (items) {
for (var i = 0, l = items.length; i < l; i++) {
observe(items[i]);
}
};
複製程式碼
總的來說。陣列的改變不會觸發setter
進行依賴更新,所以Vue
建立了一個新的陣列類,重寫了陣列的方法,將陣列方法指向了新的陣列類。同時在訪問到陣列時依舊觸發getter
進行依賴收集,在更改陣列時,觸發陣列新方法運算,並進行依賴的派發。
現在我們回過頭看看Vue的官方文件對於陣列檢測時的注意事項:
Vue
不能檢測以下陣列的變動:
- 當你利用索引直接設定一個陣列項時,例如:
vm.items[indexOfItem] = newValue
- 當你修改陣列的長度時,例如:
vm.items.length = newLength
顯然有了上述的分析我們很容易理解陣列檢測帶來的弊端,即使Vue
重寫了陣列的方法,以便在設定陣列時進行攔截處理,但是不管是通過索引還是直接修改長度,都是無法觸發依賴更新的。
7.13 物件檢測異常
我們在實際開發中經常遇到一種場景,物件test: { a: 1 }
要新增一個屬性b
,這時如果我們使用test.b = 2
的方式去新增,這個過程Vue
是無法檢測到的,理由也很簡單。我們在對物件進行依賴收集的時候,會為物件的每個屬性都進行收集依賴,而直接通過test.b
新增的新屬性並沒有依賴收集的過程,因此當之後資料b
發生改變時也不會進行依賴的更新。
瞭解決這一問題,Vue
提供了Vue.set(object, propertyName, value)
的靜態方法和vm.$set(object, propertyName, value)
的例項方法,我們看具體怎麼完成新屬性的依賴收集過程。
Vue.set = set
function set (target, key, val) {
//target必須為非空物件
if (isUndef(target) || isPrimitive(target)
) {
warn(("Cannot set reactive property on undefined, null, or primitive value: " + ((target))));
}
// 陣列場景,呼叫重寫的splice方法,對新新增屬性收集依賴。
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key);
target.splice(key, 1, val);
return val
}
// 新增物件的屬性存在時,直接返回新屬性,觸發依賴收集
if (key in target && !(key in Object.prototype)) {
target[key] = val;
return val
}
// 拿到目標源的Observer 例項
var ob = (target).__ob__;
if (target._isVue || (ob && ob.vmCount)) {
warn(
'Avoid adding reactive properties to a Vue instance or its root $data ' +
'at runtime - declare it upfront in the data option.'
);
return val
}
// 目標源物件本身不是一個響應式物件,則不需要處理
if (!ob) {
target[key] = val;
return val
}
// 手動呼叫defineReactive,為新屬性設定getter,setter
defineReactive###1(ob.value, key, val);
ob.dep.notify();
return val
}
複製程式碼
按照分支分為不同的四個處理邏輯:
- 目標物件必須為非空的物件,可以是陣列,否則丟擲異常。
- 如果目標物件是陣列時,呼叫陣列的
splice
方法,而前面分析陣列檢測時,遇到陣列新增元素的場景,會呼叫ob.observeArray(inserted)
對陣列新增的元素收集依賴。 - 新增的屬性值在原物件中已經存在,則手動訪問新的屬性值,這一過程會觸發依賴收集。
- 手動定義新屬性的
getter,setter
方法,並通過notify
觸發依賴更新。
7.14 nextTick
在上一節的內容中,我們說到資料修改時會觸發setter
方法進行依賴的派發更新,而更新時會將每個watcher
推到佇列中,等待下一個tick
到來時再執行DOM
的渲染更新操作。這個就是非同步更新的過程。為了說明非同步更新的概念,需要牽扯到瀏覽器的事件迴圈機制和最優的渲染時機問題。由於這不是文章的主線,我只用簡單的語言概述。
7.14.1 事件迴圈機制
- 完整的事件迴圈機制需要了解兩種非同步佇列:
macro-task
和micro-task
macro-task
常見的有setTimeout, setInterval, setImmediate, script指令碼, I/O操作,UI渲染
micro-task
常見的有promise, process.nextTick, MutationObserver
等- 完整事件迴圈流程為:
4.1
micro-task
空,macro-task
佇列只有script
指令碼,推出macro-task
的script
任務執行,指令碼執行期間產生的macro-task,micro-task
推到對應的佇列中 4.2 執行全部micro-task
裡的微任務事件 4.3 執行DOM
操作,渲染更新頁面 4.4 執行web worker
等相關任務 4.5 迴圈,取出macro-task
中一個巨集任務事件執行,重複4的操作。
從上面的流程中我們可以發現,最好的渲染過程發生在微任務佇列的執行過程中,此時他離頁面渲染過程最近,因此我們可以藉助微任務佇列來實現非同步更新,它可以讓複雜批量的運算操作執行在JS層面,而檢視的渲染只關心最終的結果,這大大降低了效能的損耗。
舉一個這一做法好處的例子:
由於Vue
是資料驅動檢視更新渲染,如果我們在一個操作中重複對一個響應式資料進行計算,例如 在一個迴圈中執行this.num ++
一千次,由於響應式系統的存在,資料變化觸發setter
,setter
觸發依賴派發更新,更新呼叫run
進行檢視的重新渲染。這一次迴圈,檢視渲染要執行一千次,很明顯這是很浪費效能的,我們只需要關注最後第一千次在介面上更新的結果而已。所以利用非同步更新顯得格外重要。
7.14.2 基本實現
Vue
用一個queue
收集依賴的執行,在下次微任務執行的時候統一執行queue
中Watcher
的run
操作,與此同時,相同id
的watcher
不會重複新增到queue
中,因此也不會重複執行多次的檢視渲染。我們看nextTick
的實現。
// 原型上定義的方法
Vue.prototype.$nextTick = function (fn) {
return nextTick(fn, this)
};
// 建構函式上定義的方法
Vue.nextTick = nextTick;
// 實際的定義
var callbacks = [];
function nextTick (cb, ctx) {
var _resolve;
// callbacks是維護微任務的陣列。
callbacks.push(function () {
if (cb) {
try {
cb.call(ctx);
} catch (e) {
handleError(e, ctx, 'nextTick');
}
} else if (_resolve) {
_resolve(ctx);
}
});
if (!pending) {
pending = true;
// 將維護的佇列推到微任務佇列中維護
timerFunc();
}
// nextTick沒有傳遞引數,且瀏覽器支援Promise,則返回一個promise物件
if (!cb && typeof Promise !== 'undefined') {
return new Promise(function (resolve) {
_resolve = resolve;
})
}
}
複製程式碼
nextTick
定義為一個函式,使用方式為Vue.nextTick( [callback, context] )
,當callback
經過nextTick
封裝後,callback
會在下一個tick
中執行呼叫。從實現上,callbacks
是一個維護了需要在下一個tick
中執行的任務的佇列,它的每個元素都是需要執行的函式。pending
是判斷是否在等待執行微任務佇列的標誌。而timerFunc
是真正將任務佇列推到微任務佇列中的函式。我們看timerFunc
的實現。
1.如果瀏覽器執行Promise
,那麼預設以Promsie
將執行過程推到微任務佇列中。
var timerFunc;
if (typeof Promise !== 'undefined' && isNative(Promise)) {
var p = Promise.resolve();
timerFunc = function () {
p.then(flushCallbacks);
// 手機端的相容程式碼
if (isIOS) { setTimeout(noop); }
};
// 使用微任務佇列的標誌
isUsingMicroTask = true;
}
複製程式碼
flushCallbacks
是非同步更新的函式,他會取出callbacks陣列的每一個任務,執行任務,具體定義如下:
function flushCallbacks () {
pending = false;
var copies = callbacks.slice(0);
// 取出callbacks陣列的每一個任務,執行任務
callbacks.length = 0;
for (var i = 0; i < copies.length; i++) {
copies[i]();
}
}
複製程式碼
2.不支援promise
,支援MutataionObserver
else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
var counter = 1;
var observer = new MutationObserver(flushCallbacks);
var textNode = document.createTextNode(String(counter));
observer.observe(textNode, {
characterData: true
});
timerFunc = function () {
counter = (counter + 1) % 2;
textNode.data = String(counter);
};
isUsingMicroTask = true;
}
複製程式碼
3.如果不支援微任務方法,則會使用巨集任務方法,setImmediate
會先被使用
else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
// Fallback to setImmediate.
// Techinically it leverages the (macro) task queue,
// but it is still a better choice than setTimeout.
timerFunc = function () {
setImmediate(flushCallbacks);
};
}
複製程式碼
4.所有方法都不適合,會使用巨集任務方法中的setTimeout
else {
timerFunc = function () {
setTimeout(flushCallbacks, 0);
};
}
複製程式碼
當nextTick
不傳遞任何引數時,可以作為一個promise
用,例如:
nextTick().then(() => {})
複製程式碼
7.14.3 使用場景
說了這麼多原理性的東西,回過頭來看看nextTick
的使用場景,由於非同步更新的原理,我們在某一時間改變的資料並不會觸發檢視的更新,而是需要等下一個tick
到來時才會更新檢視,下面是一個典型場景:
<input v-if="show" type="text" ref="myInput">
// js
data() {
show: false
},
mounted() {
this.show = true;
this.$refs.myInput.focus();// 報錯
}
複製程式碼
資料改變時,檢視並不會同時改變,因此需要使用nextTick
mounted() {
this.show = true;
this.$nextTick(function() {
this.$refs.myInput.focus();// 正常
})
}
複製程式碼
7.15 watch
到這裡,關於響應式系統的分析大部分內容已經分析完畢,我們上一節還遺留著一個問題,Vue
對使用者手動新增的watch
如何進行資料攔截。我們先看看兩種基本的使用形式。
// watch選項
var vm = new Vue({
el: '#app',
data() {
return {
num: 12
}
},
watch: {
num() {}
}
})
vm.num = 111
// $watch api方式
vm.$watch('num', function() {}, {
deep: ,
immediate: ,
})
複製程式碼
7.15.1 依賴收集
我們以watch
選項的方式來分析watch
的細節,同樣從初始化說起,初始化資料會執行initWatch
,initWatch
的核心是createWatcher
。
function initWatch (vm, watch) {
for (var key in watch) {
var handler = watch[key];
// handler可以是陣列的形式,執行多個回撥
if (Array.isArray(handler)) {
for (var i = 0; i < handler.length; i++) {
createWatcher(vm, key, handler[i]);
}
} else {
createWatcher(vm, key, handler);
}
}
}
function createWatcher (vm,expOrFn,handler,options) {
// 針對watch是物件的形式,此時回撥回選項中的handler
if (isPlainObject(handler)) {
options = handler;
handler = handler.handler;
}
if (typeof handler === 'string') {
handler = vm[handler];
}
return vm.$watch(expOrFn, handler, options)
}
複製程式碼
無論是選項的形式,還是api
的形式,最終都會呼叫例項的$watch
方法,其中expOrFn
是監聽的字串,handler
是監聽的回撥函式,options
是相關配置。我們重點看看$watch
的實現。
Vue.prototype.$watch = function (expOrFn,cb,options) {
var vm = this;
if (isPlainObject(cb)) {
return createWatcher(vm, expOrFn, cb, options)
}
options = options || {};
options.user = true;
var watcher = new Watcher(vm, expOrFn, cb, options);
// 當watch有immediate選項時,立即執行cb方法,即不需要等待屬性變化,立刻執行回撥。
if (options.immediate) {
try {
cb.call(vm, watcher.value);
} catch (error) {
handleError(error, vm, ("callback for immediate watcher \"" + (watcher.expression) + "\""));
}
}
return function unwatchFn () {
watcher.teardown();
}
};
}
複製程式碼
$watch
的核心是建立一個user watcher
,options.user
是當前使用者定義watcher
的標誌。如果有immediate
屬性,則立即執行回撥函式。
而例項化watcher
時會執行一次getter
求值,這時,user watcher
會作為依賴被資料所收集。這個過程可以參考data
的分析。
var Watcher = function Watcher() {
···
this.value = this.lazy
? undefined
: this.get();
}
Watcher.prototype.get = function get() {
···
try {
// getter回撥函式,觸發依賴收集
value = this.getter.call(vm, vm);
}
}
複製程式碼
7.15.2 派發更新
watch
派發更新的過程很好理解,資料發生改變時,setter
攔截對依賴進行更新,而此前user watcher
已經被當成依賴收集了。這個時候依賴的更新就是回撥函式的執行。
7.16 小結
這一節是響應式系統構建的完結篇,data,computed
如何進行響應式系統設計,這在上一節內容已經詳細分析,這一節針對一些特殊場景做了分析。例如由於Object.defineProperty
自身的缺陷,無法對陣列的新增刪除進行攔截檢測,因此Vue
對陣列進行了特殊處理,重寫了陣列的方法,並在方法中對資料進行攔截。我們也重點介紹了nextTick
的原理,利用瀏覽器的事件迴圈機制來達到最優的渲染時機。文章的最後補充了watch
在響應式設計的原理,使用者自定義的watch
會建立一個依賴,這個依賴在資料改變時會執行回撥。
- 深入剖析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的魔法(下)