實現 vue3 響應式系統核心 - MVP 模型

簡介

2023 年 12 月 31 日,vue2 已經停止維護了。你還不會 Vue3 的源碼麼?

手把手帶你實現一個 vue3 響應式系統,你將獲得:

代碼地址: 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.definePropertyProxy

我們可以把副作用函數 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);

在瀏覽器中直接運行,我們可以得到期望的效果。

但目前的實現還存在一些問題:

後續會逐步解決這些問題,這裏大家只需要理解響應式數據的基本實現和工作原理即可。

相關代碼在 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);
})

在這段代碼中存在三個角色:

如果用 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 的依賴集合。

單元測試

爲什麼這裏纔開始寫單元測試?

先來看看我們寫單元測試的目的:

之前的代碼都比較簡單,沒有寫的必要,也不能爲了寫而寫,現在功能在慢慢增加,後續我們會對代碼進行重構,需要單元測試來保證我們的功能沒有問題。

編寫單測

如果你不會寫,或者之前沒有寫過,可以嘗試使用 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 ?

其實涉及 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 方法需要返回一個布爾值。這個返回值有重要的意義:

  1. 返回 true: 表示屬性設置成功。

  2. 返回 false: 表示屬性設置失敗。在嚴格模式(strict mode)下,這會導致一個 TypeError 被拋出。

如果在 set 函數中不返回任何值(或返回 undefined),那麼默認情況下,它相當於返回 false。這意味着:

正確的應該這樣寫:

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;
    },
  });
}

分別把邏輯封裝到 tracktrigger 函數內,這能爲我們帶來極大的靈活性。

我們來運行一下 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