一步一步實現 Vue 3 Reactivity

Vue 3 中的響應式原理可謂是很是之重要,經過學習 Vue3 的響應式原理,不只能讓咱們學習到 Vue.js 的一些設計模式和思想,還能幫助咱們提升項目開發效率和代碼調試能力javascript

在這以前,我也寫了一篇《探索 Vue.js 響應式原理》 ,主要介紹 Vue 2 響應式的原理,這篇補上 Vue 3 的。 html

因而最近在 Vue Mastery 上從新學習 Vue3 Reactivity 的知識,此次收穫更大。本文將帶你們從頭開始學習如何實現簡單版 Vue 3 響應式,幫助你們瞭解其核心,後面閱讀 Vue 3 響應式相關的源碼可以更加駕輕就熟。前端

1、Vue 3 響應式使用

1. Vue 3 中的使用

當咱們在學習 Vue 3 的時候,能夠經過一個簡單示例,看看什麼是 Vue 3 中的響應式:vue

<!-- 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 實例便可,這時能夠看到頁面中分別顯示對應數值:
image.pngjava

當咱們修改 price 或 quantity 值的時候,頁面上引用它們的地方,內容也能正常展現變化後的結果。這時,咱們會好奇爲什麼數據發生變化後,相關的數據也會跟着變化,那麼咱們接着往下看。react

2. 實現單個值的響應式

在普通 JS 代碼執行中,並不會有響應式變化,好比在控制檯執行下面代碼:git

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 的值並無發生改變。github

那麼如何修改上面代碼,讓 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 數據進行響應式變化:api

① 初始化一個 Set 類型的 dep 變量,用來存放須要執行的反作用( effect 函數),這邊是修改 total 值的方法;

② 建立 track() 函數,用來將須要執行的反作用保存到 dep 變量中(也稱收集反作用);

③ 建立 trigger() 函數,用來執行 dep 變量中的全部反作用;

在每次修改 pricequantity 後,調用 trigger() 函數執行全部反作用後, total 值將自動更新爲最新值。
image.png

(圖片來源:Vue Mastery)

3. 實現單個對象的響應式

一般,咱們的對象具備多個屬性,而且每一個屬性都須要本身的 dep。咱們如何存儲這些?好比:

let product = { price: 10, quantity: 2 };

從前面介紹咱們知道,咱們將全部反作用保存在一個 Set 集合中,而該集合不會有重複項,這裏咱們引入一個 Map 類型集合(即 depsMap ),其 key 爲對象的屬性(如: price 屬性), value 爲前面保存反作用的 Set 集合(如: dep 對象),大體結構以下圖:

image.png
(圖片來源:Vue Mastery)

實現代碼:

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 值也會跟着更新。

大體流程以下圖:

image.png
(圖片來源:Vue Mastery)

2、Proxy 和 Reflect

在上一節內容中,介紹瞭如何在數據發生變化後,自動更新數據,但存在的問題是,每次須要手動經過觸發 track() 函數蒐集依賴,經過 trigger() 函數執行全部反作用,達到數據更新目的。

這一節未來解決這個問題,實現這兩個函數自動調用。

1. 如何實現自動操做

這裏咱們引入 JS 對象訪問器的概念,解決辦法以下:

  • 在讀取(GET 操做)數據時,自動執行 track() 函數自動收集依賴;
  • 在修改(SET 操做)數據時,自動執行 trigger() 函數執行全部反作用;

那麼如何攔截 GET 和 SET 操做?接下來看看 Vue2 和 Vue3 是如何實現的:

須要注意的是:Vue3 使用的 ProxyReflect API 並不支持 IE。

Object.defineProperty() 函數這邊就很少作介紹,能夠閱讀文檔,下文將主要介紹 ProxyReflect API。

2. 如何使用 Reflect

一般咱們有三種方法讀取一個對象的屬性:

  1. 使用 . 操做符:leo.name
  2. 使用 []leo['name']
  3. 使用 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 處理函數。其過程以下:

image.png
(圖片來源:Vue Mastery)

而後結合 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

image.png
(圖片來源:Vue Mastery)

3、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 函數:

  1. 使用 rective 函數
const ref = intialValue => reactive({value: intialValue});

這樣是能夠的,雖然 Vue3 不是這麼實現。

  1. 使用對象的屬性訪問器(計算屬性)

屬性方式去包括:gettersetter

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

4、實現簡易 Computed 方法

用過 Vue 的同窗可能會好奇,上面的 salePricetotal 變量爲何不使用 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 方法,執行效果和前面同樣。

5、源碼學習建議

1. 構建 reactivity.cjs.js

這一節介紹如何去從 Vue 3 倉庫打包一個 Reactivity 包來學習和使用。

準備流程以下:

  1. Vue 3 倉庫下載最新 Vue3 源碼;
git clone https://github.com/vuejs/vue-next.git
  1. 安裝依賴:
yarn install
  1. 構建 Reactivity 代碼:
yarn build reactivity
  1. 複製 reactivity.cjs.js 到你的學習 demo 目錄:

上一步構建完的內容,會保存在 packages/reactivity/dist目錄下,咱們只要在本身的學習 demo 中引入該目錄的  reactivity.cjs.js 文件便可。

  1. 學習 demo 中引入:
const { reactive, computed, effect } = require("./reactivity.cjs.js");

2. Vue3 Reactivity 文件目錄

在源碼的 packages/reactivity/src目錄下,有如下幾個主要文件:

  1. effect.ts:用來定義 effect / track / trigger ;
  2. baseHandlers.ts:定義 Proxy 處理器( get 和 set);
  3. reactive.ts:定義 reactive 方法並建立 ES6 Proxy;
  4. ref.ts:定義 reactive 的 ref 使用的對象訪問器;
  5. computed.ts:定義計算屬性的方法;

image.png
(圖片來源:Vue Mastery)

6、總結

本文帶你們從頭開始學習如何實現簡單版 Vue 3 響應式,實現了 Vue3 Reactivity 中的核心方法( effect / track / trigger / computed /ref 等方法),幫助你們瞭解其核心,提升項目開發效率和代碼調試能力

參考文章

往期推薦

  1. 探索 React 合成事件
  2. 探索 Vue.js 響應式原理
  3. 探索 Snabbdom 模塊系統原理

我是王平安,若是個人文章對你有幫助,請點個 贊👍🏻 支持我一下

個人公衆號:前端自習課,每日清晨,享受一篇前端優秀文章。歡迎你們加入個人前端羣,一塊兒分享和交流技術,vx: pingan8787

相關文章
相關標籤/搜索