Vue2 與 Vue3 響應式原理與依賴收集詳解
前言
繼 Angular 和 React 之後,尤大在 2016 年發佈瞭如今 “前端三劍客” 之一的 Vue 2.0,並憑藉其簡單易用、輕量高效的特點受到了廣泛的歡迎,特別是在國內環境中。然而 Vue 2 中基於 Object.defineProperty
實現的響應式系統,隨着 JavaScript 與瀏覽器技術的不斷升級,其缺陷也愈加明顯~
所以 Vue 團隊在 Vue 2 的基礎上,通過基於 Proxy
的全新響應式系統發佈了 Vue 3.0,帶來了更好的性能表現。
本文將深入剖析 Vue 2 與 Vue 3 的響應式系統實現,解釋其實現原理和優勢對比,如果你正在學習和了解 Vue 源碼相關的內容,那本文肯定值得一看~
Vue 的響應式系統設計思路
雖然 Vue 2 與 Vue 3 實現響應式系統的方式不同,但是他們的核心思想還是一致的,都是通過 發佈 - 訂閱模式 來實現(因爲發佈者和觀察者之間多了一個 dependence 依賴收集者,與傳統觀察者模式不同)。
個人理解,觀察者模式 與 發佈 - 訂閱模式 都是 消息傳遞的一種實現方式,用來實現 對象間的通信(消息傳遞),只是發佈訂閱模式可以看做是觀察者模式的 升級版,避免了 被觀察對象與觀察者之間的直接聯繫。
觀察者模式:一個被觀察對象對應多個觀察者,兩者直接聯繫;被觀察這改變時直接向所有觀察者發送消息(調用觀察者的更新方法)
發佈訂閱模式:被觀察對象與觀察者之間可能是 多對多 的關係,兩者都可以綁定多個另一角色;而兩個角色之間還有一個 依賴收集和管理 的角色(提供一些觀察者的操作方法)。
在 Vue 中我們視圖中依賴的每一個數據其實就是一個被觀察者,我們的視圖渲染函數(renderWatcher)和其他的 watcher/effect 函數則都是訂閱者。
當數據改變時,就會發送一個事件去觸發我們的觀察者進行更新,即視圖更新。
Vue 2 的響應式實現
剛剛我們簡述了一下 Vue 實現響應式的基礎方案,就是通過發佈訂閱模式收集數據依賴,當數據更新時觸發 render 等一系列 Watcher 函數的執行來實現視圖更新的。
大家也都知道 Vue 2 是通過 Object.defineProperty
來實現數據 讀取和更新時的操作劫持,通過更改默認的 getter/setter 函數,在 get 過程中收集依賴,在 set 過程中派發更新的。
我們可以通過下面的簡易代碼來分析:
// 響應式數據處理,構造一個響應式對象
class Observer {
constructor(data) {
this.data = data
this.walk(data)
}
// 遍歷對象的每個 已定義 屬性,分別執行 defineReactive
walk(data) {
if (!data || typeof data !== 'object') {
return
}
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key])
})
}
// 爲對象的每個屬性重新設置 getter/setter
defineReactive(obj, key, val) {
// 每個屬性都有單獨的 dep 依賴管理
const dep = new Dep()
// 通過 defineProperty 進行操作代理定義
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
// 值的讀取操作,進行依賴收集
get() {
if (Dep.target) {
dep.depend()
}
return val
},
// 值的更新操作,觸發依賴更新
set(newVal) {
if (newVal === val) {
return
}
val = newVal
dep.notify()
}
})
}
}
// 觀察者的構造函數,接收一個表達式和回調函數
class Watcher {
constructor(vm, expOrFn, cb) {
this.vm = vm
this.getter = parsePath(expOrFn)
this.cb = cb
this.value = this.get()
}
// watcher 實例觸發值讀取時,將依賴收集的目標對象設置成自身,
// 通過 call 綁定當前 Vue 實例進行一次函數執行,在運行過程中收集函數中用到的數據
// 此時會在所有用到數據的 dep 依賴管理中插入該觀察者實例
get() {
Dep.target = this
const value = this.getter.call(this.vm, this.vm)
// 函數執行完畢後將依賴收集目標清空,避免重複收集
Dep.target = null
return value
}
// dep 依賴更新時會調用,執行回調函數
update() {
const oldValue = this.value
this.value = this.get()
this.cb.call(this.vm, this.value, oldValue)
}
}
// 依賴收集管理者的構造函數
class Dep {
constructor() {
// 保存所有 watcher 觀察者依賴數組
this.subs = []
}
// 插入一個觀察者到依賴數組中
addSub(sub) {
this.subs.push(sub)
}
// 收集依賴,只有此時的依賴目標(watcher 實例)存在時才收集依賴
depend() {
if (Dep.target) {
this.addSub(Dep.target)
}
}
// 發送更新,遍歷依賴數組分別執行每個觀察者定義好的 update 方法
notify() {
this.subs.forEach(sub => {
sub.update()
})
}
}
Dep.target = null
// 表達式解析
function parsePath(path) {
const segments = path.split('.')
return function (obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) {
return
}
obj = obj[segments[i]]
}
return obj
}
}
這裏省略了數組部分,但是 數組本身的響應式監聽 是通過重寫數組方法來實現的,而 每個數組元素 則會再次進行 Observer 處理(需要數組在定義時就已經聲明的數組元素)。
當然,因爲 Object.definePorperty 只能對 對象的已知屬性 進行操作,所有才會導致 沒有在 data 中進行聲明的對象屬性直接賦值時無法觸發視圖更新,需要通過魔法($set
)來處理。而數組也應爲是通過重新數組方法和遍歷數組元素進行的響應式處理,也會導致按照數組下標進行賦值或者更改元素時無法觸發視圖更新。
例如:
<body>
<div id="app" class="demo-vm-1">
<p>{{arr[0]}}</p>
<p>{{arr[2]}}</p>
<p>{{arr[3].c}}</p>
</div>
</body>
<script>
new Vue({
el: "#app",
data() {
return {
arr: [1, 2, { a: 3 },{ c: 5 }]
}
},
mounted() {
console.log("demo Instance: ", this.$data);
setTimeout(() => {
console.log('update')
this.arr[0] = { o: 1 }
this.arr[2] = { a: 1 }
},2000)
},
})
</script>
因爲數組元素的前三個元素 在定義時都是簡單類型,所以即使在模板中使用了該數據,也無法進行依賴收集和更新響應:
而本身的 data
返回對象與 arr, arr[3]
都有各自的 dep
依賴數組,並且 arr
和 arr[3]
的依賴中都有同一個 Watcher
—— RenderWacther
模板渲染。
當然整個響應系統還包含依賴清理等其他操作,具體過程可以查看 Vue 2 閱讀理解(十八)之響應式系統(一 ~ 四)Watcher
Vue 3 的響應式實現
礙於 Object.defineProperty
的侷限性,Vue 3 採用了全新的 Proxy
對象來實現整個響應式系統基礎。
什麼是 Proxy ?
Proxy
是 ES6
新增的一個構造函數,用來創建一個 目標對象的代理對象,攔截對原對象的所有操作;用戶可以通過註冊相應的攔截方法來實現對象操作時的自定義行爲。
目前 Proxy
支持的攔截方法包含一下內容:
-
•
get(target, propKey, receiver)
:攔截對象屬性的讀取操作; -
•
set(target, propKey, value, receiver)
:攔截對象屬性的賦值操作; -
•
apply(target, thisArg, argArray)
:攔截函數的調用操作; -
•
construct(target, argArray, newTarget)
:攔截對象的實例化操作; -
•
has(target, propKey)
:攔截in
操作符; -
•
deleteProperty(target, propKey)
:攔截delete
操作符; -
•
defineProperty(target, propKey, propDesc)
:攔截Object.defineProperty
方法; -
•
getOwnPropertyDescriptor(target, propKey)
:攔截Object.getOwnPropertyDescriptor
方法; -
•
getPrototypeOf(target)
:攔截Object.getPrototypeOf
方法; -
•
setPrototypeOf(target, proto)
:攔截Object.setPrototypeOf
方法; -
•
isExtensible(target)
:攔截Object.isExtensible
方法; -
•
preventExtensions(target)
:攔截Object.preventExtensions
方法; -
•
enumerate(target)
:攔截for...in
循環; -
•
ownKeys(target)
:攔截Object.getOwnPropertyNames
、Object.getOwnPropertySymbols
、Object.keys
、JSON.stringify
方法。
與 Object,defineProperty
比起來,有非常明顯的優勢:
-
• 攔截操作更加多樣
-
• 攔截定義更加直接
-
• 性能更加高效
在 Vue 中體現最爲明顯的一點就是:Proxy
代理對象之後不僅可以攔截對象屬性的讀取、更新、方法調用之外,對整個對象的新增、刪除、枚舉等也能直接攔截,而 Object.defineProperty
只能針對對象的已知屬性進行讀取和更新的操作攔截。
例如:
const obj = { name: 'MiyueFE', age: 28 };
const proxyObj = new Proxy(obj, {
get(target, property) {
console.log(`Getting ${property} value: ${target[property]}`);
return target[property];
},
set(target, property, value) {
console.log(`Setting ${property} value: ${value}`);
target[property] = value;
},
deleteProperty(target, property) {
console.log(`Deleting ${property} property`);
delete target[property];
},
});
console.log(proxyObj.name); // Getting name value: MiyueFE, 輸出 "MiyueFE"
proxyObj.name = 'MY'; // Setting name value: MY
console.log(proxyObj.name); // Getting name value: MY, 輸出 "MY"
delete proxyObj.age; // Deleting age property
console.log(proxyObj.age); // undefined
但是 只有通過
proxyObj
進行操作的時候才能通過定義的操作攔截方法進行處理,直接使用原對象則無法觸發攔截器。這也是 Vue 3 中要求的
reactive
聲明的對象修改原對象無法觸發視圖更新的原因。並且
Proxy
也只針對 引用類型數據 才能進行代理,所以這也是 Vue 的基礎數據都需要通過ref
進行聲明的原因,內部會建立一個新對象保存原有的基礎數據值。
例如:
// 接上面的代碼
obj.name = "newName"; // "newName"
console.log(proxyObj.name); // Getting name value: newName; "newName"
響應式的實現
在選擇了使用 Proxy
代理來進行數據的操作攔截時,Vue 對依賴收集的邏輯也進行了修改,讓我們分別來解析一下這兩者的實現。
核心方法
Vue 提供了一下幾個 響應式數據聲明 的核心 API:
-
•
ref
:接受一個內部值,返回一個響應式的、可更改的 ref 對象,此對象只有一個指向其內部值的屬性.value
。function ref<T>(value: T): Ref<UnwrapRef<T>> interface Ref<T> { value: T }
-
•
shallowRef
:ref()
的淺層作用形式。function shallowRef<T>(value: T): ShallowRef<T> interface ShallowRef<T> { value: T }
-
•
reactive
:返回一個對象的響應式代理。function reactive<T extends object>(target: T): UnwrapNestedRefs<T>
-
•
shallowReactive
:reactive()
的淺層作用形式。function shallowReactive<T extends object>(target: T): T
-
•
readonly
:接受一個對象 (不論是響應式還是普通的) 或是一個 ref,返回一個原值的只讀代理。function readonly<T extends object>(target: T): DeepReadonly<UnwrapNestedRefs<T>>
-
•
shallowReadonly
:readonly
的淺層作用形式function shallowReadonly<T extends object>(target: T): Readonly<T>
其中 ref
雖然常常有文章說一般只用來 聲明基礎數據的響應式,但是其實 ** 所有的數據類型聲明響應式時都可以使用 ref
**,只是內部爲了同時實現基礎數據的響應式處理,封裝成了一個具有 value
屬性的對象,所以我們訪問時必須通過 xxx.value
的形式訪問。
ref
函數內部執行時會創建一個 RefImpl
類型的實例,其中的 _value
就保存響應式數據,並定義了對象的 get
和 set
方法,用來收集依賴和發佈更新事件。如果不是 shallowRef
聲明的淺層響應式數據的話,其 _value
其實也會通過 reactive
方法進行深層數據處理。
而 reactive
與 readonly
則比較相似,都是通過 createReactiveObject
方法來創建一個 Proxy
對象返回,只是 readonly
的 set
和 deleteProperty
操作會直接攔截報錯,提示禁止更新。
至於 get
操作的攔截兩者的大體思路差不多,不過 readonly
因爲只讀的原因,數據不會發生改變,所以不會進行依賴收集操作,如果有深層數據則會繼續向內部進行同樣的處理操作。
帶有 shallow
前綴的數據聲明方法,結合官方的解釋其實就明白了,整個定義 類似深拷貝與淺拷貝的區別,即只處理首層數據。代表假設使用 shadowRef
、shallowReactive
聲明的對象,如果對象的某個屬性也是對象,則 只有當這個屬性的引用地址發生改變時纔會觸發更新;至於 shallowReadobly
的話,其實就是讀取什麼返回什麼,基本沒有別的處理了。
操作攔截
因爲篇幅的問題,我們直接從核心方法 reactive
開始。
當我們通過 reactive
創建一個響應式數據時,會調用以下方法:
const reactiveMap = new WeakMap();
function reactive(target) {
return createReactiveObject(target, false, mutableHandlers, mutableCollectionHandlers, reactiveMap);
}
function createReactiveObject(target, isReadonly, baseHandlers, collectionHandlers, proxyMap) {
if (target["__v_raw"] && !(isReadonly && target["__v_isReactive"])) {
return target;
}
const existingProxy = proxyMap.get(target);
if (existingProxy) {
return existingProxy;
}
const targetType = getTargetType(target);
if (targetType === 0) {
return target;
}
const proxy = new Proxy(target, targetType === 2 ? collectionHandlers : baseHandlers);
proxyMap.set(target, proxy);
return proxy;
}
省略了
readonly
判斷和非引用數據判斷。
在 createReactiveObject
中首先判斷了是否是 保留原始數據 和 只讀數據校驗,然後判斷這個目標數據時候已經處理過,這兩種情況都直接返回。
然後通過 getTargetType
判斷數據類型:
function targetTypeMap(rawType) {
switch (rawType) {
case "Object":
case "Array":
return 1 /* COMMON */;
case "Map":
case "Set":
case "WeakMap":
case "WeakSet":
return 2 /* COLLECTION */;
default:
return 0 /* INVALID */;
}
}
這裏將數組和對象與 ES6
新增的 Set、Map
等進行了區分,非引用類型直接返回 invalid
錯誤。
最後則是根據數據類型選擇合適的處理程序進行攔截定義。
這裏我們假設是一個對象或者數組,此時 targetTypeMap
函數返回 1,也就是使用上面定義的 mutableHandlers
進行處理。
const mutableHandlers = {
get: createGetter(),
set: createSetter(),
deleteProperty,
has,
ownKeys
};
function createGetter(isReadonly2 = false, shallow = false) {
return function get(target, key, receiver) {
if (key === "__v_isReactive" /* IS_REACTIVE */) {
return !isReadonly2;
} else if (key === "__v_isReadonly" /* IS_READONLY */) {
return isReadonly2;
} else if (key === "__v_isShallow" /* IS_SHALLOW */) {
return shallow;
} else if (key === "__v_raw" /* RAW */ && receiver === (isReadonly2 ? shallow ? shallowReadonlyMap : readonlyMap : shallow ? shallowReactiveMap : reactiveMap).get(target)) {
return target;
}
const targetIsArray = isArray(target);
if (!isReadonly2 && targetIsArray && hasOwn(arrayInstrumentations, key)) {
return Reflect.get(arrayInstrumentations, key, receiver);
}
const res = Reflect.get(target, key, receiver);
if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
return res;
}
if (!isReadonly2) {
track(target, "get" /* GET */, key);
}
if (shallow) {
return res;
}
if (isRef(res)) {
return targetIsArray && isIntegerKey(key) ? res : res.value;
}
if (isObject(res)) {
return isReadonly2 ? readonly(res) : reactive(res);
}
return res;
};
}
function has(target, key) {
const result = Reflect.has(target, key);
if (!isSymbol(key) || !builtInSymbols.has(key)) {
track(target, "has" /* HAS */, key);
}
return result;
}
function ownKeys(target) {
track(target, "iterate" /* ITERATE */, isArray(target) ? "length" : ITERATE_KEY);
return Reflect.ownKeys(target);
}
function createSetter(shallow = false) {
return function set(target, key, value, receiver) {
let oldValue = target[key];
if (isReadonly(oldValue) && isRef(oldValue) && !isRef(value)) {
return false;
}
if (!shallow) {
if (!isShallow(value) && !isReadonly(value)) {
oldValue = toRaw(oldValue);
value = toRaw(value);
}
if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
oldValue.value = value;
return true;
}
} else {
}
const hadKey = isArray(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwn(target, key);
const result = Reflect.set(target, key, value, receiver);
if (target === toRaw(receiver)) {
if (!hadKey) {
trigger(target, "add" /* ADD */, key, value);
} else if (hasChanged(value, oldValue)) {
trigger(target, "set" /* SET */, key, value, oldValue);
}
}
return result;
};
}
function deleteProperty(target, key) {
const hadKey = hasOwn(target, key);
const oldValue = target[key];
const result = Reflect.deleteProperty(target, key);
if (result && hadKey) {
trigger(target, "delete" /* DELETE */, key, void 0, oldValue);
}
return result;
}
在 mutableHandlers
定義了 get, set, deleteProperty, has, ownKeys
五個方法的攔截操作,其中 set、deleteProperty
屬於數據修改操作,會改變原有數據,所以在這兩個方法中 主要進行 “更新消息派發”,也就是 trigger
方法,而剩下的 get、has、ownKeys
三個方法則只會訪問數據的值,不改變原數據,所以這三個方法中 主要進行 “數據依賴收集”, 也就是 track
方法。
依賴收集
在數據讀取和更新時定義好了依賴收集和更新派發事件的執行時機之後,我們再回頭看一下 Vue 3 的依賴收集系統。
通過上文我們也知道在 Vue 2 中是給每個對象增加一個
Dep
實例來保存每個對象所關聯的Watcher
數組,然後更新時遍歷執行,那麼 Vue 3 是不是也是這麼操作的呢?
答案是沒有的。
因爲 Vue 3 採用的 Proxy
可以直接攔截對象的訪問和更新,而無需像 Object.defineProperty
一樣單獨爲每個屬性定義攔截,所以 一個引用類型數據我們只需要收集一個依賴即可,通過一個全局變量進行所有的依賴數據的依賴管理。
const targetMap = new WeakMap();
let shouldTrack = true;
let activeEffect = null;
function track(target, type, key) {
if (shouldTrack && activeEffect) {
let depsMap = targetMap.get(target)
if (!depsMap) targetMap.set(target, (depsMap = new Map()))
let dep = depsMap.get(key)
if (!dep) depsMap.set(key, (dep = createDep()))
const eventInfo = { effect: activeEffect, target, type, key }
trackEffects(dep, eventInfo)
}
}
function trackEffects(dep, debuggerEventExtraInfo) {
let shouldTrack2 = false;
if (effectTrackDepth <= maxMarkerBits) {
if (!newTracked(dep)) {
dep.n |= trackOpBit;
shouldTrack2 = !wasTracked(dep);
}
} else {
shouldTrack2 = !dep.has(activeEffect);
}
if (shouldTrack) {
dep.add(activeEffect)
activeEffect.deps.push(dep)
}
}
以上代碼就是依賴收集部分的大致邏輯,通過
track
函數,在shouldTrack
標識爲true
且存在激活副作用函數activeEffect
時,會在 全局的依賴管理中心targetMap
中插入該數據,併爲該數據添加一個Set
格式的dep
依賴,將激活狀態的副作用activeEffect
插入到dep
中
收集到的依賴內容如下:
副作用定義
Vue 3 中的副作用函數稱爲 effect
,官方也提供了該函數的直接調用,在 執行時能自動收集相關依賴。其定義如下:
function effect(fn, options) {
if (fn.effect) {
fn = fn.effect.fn;
}
const _effect = new ReactiveEffect(fn);
if (options) {
extend(_effect, options);
if (options.scope)
recordEffectScope(_effect, options.scope);
}
if (!options || !options.lazy) {
_effect.run();
}
const runner = _effect.run.bind(_effect);
runner.effect = _effect;
return runner;
}
它的本質呢其實就是 返回一個綁定了當前作用域並且在內部封裝過其他操作的 fn
函數的新函數(render
最終也會封裝成一個 副作用函數),effect
函數內部的核心邏輯還是創建一個 ReactiveEffect
實例。
那麼我們接着進入 ReactiveEffect
內部看看。
class ReactiveEffect {
constructor(fn, scheduler) {
this.fn = fn;
this.scheduler = scheduler;
this.active = true;
this.deps = [];
}
run() {
if (!this.active) {
return this.fn();
}
shouldTrack = true;
activeEffect = this;
const result = this.fn();
shouldTrack = false;
activeEffect = undefined;
return result;
}
stop() {
if (this.active) {
cleanupEffect(this);
if (this.onStop) {
this.onStop();
}
this.active = false;
}
}
}
function cleanupEffect(effect) {
effect.deps.forEach((dep) => {
dep.delete(effect);
});
effect.deps.length = 0;
}
這裏對部分代碼進行了簡化,保留了依賴收集和停止處理的相關邏輯。
在上文的 effect
函數執行時,會調用 ReactiveEffect
實例的 run
方法,執行副作用定義的函數 fn
並將當前的 activeEffect
設置爲該實例。
fn
函數執行時,遇到定義的響應式變量就會觸發相應的 get
操作,此時就會將當前的副作用實例(也就是 ReactiveEffect
實例)插入到該變量的 dep
數據,完成依賴收集過程。
並且 Vue 3 中的 ReactiveEffect
提供了一個 stop
方法,在調用時會清空該副作用相關的所有數據依賴,並且將 this.active
設置爲 false
,避免後續執行時的依賴收集。
派發更新
當數據更新時,即觸發 set、deleteProperty
等攔截時,就需要觸發相應的依賴函數執行(也就是視圖更新、watch
執行等),其核心就是 遍歷 targetMap
中該數據對應的 dep
中的每個副作用函數執行。
大致代碼如下:
function trigger(target, type, key, newValue, oldValue, oldTarget) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
let deps = [];
if (type === "clear") {
deps = [...depsMap.values()];
} else if (key === "length" && isArray(target)) {
depsMap.forEach((dep, key2) => ((key2 === "length" || key2 >= newValue) && deps.push(dep)));
} else {
if (key !== void 0) {
deps.push(depsMap.get(key));
}
}
const effects = [];
for (const dep of deps) {
dep && effects.push(...dep);
}
triggerEffects(createDep(effects));
}
function triggerEffects(dep) {
const effects = isArray(dep) ? dep : [...dep];
for (const effect of effects) {
triggerEffect(effect)
}
}
function triggerEffect(effect) {
if (effect !== activeEffect || effect.allowRecurse) {
effect.scheduler ? effect.scheduler() : effect.run();
}
}
簡單理解就是:
-
1. 通過
targetMap.get(target)
獲取到該數據對應的depsMap
依賴數據 -
2. 在
deps
數組中插入這個操作key
對應的副作用函數,也就是deps.push(depsMap.get(key))
-
3. 通過
createDep
去除重複effect
副作用,調用triggerEffect
-
4. 遍歷
effects
並執行副作用函數
小結一下
對比起 Vue 2 通過 Object.defineProperty
在數據定義時爲指定具體屬性添加 getter/setter
攔截、爲每個對象添加 deps
依賴數組的響應式系統實現方式,Vue 3 採用 ES6
的 Proxy
結合 WeakMap、WeakSet
,通過代理在運行時攔截對象的基本操作來收集依賴並全局管理,在性能上就得到了很大的提升,也爲開發者帶來了更好的開發體驗。
並且通過全局的依賴管理模式,也讓 Vue 3 的組合式 API 和 hooks
開發模式得以實現,在進行大型項目開發時,公共邏輯拆分也會變得更加清晰。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/b580R42jSU4dfPwgpRrSlw