實現 vue3 響應式系統核心 - MVP 模型
簡介
2023 年 12 月 31 日,vue2 已經停止維護了。你還不會 Vue3 的源碼麼?
手把手帶你實現一個 vue3 響應式系統,你將獲得:
-
Vue3 的響應式的數據結構是什麼樣?爲什麼是這樣?如何形成的?
-
Proxy 爲什麼要配合 Reflect 使用?如果不配合會有什麼問題?
-
Map 與 WeakMap 的區別
-
響應式數據以及副作用函數
-
響應式系統基本實現
-
依賴收集
-
派發更新
-
依賴清理
-
支持嵌套
-
實現執行調度
-
實現 computed
-
實現 watch
-
TDD 測試驅動開發
-
重構
-
vitest 的使用
-
如何使用 ChatGPT 編寫單元測試
-
excalidraw 畫圖工具
代碼地址: https://github.com/SuYxh/share-vue3
代碼並沒有按照源碼的方式去進行組織,目的是學習、實現 vue3 響應式系統的核心,用最少的代碼去實現最核心的能力,減少我們的學習負擔,並且所有的流程都會有配套的圖片,圖文 + 代碼,讓我們學習更加輕鬆、快樂。
每一個功能都會提交一個 commit
,大家可以切換查看,也順便練習練習 git 的使用。
如果想看和 vue 源碼結構類似的請看: https://github.com/SuYxh/mini-vue3
環境搭建
1、使用 vite 創建一個空模板,
pnpm init vite
2、然後安裝 vitest
pnpm add -D vitest
爲什麼要安裝 vitest?
❝
測試驅動開發 (TDD) 是一種漸進的開發方法,它結合了測試優先的開發,即在編寫足夠的產品代碼以完成測試和重構之前編寫測試。目標是編寫有效的乾淨代碼
配置測試命令:
"scripts": {
"test": "vitest"
},
3、安裝 vscode 插件 Vitest Runner
4、編寫測試代碼
新建一個 main.js ,然後編寫:
export function add(a: number, b: number) {
return a + b;
}
創建 /src/__tests__/main.spec.ts
測試文件,寫入:
❝
測試文件的文件名最好包含 spec
import { describe, it, expect } from 'vitest';
import { add } from '../main';
describe('add function', () => {
it('adds two numbers', () => {
expect(add(1, 2)).toBe(3);
});
});
點擊運行單測:
注意: 我這邊因爲還安裝了 jest runner, 所有此處會有 2 對
運行結果:
到此,環境搭建結束!
相關代碼在 commit: (3af5e60)環境搭建
,git checkout 3af5e60
即可查看。
響應式數據以及副作用函數
副作用函數指的是會產生副作用的函數,如下:
// 全局變量
let val = 1
function effect() {
// 修改全局變量,產生副作用
val = 2
}
當 effect
函數執行時,它會修改 val
的值,但除了 effect
函數之外的任何函數都可以修改 val
的值。也就是說,effect
函數的執行會直接或間接影響其他函數的執行,這時我們說 effect
函數產生了副作用。
假設在一個副作用函數中讀取了某個對象的屬性:
const obj = { age: 18 }
function effect() {
console.log(obj.age)
}
當 obj.age
的值發生變化時,我們希望副作用函數 effect
會重新執行,如果能實現這個目標,那麼對象 obj
就是響應式數據。但很明顯,以上面的代碼來看,我們還做不到這一點,因爲 obj
是一個普通對象,當我們修改它的值時,除了值本身發生變化之外,不會有任何其他反應。
響應式系統基本實現
如何將 obj
變成一個響應式對象呢?大家肯定都想到了 Object.defineProperty
和 Proxy
。
-
當副作用函數
effect
執行時,會觸發字段obj.age
的讀取操作; -
當修改
obj.age
的值時,會觸發字段obj.age
的設置操作。
我們可以把副作用函數 effect
存儲到一個 “桶” 裏,如下圖所示。
接着,當設置 obj.age
時,再把副作用函數 effect
從 “桶” 裏取出並執行即可。
代碼實現
// 存儲副作用函數的桶
const bucket = new Set();
// 原始數據
const data = { name: 'dahuang', age: 18 };
// 對原始數據的代理
const obj = new Proxy(data, {
// 攔截讀取操作
get(target, key) {
// 將副作用函數 effect 添加到存儲副作用函數的桶中
bucket.add(effect);
// 返回屬性值
return target[key];
},
// 攔截設置操作
set(target, key, newVal) {
target[key] = newVal;
bucket.forEach(fn => fn());
return true;
}
});
// 以下爲測試代碼
// 副作用函數
function effect() {
console.log(obj.age);
}
// 執行副作用函數,觸發讀取
effect();
// 1 秒後修改響應式數據
setTimeout(() => {
obj.age = 23;
}, 1000);
在瀏覽器中直接運行,我們可以得到期望的效果。
但目前的實現還存在一些問題:
-
直接通過名字
effect
來獲取副作用函數,如果名稱變了怎麼辦? -
當我們在修改
name
的時候,副作用函數依然會執行
後續會逐步解決這些問題,這裏大家只需要理解響應式數據的基本實現和工作原理即可。
相關代碼在 commit: (5fc5489)響應式系統基本實現
,git checkout 5fc5489
即可查看。
完善的響應系統
解決硬編碼副作用函數名字問題
爲了實現這一點,我們需要提供一個用來註冊副作用函數的機制,如以下代碼所示:
// 當前激活的副作用函數
let activeEffect = null;
// 定義副作用函數
export function effect(fn) {
// 設置當前激活的副作用函數
activeEffect = fn;
// 執行副作用函數
fn();
// 重置當前激活的副作用函數
activeEffect = null;
}
首先,定義了一個全局變量 activeEffect
,初始值是 null
,它的作用是存儲被註冊的副作用函數。接着重新定義了 effect
函數,它變成了一個用來註冊副作用函數的函數,effect
函數接收一個參數 fn
,即要註冊的副作用函數。我們可以按照如下所示的方式使用effect
函數:
effect(() => {
console.log(obj.age);
})
如上面的代碼所示,由於副作用函數已經存儲到了activeEffect
中,所以在get
攔截函數內應該把 activeEffect
收集到 “桶” 中,這樣響應系統就不依賴副作用函數的名字了。
get(target, key) {
// 將 activeEffect 添加到存儲副作用函數的桶中
if (activeEffect) {
bucket.add(activeEffect);
}
// 返回屬性值
return target[key];
},
相關代碼在 commit: (80c9898)解決硬編碼副作用函數名字問題
,git checkout 80c9898
即可查看。
解決副作用函數會執行多次的問題
effect(() => {
console.log(obj.age);
})
setTimeout(() => {
obj.name = 'zhuanzhuan'
}, 2000);
在匿名副作用函數內並沒有讀取 obj.name
屬性的值,所以理論上,字段 obj.name
並沒有與副作用建立響應聯繫,因此, 修改 obj.name
屬性的值不應該觸發匿名副作用函數重新執行。但如果我們執行上述這段代碼就會發現,定時器到時後,匿名副作用函數卻重新執行了,這是不正確的。爲了解決這個問題,我們需要重新設計 “桶” 的數據結構。
原因
沒有在副作用函數與被操作的目標字段之間建立明確的聯繫。之前我們使用一個 Set 數據結構作爲存儲副作用函數的 “桶”。無論讀取的是哪一個屬性,都會把副作用函數收集到“桶” 裏;當設置屬性時,無論設置的是哪一個屬性,也都會把 “桶” 裏的副作用函數取出並執行。
解決
重新設計 “桶” 的數據結構,在副作用函數與被操作的字段之間建立聯繫
“桶” 結構設計
我們需要先仔細觀察下面的代碼:
effect(function effectFn() {
console.log(obj.age);
})
在這段代碼中存在三個角色:
-
被操作(讀取)的代理對象 obj
-
被操作(讀取)的字段名 age
-
使用 effect 函數註冊的副作用函數 effectFn
如果用 target
來表示一個代理對象所代理的原始對象,用 key
來表示被操作的字段名,用effectFn
來表示被註冊的副作用函數,那麼可以爲這三個角色建立如下關係:
這是一種樹型結構,下面舉幾個例子來對其進行補充說明。
如果有兩個副作用函數同時讀取同一個對象的屬性值:
effect(function effectFn1() {
console.log(obj.age);
})
effect(function effectFn2() {
console.log(obj.age);
})
那麼關係如下:
如果一個副作用函數中讀取了同一個對象的兩個不同屬性
effect(function effectFn1() {
console.log(obj.age);
console.log(obj.name);
})
那麼關係如下:
如果在不同的副作用函數中讀取了兩個不同對象的不同屬性:
effect(function effectFn1() {
console.log(obj1.age1);
})
effect(function effectFn2() {
console.log(obj2.age2);
})
那麼關係如下:
其實就是一個樹型數據結構。這個聯繫建立起來之後,如果我們設置了obj2.text2
的值,就只會導致 effectFn2
函數重新執行,並不會導致 effectFn1
函數重新執行,之前的問題就解決了。
代碼實現
const bucket = new WeakMap();
const obj = new Proxy(data, {
// 攔截讀取操作
get(target, key) {
// 沒有 activeEffect,直接返回
if (!activeEffect) return
// 根據 target 從“桶”中取得 depsMap,它也是一個 Map 類型:key --> effects
let depsMap = bucket.get(target);
// 如果不存在 depsMap,那麼新建一個 Map 並與 target 關聯
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 再根據 key 從 depsMap 中取得 deps,它是一個 Set 類型,
// 裏面存儲着所有與當前 key 相關聯的副作用函數:effects
let deps = depsMap.get(key);
// 如果 deps 不存在,同樣新建一個 Set 並與 key 關聯
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 最後將當前激活的副作用函數添加到“桶”裏
deps.add(activeEffect);
// 返回屬性值
return target[key];
},
// 攔截設置操作
set(target, key, newVal) {
// 設置屬性值
target[key] = newVal;
// 根據 target 從桶中取得 depsMap,它是 key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根據 key 取得所有副作用函數 effects
const effects = depsMap.get(key);
// 執行副作用函數
effects && effects.forEach(fn => fn());
return true
}
});
從這段代碼可以看出構建數據結構的方式,我們分別使用了 WeakMap、Map 和 Set:
WeakMap 由 target --> Map 構成;
Map 由 key --> Set 構成。
其中 WeakMap 的鍵是原始對象 target,WeakMap 的值是一個 Map 實例,而 Map 的鍵是原始對象 target 的 key ,Map 的值是一個由副作用函數組成的 Set 。它們的關係下圖所示:
我們把上圖中的 Set 數據結構所存儲的副作用函數集合稱爲 key 的依賴集合。
單元測試
爲什麼這裏纔開始寫單元測試?
先來看看我們寫單元測試的目的:
-
驗證代碼功能:確保每個組件或模塊按預期工作。單元測試通常針對特定功能或代碼路徑,驗證它們在各種輸入和條件下的表現。
-
提早發現錯誤:通過單元測試可以在代碼集成到更大的系統之前發現問題,這有助於減少未來的調試和維護工作量。
-
文檔和示例:良好的單元測試不僅驗證功能,還可以作爲代碼的使用示例,幫助其他開發人員理解代碼的預期行爲。
-
促進重構:具有良好單元測試覆蓋率的代碼庫使得重構變得更加安全和容易,因爲可以迅速驗證重構後的代碼是否仍按預期工作。
-
確保代碼質量:定期運行單元測試有助於確保代碼質量隨時間的變化保持穩定,尤其是在多人協作的項目中。
-
持續集成和部署:單元測試是持續集成(CI)和持續部署(CD)流程的重要組成部分,有助於自動化這些流程,確保代碼改動不會引入新的錯誤。
-
提高開發效率:單元測試可以減少手動測試的需要,從而加快開發過程,並使開發者更有信心進行代碼更改。
之前的代碼都比較簡單,沒有寫的必要,也不能爲了寫而寫,現在功能在慢慢增加,後續我們會對代碼進行重構,需要單元測試來保證我們的功能沒有問題。
編寫單測
如果你不會寫,或者之前沒有寫過,可以嘗試使用 ChatGPT 幫我們寫,以下是一個使用案例:
❝
推薦一個 ChatGPT: https://ask.vuejs.news/ ,也是筆者之前爲了方便使用而開發。可以免費供小夥伴們使用,有使用問題可以聯繫筆者
我想使用 vitest 進行單元測試,以下是測試代碼是我的測試代碼,我會從main.ts導出一個 effect 函數 和 一個用於創建響應式對象的 reactive 函數。我們期待effect會被立即執行一次,打印出 18, 當age 改變的時候 effect 函數會被再次執行一次,打印出 23,當改變 address 的時候,effect 不會執行,我的響應式數據爲:const data = { name: 'dahuang', age: 18 }。 請你幫我編寫一個單元測試:
effect(function effectFn1() {
console.log(obj.age);
})
setTimeout(() => {
obj.age = 23;
}, 1000);
setTimeout(() => {
obj.address = 'beijing'
}, 2000);
以下就是生成的單元測試代碼:
import { describe, it, expect, vi } from 'vitest';
import { effect, reactive } from './main'; // 確保從您的 main.ts 文件中導入
describe('reactivity system', () => {
it('should run the effect function when reactive properties change', async () => {
const consoleSpy = vi.spyOn(console, 'log'); // 創建一個監視 console.log 調用的 spy
// 創建響應式對象
const obj = reactive({ name: 'dahuang', age: 18 });
// 定義 effect 函數
effect(() => {
console.log(obj.age);
});
// 檢查 effect 是否立即執行
expect(consoleSpy).toHaveBeenCalledWith(18);
// 更改 age 屬性並等待
setTimeout(() => {
obj.age = 23;
}, 1000);
await new Promise((r) => setTimeout(r, 1100)); // 等待上述 setTimeout 完成
// 檢查 effect 是否在 age 改變時再次執行
expect(consoleSpy).toHaveBeenCalledWith(23);
// 更改 address 屬性並等待
setTimeout(() => {
obj.address = 'beijing';
}, 2000);
await new Promise((r) => setTimeout(r, 2100));
// 驗證 effect 沒有因 address 改變而執行
expect(consoleSpy).toHaveBeenCalledTimes(2);
consoleSpy.mockRestore(); // 清除 mock
});
});
解釋一下這些內容:
describe: 這是一個用來定義一組相關測試的函數。它通常用於將測試用例組織成邏輯分組,使測試更加結構化和易於管理。每個 describe
塊可以包含多個測試用例。
it: 這是一個定義單個測試用例的函數。每個 it
塊通常描述了一個具體的行爲或功能的期望結果。它是實際執行測試和斷言的地方。
expect: 這是一個用於編寫測試斷言的函數。測試斷言是用來驗證代碼的行爲是否符合預期的表達式。expect
函數通常與一系列的匹配器(如 toBe
, toEqual
等)結合使用,以檢查不同類型的期望值。
vi: vi
是 Vitest 中的一個全局對象,提供了一系列的工具函數,特別是用於監視(spy)、模擬(mock)和突變(stub)函數的行爲。它是 Vitest 特有的,用於創建更加複雜和控制的測試場景。
運行單測
那麼我們就要從main.js
中導出這 2 個函數。
相關代碼在 commit: (8362dd3)設計一個完善的響應系統
,git checkout 8362dd3
即可查看。
點擊即可運行,如果有問題,請看 第一節 環境搭建。
單測執行結果
一個響應式系統就完成了,接下來我們還會對這個響應式系統進行增強。
下一步我們會對代碼進行重構,先來體驗一下單測的快樂。同時我們也來思考幾個問題:
-
存儲副作用函數的桶爲什麼使用了
WeakMap
? -
在
Proxy
中的set
函數中直接返回了true
, 應該怎麼寫?不返回會有什麼問題?
響應式系統代碼重構
在重構代碼之前,先把思考問題先解決掉,掃清障礙
分析思考問題
存儲副作用函數的桶爲什麼使用了 WeakMap
?
其實涉及 WeakMap 和 Map 的區別,我們用一段代碼來講解:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta >
<title>Document</title>
</head>
<body>
<script>
const map = new Map();
const weakmap = new WeakMap();
(function () {
const foo = { foo: 1 };
const bar = { bar: 2 };
map.set(foo, 1);
weakmap.set(bar, 2);
})();
console.log(map);
console.log(weakmap);
</script>
</body>
</html>
當該函數表達式執行完畢後,對於對象 foo
來說,它仍然作爲 map 的 key 被引用着,因此垃圾回收器(grabage collector)不會把它從內存中移除,我們仍然可以通過 map.keys
打印出對象 foo
。然而對於對象 bar
來說,由於WeakMap
的 key 是弱引用,它不影響垃圾回收器的工作,所以一旦表達式執行完畢,垃圾回收器就會把對象 bar
從內存中移除,並且我們無法獲取 WeakMap
的 key 值,也就無法通過 WeakMap
取得對象 bar。
簡單地說,WeakMap
對 key 是弱引用,不影響垃圾回收器的工作。據這個特性可知,一旦key
被垃圾回收器回收,那麼對應的鍵和值就訪問不到了。所以 WeakMap
經常用於存儲那些只有當 key 所引用的對象存在時(沒有被回收)纔有價值的信息,例如上面的場景中,如果 target
對象沒有任何引用了,說明用戶側不再需要它了,這時垃圾回收器會完成回收任務。但如果使用 Map
來代替 WeakMap
,那麼即使用戶側的代碼對 target
沒有任何引用,這個 target
也不會被回收,最終可能導致內存溢出。
我們看下打印的結果,會有一個更加直觀的感受,可以看到 WeakMap
裏面已經爲空了。
Proxy 的使用問題
在 Proxy
中的 set
函數中直接返回了 true
, 這樣寫規範嗎?會有什麼問題?如果不寫返回值會有什麼問題?
根據 ECMAScript 規範,set
方法需要返回一個布爾值。這個返回值有重要的意義:
-
返回
true
: 表示屬性設置成功。 -
返回
false
: 表示屬性設置失敗。在嚴格模式(strict mode)下,這會導致一個TypeError
被拋出。
如果在 set
函數中不返回任何值(或返回 undefined
),那麼默認情況下,它相當於返回 false
。這意味着:
-
在非嚴格模式下,儘管不返回任何值可能不會立即引起錯誤,但這是不符合規範的行爲。它可能導致調用代碼錯誤地認爲屬性設置失敗。
-
在嚴格模式下,不返回
true
會導致拋出TypeError
異常。
正確的應該這樣寫:
set(target, key, newVal, receiver) {
const res = Reflect.set(target, key, newVal, receiver)
// ...
return res
}
那麼問題又來了,爲什麼要配合 Reflect
使用呢?
我們添加一個 case 看看:
it('why use Reflect', () => {
const consoleSpy = vi.spyOn(console, 'log'); // 捕獲 console.log
const obj = reactive({
foo: 1,
get bar() {
return this.foo
}
})
effect(() => {
console.log(obj.bar);
})
expect(consoleSpy).toHaveBeenCalledTimes(1);
obj.foo ++
expect(consoleSpy).toHaveBeenCalledTimes(2);
})
當 effect
註冊的副作用函數執行時,會讀取 obj.bar
屬性,它發現 obj.bar
是一個訪問器屬性,因此執行 getter 函數。由於在 getter 函數中通過 this.foo
讀取了 foo 屬性值,因此我們認爲副作用函數與屬性 foo
之間也會建立聯繫。當我們修改 p.foo
的值時應該能夠觸發響應,使得副作用函數重新執行纔對,但是實際上 effect 並沒有執行。這是爲什麼呢?
我們來看一下 bucket
中的收集結果:(你可以把這個 case 的內容直接放在 main.ts 中運行一下,然後在瀏覽器中查看)
很明顯, 沒有收集到 foo, 這是爲什麼呢?
我們是用的 this.foo 獲取到的 bar 值,打印一下 this:
this 是這個 obj 對象本身,並不是我們代理後的對象,自然就無法被收集到。那麼如何改變這個 this 指向呢?就需要使用到 Reflect.get 函數的第三個參數 receiver,可以把它理解爲函數調用過程中的 this。
將代碼做如下修改:
get(target, key) {
consnt res = Reflect.get(target, key, receiver)
// ... 其他不變
return res
},
set(target, key, newVal) {
const res = Reflect.set(target, key, newVal, receiver)
// ... 其他不變
return res
},
然後我們再次運行單測,就可以看到通過了
相關代碼在 commit: (c90c1ac)增加Reflect的使用
,git checkout 8362dd3
即可查看。
代碼重構
在目前的實現中,當讀取屬性值時,我們直接在 get 攔截函數里編寫把副作用函數收集到 “桶” 裏的這部分邏輯,但更好的做法是將這部分邏輯單獨封裝到一個 track
函數中,函數的名字叫 track
是爲了表達追蹤的含義。同樣,我們也可以把觸發副作用函數重新執行的邏輯封裝到 trigger
函數中:
function track(target, key) {
// 沒有 activeEffect,直接返回
if (!activeEffect) return target[key];
// 根據 target 從“桶”中取得 depsMap,它也是一個 Map 類型:key --> effects
let depsMap = bucket.get(target);
// 如果不存在 depsMap,那麼新建一個 Map 並與 target 關聯
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 再根據 key 從 depsMap 中取得 deps,它是一個 Set 類型,
// 裏面存儲着所有與當前 key 相關聯的副作用函數:effects
let deps = depsMap.get(key);
// 如果 deps 不存在,同樣新建一個 Set 並與 key 關聯
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 最後將當前激活的副作用函數添加到“桶”裏
deps.add(activeEffect);
}
function trigger(target, key) {
// 根據 target 從桶中取得 depsMap,它是 key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根據 key 取得所有副作用函數 effects
const effects = depsMap.get(key);
// 執行副作用函數
effects && effects.forEach((fn) => fn());
}
// 對原始數據的代理
export function reactive(target) {
return new Proxy(target, {
// 攔截讀取操作
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
// 依賴收集
track(target, key);
return res;
},
// 攔截設置操作
set(target, key, newVal, receiver) {
// 設置屬性值
const res = Reflect.set(target, key, newVal, receiver);
// 派發更新
trigger(target, key);
return res;
},
});
}
分別把邏輯封裝到 track
和 trigger
函數內,這能爲我們帶來極大的靈活性。
我們來運行一下 pnpm test
命令:
可以看到,我們的 case 全部通過。單測,讓我們的重構沒有壓力,這下再也不怕把代碼改壞啦!這裏我們也可以感受出來,單測的一些好處。
當我們在寫單測的時候,最好一個功能寫一個 case,一組有關聯的邏輯放在一個測試文件中,這樣當功能改動的時候,需要改動的 case 會最少。
相關代碼在 commit: (afbaff0)
響應式系統代碼重構 ,git checkout afbaff0
即可查看。
總結
響應式系統核心邏輯流程圖,如下:
由於篇幅原因,本文就到此結束。後續文章會在此基礎上進一步優化這個響應式系統,所以本文的內容一定要弄清楚。後續文章的內容將會包括:依賴清理、嵌套 effect、scheduler、computed、watch 等,最終會實現出一個mini-vue
!
參考
《vuejs 設計與實現》
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/FLIkSf_eOQkTDjmZ0r7etg