一步一步實現 Vue 3 Reactivity
Vue 3 中的響應式原理可謂是非常之重要,通過學習 Vue3 的響應式原理,不僅能讓我們學習到 Vue.js 的一些設計模式和思想,還能**「幫助我們提高項目開發效率和代碼調試能力」**。
在這之前,我也寫了一篇《探索 Vue.js 響應式原理》 ,主要介紹 Vue 2 響應式的原理,這篇補上 Vue 3 的。
於是最近在 Vue Mastery 上重新學習 Vue3 Reactivity 的知識,這次收穫更大。本文將帶大家從頭開始學習如何實現簡單版 Vue 3 響應式,幫助大家瞭解其核心,後面閱讀 Vue 3 響應式相關的源碼能夠更加得心應手。
一、Vue 3 響應式使用
1. Vue 3 中的使用
當我們在學習 Vue 3 的時候,可以通過一個簡單示例,看看什麼是 Vue 3 中的響應式:
<!-- HTML 內容 -->
<div id="app">
<div>Price: {{price}}</div>
<div>Total: {{price * quantity}}</div>
<div>getTotal: {{getTotal}}</div>
</div>
const app = Vue.createApp({ // ① 創建 APP 實例
data() {
return {
price: 10,
quantity: 2
}
},
computed: {
getTotal() {
return this.price * this.quantity * 1.1
}
}
})
app.mount('#app') // ② 掛載 APP 實例
通過創建 APP 實例和掛載 APP 實例即可,這時可以看到頁面中分別顯示對應數值:
當我們修改 price
或 quantity
值的時候,頁面上引用它們的地方,內容也能正常展示變化後的結果。這時,我們會好奇爲何數據發生變化後,相關的數據也會跟着變化,那麼我們接着往下看。
2. 實現單個值的響應式
在普通 JS 代碼執行中,並不會有響應式變化,比如在控制檯執行下面代碼:
let price = 10, quantity = 2;
const total = price * quantity;
console.log(`total: ${total}`); // total: 20
price = 20;
console.log(`total: ${total}`); // total: 20
從這可以看出,在修改 price
變量的值後, total
的值並沒有發生改變。
那麼如何修改上面代碼,讓 total
能夠自動更新呢?我們其實可以將修改 total
值的方法保存起來,等到與 total
值相關的變量(如 price
或 quantity
變量的值)發生變化時,觸發該方法,更新 total
即可。我們可以這麼實現:
let price = 10, quantity = 2, total = 0;
const dep = new Set(); // ①
const effect = () => { total = price * quantity };
const track = () => { dep.add(effect) }; // ②
const trigger = () => { dep.forEach( effect => effect() )}; // ③
track();
console.log(`total: ${total}`); // total: 0
trigger();
console.log(`total: ${total}`); // total: 20
price = 20;
trigger();
console.log(`total: ${total}`); // total: 40
上面代碼通過 3 個步驟,實現對 total
數據進行響應式變化:
① 初始化一個 Set
類型的 dep
變量,用來存放需要執行的副作用( effect
函數),這邊是修改 total
值的方法;
② 創建 track()
函數,用來將需要執行的副作用保存到 dep
變量中(也稱收集副作用);
③ 創建 trigger()
函數,用來執行 dep
變量中的所有副作用;
在每次修改 price
或 quantity
後,調用 trigger()
函數執行所有副作用後, total
值將自動更新爲最新值。
(圖片來源:Vue Mastery)
3. 實現單個對象的響應式
通常,「我們的對象具有多個屬性,並且每個屬性都需要自己的 dep
。我們如何存儲這些?比如:」
let product = { price: 10, quantity: 2 };
從前面介紹我們知道,我們將所有副作用保存在一個 Set
集合中,而該集合不會有重複項,這裏我們引入一個 Map
類型集合(即 depsMap
),其 key
爲對象的屬性(如:price
屬性), value
爲前面保存副作用的 Set
集合(如:dep
對象),大致結構如下圖:
實現代碼:
let product = { price: 10, quantity: 2 }, total = 0;
const depsMap = new Map(); // ①
const effect = () => { total = product.price * product.quantity };
const track = key => { // ②
let dep = depsMap.get(key);
if(!dep) {
depsMap.set(key, (dep = new Set()));
}
dep.add(effect);
}
const trigger = key => { // ③
let dep = depsMap.get(key);
if(dep) {
dep.forEach( effect => effect() );
}
};
track('price');
console.log(`total: ${total}`); // total: 0
effect();
console.log(`total: ${total}`); // total: 20
product.price = 20;
trigger('price');
console.log(`total: ${total}`); // total: 40
上面代碼通過 3 個步驟,實現對 total
數據進行響應式變化:
① 初始化一個 Map
類型的 depsMap
變量,用來保存每個需要響應式變化的對象屬性(key
爲對象的屬性, value
爲前面 Set
集合);
② 創建 track()
函數,用來將需要執行的副作用保存到 depsMap
變量中對應的對象屬性下(也稱收集副作用);
③ 創建 trigger()
函數,用來執行 dep
變量中指定對象屬性的所有副作用;
這樣就實現監聽對象的響應式變化,在 product
對象中的屬性值發生變化, total
值也會跟着更新。
4. 實現多個對象的響應式
如果我們有多個響應式數據,比如同時需要觀察對象 a
和對象 b
的數據,那麼又要如何跟蹤每個響應變化的對象?
這裏我們引入一個 WeakMap 類型的對象,將需要觀察的對象作爲 key
,值爲前面用來保存對象屬性的 Map 變量。代碼如下:
let product = { price: 10, quantity: 2 }, total = 0;
const targetMap = new WeakMap(); // ① 初始化 targetMap,保存觀察對象
const effect = () => { total = product.price * product.quantity };
const track = (target, key) => { // ② 收集依賴
let depsMap = targetMap.get(target);
if(!depsMap){
targetMap.set(target, (depsMap = new Map()));
}
let dep = depsMap.get(key);
if(!dep) {
depsMap.set(key, (dep = new Set()));
}
dep.add(effect);
}
const trigger = (target, key) => { // ③ 執行指定對象的指定屬性的所有副作用
const depsMap = targetMap.get(target);
if(!depsMap) return;
let dep = depsMap.get(key);
if(dep) {
dep.forEach( effect => effect() );
}
};
track(product, 'price');
console.log(`total: ${total}`); // total: 0
effect();
console.log(`total: ${total}`); // total: 20
product.price = 20;
trigger(product, 'price');
console.log(`total: ${total}`); // total: 40
上面代碼通過 3 個步驟,實現對 total
數據進行響應式變化:
① 初始化一個 WeakMap
類型的 targetMap
變量,用來要觀察每個響應式對象;
② 創建 track()
函數,用來將需要執行的副作用保存到指定對象( target
)的依賴中(也稱收集副作用);
③ 創建 trigger()
函數,用來執行指定對象( target
)中指定屬性( key
)的所有副作用;
這樣就實現監聽對象的響應式變化,在 product
對象中的屬性值發生變化, total
值也會跟着更新。
大致流程如下圖:
二、Proxy 和 Reflect
在上一節內容中,介紹瞭如何在數據發生變化後,自動更新數據,但存在的問題是,每次需要手動通過觸發 track()
函數蒐集依賴,通過 trigger()
函數執行所有副作用,達到數據更新目的。
這一節將來解決這個問題,實現這兩個函數自動調用。
1. 如何實現自動操作
這裏我們引入 JS 對象訪問器的概念,解決辦法如下:
-
在讀取(GET 操作)數據時,自動執行
track()
函數自動收集依賴; -
在修改(SET 操作)數據時,自動執行
trigger()
函數執行所有副作用;
那麼如何攔截 GET 和 SET 操作?接下來看看 Vue2 和 Vue3 是如何實現的:
-
在 Vue2 中,使用 ES5 的
Object.defineProperty()
函數實現; -
在 Vue3 中,使用 ES6 的
Proxy
和Reflect
API 實現;
需要注意的是:Vue3 使用的 Proxy
和 Reflect
API 並不支持 IE。
Object.defineProperty()
函數這邊就不多做介紹,可以閱讀文檔,下文將主要介紹 Proxy
和 Reflect
API。
2. 如何使用 Reflect
通常我們有三種方法讀取一個對象的屬性:
-
使用
.
操作符:leo.name
; -
使用
[]
:leo['name']
; -
使用
Reflect
API:Reflect.get(leo, 'name')
。
這三種方式輸出結果相同。
3. 如何使用 Proxy
Proxy 對象用於創建一個對象的代理,從而實現基本操作的攔截和自定義(如屬性查找、賦值、枚舉、函數調用等)。語法如下:
const p = new Proxy(target, handler)
參數如下:
-
target : 要使用 Proxy 包裝的目標對象(可以是任何類型的對象,包括原生數組,函數,甚至另一個代理)。
-
handler : 一個通常以函數作爲屬性的對象,各屬性中的函數分別定義了在執行各種操作時代理
p
的行爲。
我們通過官方文檔,體驗一下 Proxy API:
let product = { price: 10, quantity: 2 };
let proxiedProduct = new Proxy(product, {
get(target, key){
console.log('正在讀取的數據:',key);
return target[key];
}
})
console.log(proxiedProduct.price);
// 正在讀取的數據:price
// 10
這樣就保證我們每次在讀取 proxiedProduct.price
都會執行到其中代理的 get 處理函數。其過程如下:
然後結合 Reflect 使用,只需修改 get 函數:
get(target, key, receiver){
console.log('正在讀取的數據:',key);
return Reflect.get(target, key, receiver);
}
輸出結果還是一樣。
接下來增加 set 函數,來攔截對象的修改操作:
let product = { price: 10, quantity: 2 };
let proxiedProduct = new Proxy(product, {
get(target, key, receiver){
console.log('正在讀取的數據:',key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver){
console.log('正在修改的數據:', key, ',值爲:', value);
return Reflect.set(target, key, value, receiver);
}
})
proxiedProduct.price = 20;
console.log(proxiedProduct.price);
// 正在修改的數據:price ,值爲:20
// 正在讀取的數據:price
// 20
這樣便完成 get 和 set 函數來攔截對象的讀取和修改的操作。爲了方便對比 Vue 3 源碼,我們將上面代碼抽象一層,使它看起來更像 Vue3 源碼:
function reactive(target){
const handler = { // ① 封裝統一處理函數對象
get(target, key, receiver){
console.log('正在讀取的數據:',key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver){
console.log('正在修改的數據:', key, ',值爲:', value);
return Reflect.set(target, key, value, receiver);
}
}
return new Proxy(target, handler); // ② 統一調用 Proxy API
}
let product = reactive({price: 10, quantity: 2}); // ③ 將對象轉換爲響應式對象
product.price = 20;
console.log(product.price);
// 正在修改的數據:price ,值爲:20
// 正在讀取的數據:price
// 20
這樣輸出結果仍然不變。
4. 修改 track 和 trigger 函數
通過上面代碼,我們已經實現一個簡單 reactive()
函數,用來**「將普通對象轉換爲響應式對象」**。但是還缺少自動執行 track()
函數和 trigger()
函數,接下來修改上面代碼:
const targetMap = new WeakMap();
let total = 0;
const effect = () => { total = product.price * product.quantity };
const track = (target, key) => {
let depsMap = targetMap.get(target);
if(!depsMap){
targetMap.set(target, (depsMap = new Map()));
}
let dep = depsMap.get(key);
if(!dep) {
depsMap.set(key, (dep = new Set()));
}
dep.add(effect);
}
const trigger = (target, key) => {
const depsMap = targetMap.get(target);
if(!depsMap) return;
let dep = depsMap.get(key);
if(dep) {
dep.forEach( effect => effect() );
}
};
const reactive = (target) => {
const handler = {
get(target, key, receiver){
console.log('正在讀取的數據:',key);
const result = Reflect.get(target, key, receiver);
track(target, key); // 自動調用 track 方法收集依賴
return result;
},
set(target, key, value, receiver){
console.log('正在修改的數據:', key, ',值爲:', value);
const oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
if(oldValue != result){
trigger(target, key); // 自動調用 trigger 方法執行依賴
}
return result;
}
}
return new Proxy(target, handler);
}
let product = reactive({price: 10, quantity: 2});
effect();
console.log(total);
product.price = 20;
console.log(total);
// 正在讀取的數據:price
// 正在讀取的數據:quantity
// 20
// 正在修改的數據:price ,值爲:20
// 正在讀取的數據:price
// 正在讀取的數據:quantity
// 40
三、activeEffect 和 ref
在上一節代碼中,還存在一個問題:track
函數中的依賴( effect
函數)是外部定義的,當依賴發生變化, track
函數收集依賴時都要手動修改其依賴的方法名。
比如現在的依賴爲 foo
函數,就要修改 track
函數的邏輯,可能是這樣:
const foo = () => { /**/ };
const track = (target, key) => { // ②
// ...
dep.add(foo);
}
那麼如何解決這個問題呢?
1. 引入 activeEffect 變量
接下來引入 activeEffect
變量,來保存當前運行的 effect 函數。
let activeEffect = null;
const effect = eff => {
activeEffect = eff; // 1. 將 eff 函數賦值給 activeEffect
activeEffect(); // 2. 執行 activeEffect
activeEffect = null;// 3. 重置 activeEffect
}
然後在 track
函數中將 activeEffect
變量作爲依賴:
const track = (target, key) => {
if (activeEffect) { // 1. 判斷當前是否有 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 = new Set()));
}
dep.add(activeEffect); // 2. 添加 activeEffect 依賴
}
}
使用方式修改爲:
effect(() => {
total = product.price * product.quantity
});
這樣就可以解決手動修改依賴的問題,這也是 Vue3 解決該問題的方法。完善一下測試代碼後,如下:
const targetMap = new WeakMap();
let activeEffect = null; // 引入 activeEffect 變量
const effect = eff => {
activeEffect = eff; // 1. 將副作用賦值給 activeEffect
activeEffect(); // 2. 執行 activeEffect
activeEffect = null;// 3. 重置 activeEffect
}
const track = (target, key) => {
if (activeEffect) { // 1. 判斷當前是否有 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 = new Set()));
}
dep.add(activeEffect); // 2. 添加 activeEffect 依賴
}
}
const trigger = (target, key) => {
const depsMap = targetMap.get(target);
if (!depsMap) return;
let dep = depsMap.get(key);
if (dep) {
dep.forEach(effect => effect());
}
};
const reactive = (target) => {
const handler = {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver);
track(target, key);
return result;
},
set(target, key, value, receiver) {
const oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
if (oldValue != result) {
trigger(target, key);
}
return result;
}
}
return new Proxy(target, handler);
}
let product = reactive({ price: 10, quantity: 2 });
let total = 0, salePrice = 0;
// 修改 effect 使用方式,將副作用作爲參數傳給 effect 方法
effect(() => {
total = product.price * product.quantity
});
effect(() => {
salePrice = product.price * 0.9
});
console.log(total, salePrice); // 20 9
product.quantity = 5;
console.log(total, salePrice); // 50 9
product.price = 20;
console.log(total, salePrice); // 100 18
思考一下,如果把第一個 effect
函數中 product.price
換成 salePrice
會如何:
effect(() => {
total = salePrice * product.quantity
});
effect(() => {
salePrice = product.price * 0.9
});
console.log(total, salePrice); // 0 9
product.quantity = 5;
console.log(total, salePrice); // 45 9
product.price = 20;
console.log(total, salePrice); // 45 18
得到的結果完全不同,因爲 salePrice
並不是響應式變化,而是需要調用第二個 effect
函數纔會變化,也就是 product.price
變量值發生變化。
❝
代碼地址:https://github.com/Code-Pop/vue-3-reactivity/blob/master/05-activeEffect.js
❞
2. 引入 ref 方法
熟悉 Vue3 Composition API 的朋友可能會想到 Ref,它接收一個值,並返回一個響應式可變的 Ref 對象,其值可以通過 value
屬性獲取。
❝
ref:接受一個內部值並返回一個響應式且可變的 ref 對象。ref 對象具有指向內部值的單個 property .value。
❞
官網的使用示例如下:
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
我們有 2 種方法實現 ref 函數:
- 「使用
rective
函數」
const ref = intialValue => reactive({value: intialValue});
這樣是可以的,雖然 Vue3 不是這麼實現。
- 「使用對象的屬性訪問器(計算屬性)」
屬性方式去包括:getter 和 setter。
const ref = raw => {
const r = {
get value(){
track(r, 'value');
return raw;
},
set value(newVal){
raw = newVal;
trigger(r, 'value');
}
}
return r;
}
使用方式如下:
let product = reactive({ price: 10, quantity: 2 });
let total = 0, salePrice = ref(0);
effect(() => {
salePrice.value = product.price * 0.9
});
effect(() => {
total = salePrice.value * product.quantity
});
console.log(total, salePrice.value); // 18 9
product.quantity = 5;
console.log(total, salePrice.value); // 45 9
product.price = 20;
console.log(total, salePrice.value); // 90 18
在 Vue3 中 ref 實現的核心也是如此。
❝
代碼地址:https://github.com/Code-Pop/vue-3-reactivity/blob/master/06-ref.js
❞
四、實現簡易 Computed 方法
用過 Vue 的同學可能會好奇,上面的 salePrice
和 total
變量爲什麼不使用 computed
方法呢?
沒錯,這個可以的,接下來一起實現個簡單的 computed
方法。
const computed = getter => {
let result = ref();
effect(() => result.value = getter());
return result;
}
let product = reactive({ price: 10, quantity: 2 });
let salePrice = computed(() => {
return product.price * 0.9;
})
let total = computed(() => {
return salePrice.value * product.quantity;
})
console.log(total.value, salePrice.value);
product.quantity = 5;
console.log(total.value, salePrice.value);
product.price = 20;
console.log(total.value, salePrice.value);
這裏我們將一個函數作爲參數傳入 computed
方法,computed
方法內通過 ref
方法構建一個 ref 對象,然後通過 effct
方法,將 getter
方法返回值作爲 computed
方法的返回值。
這樣我們實現了個簡單的 computed
方法,執行效果和前面一樣。
五、源碼學習建議
1. 構建 reactivity.cjs.js
這一節介紹如何去從 Vue 3 倉庫打包一個 Reactivity 包來學習和使用。
準備流程如下:
- 從 Vue 3 倉庫下載最新 Vue3 源碼;
git clone https://github.com/vuejs/vue-next.git
- 安裝依賴:
yarn install
- 構建 Reactivity 代碼:
yarn build reactivity
- 複製 reactivity.cjs.js 到你的學習 demo 目錄:
上一步構建完的內容,會保存在 packages/reactivity/dist
目錄下,我們只要在自己的學習 demo 中引入該目錄的 reactivity.cjs.js 文件即可。
- 學習 demo 中引入:
const { reactive, computed, effect } = require("./reactivity.cjs.js");
2. Vue3 Reactivity 文件目錄
在源碼的 packages/reactivity/src
目錄下,有以下幾個主要文件:
-
effect.ts:用來定義
effect
/track
/trigger
; -
baseHandlers.ts:定義 Proxy 處理器( get 和 set);
-
reactive.ts:定義
reactive
方法並創建 ES6 Proxy; -
ref.ts:定義 reactive 的 ref 使用的對象訪問器;
-
computed.ts:定義計算屬性的方法;
六、總結
本文帶大家從頭開始學習如何實現簡單版 Vue 3 響應式,實現了 Vue3 Reactivity 中的核心方法( effect
/ track
/ trigger
/ computed
/ref
等方法),幫助大家瞭解其核心,「提高項目開發效率和代碼調試能力」。
參考文章
- Vue Mastery
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/HPDeFJRX-MKo7-VBFOMJXQ