你可以手寫Vue3的響應式原理嗎?

語言: CN / TW / HK

在上一篇嗶哩嗶哩面試官:你可以手寫 Vue2 的響應式原理嗎?中,我們已經瞭解了Vue2中的響應式原理並且動手實現了其核心邏輯。但是Vue2的響應式原理是存在一些缺點的:

  • 預設會遞迴、消耗較大
  • 陣列響應化需要額外實現
  • 新增/刪除屬性屬性無法監聽
  • Map、Set、Class 等無法響應式,修改語法有限制

Vue3使用ES6Proxy特性來解決上面這些問題,本篇文章我將帶大家深入瞭解Vue3的響應式原理並在最後通過Proxy實現其核心邏輯。

在開始分析之前,我們先來看一下什麼是 Proxy?

什麼是 Proxy?

ES6 中我們看到了一個讓人耳目一新的屬性——Proxy。我們先看一下概念:

通過呼叫 new Proxy() ,你可以建立一個代理用來替代另一個物件(被稱為目標),這個代理對目標物件進行了虛擬,因此該代理與該目標物件表面上可以被當作同一個物件來對待。代理允許你攔截在目標物件上的底層操作,而這原本是 JS 引擎的內部能力。

Proxy 顧名思義,就是代理的意思,這是一個能讓我們隨意操控物件的特性。當我們通過 Proxy 去對一個物件進行代理之後,我們將得到一個和被代理物件幾乎完全一樣的物件,並且可以對這個物件進行完全的監控。

什麼叫完全監控?Proxy 所帶來的,是對底層操作的攔截。前面我們在實現對物件監聽時使用了 Object.defineProperty,這個其實是 JS 提供給我們的高階操作,也就是通過底層封裝之後暴露出來的方法。Proxy 的強大之處在於,我們可以直接攔截對代理物件的底層操作。這樣我們相當於從一個物件的底層操作開始實現對它的監聽。

那麼Proxy相比Object.defineProperty都有哪些優勢呢?

Proxy 的優勢

  • Proxy 可以直接監聽物件而非屬性;
  • Proxy 可以直接監聽陣列的變化;
  • Proxy 有多達 13 種攔截方法,不限於 applyownKeysdeletePropertyhas 等等是 Object.defineProperty 不具備的;
  • Proxy 返回的是一個新物件,我們可以只操作新的物件達到目的,而 Object.defineProperty 只能遍歷物件屬性直接修改;
  • Proxy 作為新標準將受到瀏覽器廠商重點持續的效能優化,也就是傳說中的新標準的效能紅利。

Proxy有了大致的瞭解後,下面我就來分析一下Vue3的響應式原理

響應式原理

這裡放一張我之前整理的關於Vue3響應式的流程圖:

我們來梳理一下流程:

1、通過state = reactive(target)來定義響應式資料(這裡基於Proxy實現)

2、通過 effect宣告依賴響應式資料的函式cb ( 例如檢視渲染函式render函式),並執行cb函式,執行過程中,會觸發響應式資料 getter

3、在響應式資料 getter中進行 track依賴收集:儲存響應式資料與更新函式 cb 的對映關係,儲存於targetMap

4、當變更響應式資料時,觸發trigger,根據targetMap找到關聯的cb並執行

targetMap的結構為:{target: {key: [fn1,fn2]}}

手寫實現

看下我們都要實現哪些核心函式:

  • reactive:響應式核心方法,用於建立資料響應式
  • effect:宣告響應函式 cb,將回調函式儲存起來備用,立即執行一次回撥函式觸發它裡面一些響應資料的 getter
  • track:依賴收集,儲存響應式資料與更新函式 cb 的對映關係
  • trigger:觸發更新:根據對映關係,執行 cb

建立資料響應式(reactive函式)

// 判斷是不是物件
function isObject(val) {
  return typeof val === "object" && val !== null;
}
function hasOwn(target, key) {
  return target.hasOwnProperty[key];
}
// WeakMap: 弱引用對映表
// 原物件 : 代理過的物件
let toProxy = new WeakMap();
// 代理過的物件:原物件
let toRaw = new WeakMap();
// 響應式核心方法
function reactive(target) {
  // 建立響應式物件
  return createReactiveObject(target);
}
function createReactiveObject(target) {
  // 如果當前不是物件,直接返回即可
  if (!isObject(target)) {
    return target;
  }
  // 如果已經代理過了,就直接返回代理過的結果
  let proxy = toProxy.get(target);
  if (proxy) {
    return proxy;
  }
  // 防止代理過的物件再次被代理
  if (toRaw.has(target)) {
    return target;
  }
  let baseHandler = {
    get(target, key, receiver) {
      // Reflect 是一個內建的物件,它提供攔截 JavaScript 操作的方法。這些方法與proxy handlers的方法相同。
      let res = Reflect.get(target, key, receiver);
      // 收集依賴/訂閱 把當前的key和effect做對映關係
      track(target, key);
      // 在get取值的時候才去判斷該值是否是一個物件,如果是則遞迴(這裡相比於Vue2中的預設遞迴,其實是一種優化)
      return isObject(res) ? reactive(res) : res;
    },
    set(target, key, value, receiver) {
      // 這裡需要區分是新增屬性還是修改屬性
      let hasKey = hasOwn(target, key);
      let oldVal = target[key];
      let res = Reflect.set(target, key, value, receiver);
      if (!hasKey) {
        console.log("新增屬性");
        trigger(target, "add", key);
      } else if (oldVal !== value) {
        console.log("修改屬性");
        trigger(target, "set", key);
      }
      return res;
    },
    deleteProperty(target, key) {
      let res = Reflect.deleteProperty(target, key);
      return res;
    },
  };
  let observed = new Proxy(target, baseHandler);
  toProxy.set(target, observed);
  toRaw.set(observed, target);
  return observed;
}
複製程式碼

依賴收集

其實就是建立響應資料 key 和更新函式之間的對應關係,用法如下:

let obj = reactive({ name: "cosen" });
effect(() => {
  console.log(obj.name);
});
obj.name = "senlin";
obj.name = "senlin1";
複製程式碼

要實現這部分功能,我們需要完成上面提到的三個方法:

  • effect
  • track
  • trigger

首先,我們來梳理一下effect需要實現什麼功能。

經過前面的reactive()方法,我們已經能夠拿到一個響應式的資料物件了,每次getset操作都能夠被攔截。

effect()方法需要實現的功能就是:每當我們修改資料的時候,都能夠觸發傳入effect的回撥函式執行

effect()方法的回撥函式要想在資料發生變化後能夠執行,必須返回一個響應式的effect()函式,所以effect()內部會返回一個響應式的effect

來看下effect方法的實現:

// 響應式 副作用
function effect(fn) {
  const rxEffect = function () {
    try {
      // 捕獲異常
      // 執行fn並將effect儲存起來
      activeEffectStacks.push(rxEffect);
      return fn();
    } finally {
      activeEffectStacks.pop();
    }
  };
  // 預設應該先執行一次
  rxEffect();
  // 返回響應函式
  return rxEffect;
}
複製程式碼

此時資料發生變化還無法通知effect的回撥函式執行,因為reactiveeffect還未關聯起來,也就是說還沒有進行依賴收集,所以接下來需要進行依賴收集。

到這裡我們需要思考兩個問題:

1、什麼時候收集依賴?

2、如何收集依賴,如何儲存依賴?

首先第一個問題:什麼時候收集依賴?我們需要在取值的時候開始收集依賴,而這對應於在Proxy的handlers的get中進行取值,也就是在上面的createReactiveObject方法中的:

get(target, key, receiver) {
  // Reflect 是一個內建的物件,它提供攔截 JavaScript 操作的方法。這些方法與proxy handlers的方法相同。
  let res = Reflect.get(target, key, receiver);
  // 收集依賴/訂閱 把當前的key和effect做對映關係
+  track(target, key);
  // 在get取值的時候才去判斷該值是否是一個物件,如果是則遞迴(這裡相比於Vue2中的預設遞迴,其實是一種優化)
  return isObject(res) ? reactive(res) : res;
},
複製程式碼

對應觸發依賴的執行是在Proxy的handlers的get中

set(target, key, value, receiver) {
  // 這裡需要區分是新增屬性還是修改屬性
  let hasKey = hasOwn(target, key);
  let oldVal = target[key];
  let res = Reflect.set(target, key, value, receiver);
  if (!hasKey) {
    console.log("新增屬性");
+    trigger(target, "add", key);
  } else if (oldVal !== value) {
    console.log("修改屬性");
+    trigger(target, "set", key);
  }
  return res;
},
複製程式碼

然後是第二個問題:如何收集依賴,如何儲存依賴?這個其實我有在上面的流程圖中標註:

{
  target: {
    key: [fn1, fn2];
  }
}
複製程式碼

這裡解釋一下:首先依賴是一個一個的effect函式,我們可以通過Set集合進行儲存,而這個 Set 集合肯定是要和物件的某個key進行對應,即哪些effect依賴了物件中某個key對應的值,這個對應關係可以通過一個Map物件進行儲存。即:

targetMap: WeakMap{
    target:Map{
        key: Set[cb1,cb2...]
    }
}
複製程式碼

當我們取值的時候,首先通過該target物件從全域性的WeakMap物件中取出對應的depsMap物件,然後根據修改的key獲取到對應的dep依賴集合物件,然後將當前effect放入到dep依賴集合中,完成依賴的收集。其實這裡對應的就是track方法:

function track(target, key) {
  // 拿出棧頂函式
  let effect = activeEffectStacks[activeEffectStacks.length - 1];
  //
  if (effect) {
    // 獲取target對應依賴表
    let depsMap = targetsMap.get(target);
    if (!depsMap) {
      targetsMap.set(target, (depsMap = new Map()));
    }
    // 獲取key對應的響應函式集
    let deps = depsMap.get(key);
    // 動態建立依賴關係
    if (!deps) {
      depsMap.set(key, (deps = new Set()));
    }
    if (!deps.has(effect)) {
      deps.add(effect);
    }
  }
}
複製程式碼

當我們修改值的時候會觸發依賴更新,也是通過target物件從全域性的WeakMap物件中取出對應的depMap物件,然後根據修改的key取出對應的dep依賴集合,並遍歷該集合中的所有effect,並執行effect。對應就是trigger方法:

function trigger(target, type, key) {
  let depsMap = targetsMap.get(target);
  if (depsMap) {
    let deps = depsMap.get(key);
    if (deps) {
      // 將當前key對應的effect依次執行
      deps.forEach((effect) => {
        effect();
      });
    }
  }
}
複製程式碼

完整程式碼

這裡整合一下程式碼,並在最後通過一個demo來測試一下:

/**
 * Vue3 響應式原理
 *
 */

// 判斷是不是物件
function isObject(val) {
  return typeof val === "object" && val !== null;
}
function hasOwn(target, key) {
  return target.hasOwnProperty[key];
}
// WeakMap: 弱引用對映表
// 原物件 : 代理過的物件
let toProxy = new WeakMap();
// 代理過的物件:原物件
let toRaw = new WeakMap();
// 響應式核心方法
function reactive(target) {
  // 建立響應式物件
  return createReactiveObject(target);
}
function createReactiveObject(target) {
  // 如果當前不是物件,直接返回即可
  if (!isObject(target)) {
    return target;
  }
  // 如果已經代理過了,就直接返回代理過的結果
  let proxy = toProxy.get(target);
  if (proxy) {
    return proxy;
  }
  // 防止代理過的物件再次被代理
  if (toRaw.has(target)) {
    return target;
  }
  let baseHandler = {
    get(target, key, receiver) {
      // Reflect 是一個內建的物件,它提供攔截 JavaScript 操作的方法。這些方法與proxy handlers的方法相同。
      let res = Reflect.get(target, key, receiver);
      // 收集依賴/訂閱 把當前的key和effect做對映關係
      track(target, key);
      // 在get取值的時候才去判斷該值是否是一個物件,如果是則遞迴(這裡相比於Vue2中的預設遞迴,其實是一種優化)
      return isObject(res) ? reactive(res) : res;
    },
    set(target, key, value, receiver) {
      // 這裡需要區分是新增屬性還是修改屬性
      let hasKey = hasOwn(target, key);
      let oldVal = target[key];
      let res = Reflect.set(target, key, value, receiver);
      if (!hasKey) {
        console.log("新增屬性");
        trigger(target, "add", key);
      } else if (oldVal !== value) {
        console.log("修改屬性");
        trigger(target, "set", key);
      }
      return res;
    },
    deleteProperty(target, key) {
      let res = Reflect.deleteProperty(target, key);
      return res;
    },
  };
  let observed = new Proxy(target, baseHandler);
  toProxy.set(target, observed);
  toRaw.set(observed, target);
  return observed;
}

// 棧 先進後出 {name:[effect]}
let activeEffectStacks = [];
let targetsMap = new WeakMap();
// 如果target中的key發生變化了,就執行數組裡的方法
function track(target, key) {
  // 拿出棧頂函式
  let effect = activeEffectStacks[activeEffectStacks.length - 1];
  if (effect) {
    // 獲取target對應依賴表
    let depsMap = targetsMap.get(target);
    if (!depsMap) {
      targetsMap.set(target, (depsMap = new Map()));
    }
    // 獲取key對應的響應函式集
    let deps = depsMap.get(key);
    // 動態建立依賴關係
    if (!deps) {
      depsMap.set(key, (deps = new Set()));
    }
    if (!deps.has(effect)) {
      deps.add(effect);
    }
  }
}
function trigger(target, type, key) {
  let depsMap = targetsMap.get(target);
  if (depsMap) {
    let deps = depsMap.get(key);
    if (deps) {
      // 將當前key對應的effect依次執行
      deps.forEach((effect) => {
        effect();
      });
    }
  }
}
// 響應式 副作用
function effect(fn) {
  const rxEffect = function () {
    try {
      // 捕獲異常
      // 執行fn並將effect儲存起來
      activeEffectStacks.push(rxEffect);
      return fn();
    } finally {
      activeEffectStacks.pop();
    }
  };
  // 預設應該先執行一次
  rxEffect();
  // 返回響應函式
  return rxEffect;
}

let obj = reactive({ name: "cosen" });
effect(() => {
  console.log(obj.name);
});
obj.name = "senlin";
obj.name = "senlin";
複製程式碼

順便貼下執行的結果:

我們能看到雖然執行了兩次的obj.name = "senlin"操作,但執行結果卻只執行了一次,這個與程式碼中定義的toProxytoRaw是有關的:

  • toProxy:儲存原物件代理過的物件的對映關係,如果已經代理過了,就直接返回代理過的結果
  • toRaw儲存代理過的對原物件的對映關係,防止代理過的物件再次被代理。

總結

ok,到這裡,我基本把Vue3中關於響應式以及依賴收集的相關原理和大家梳理了一遍,也自己手動實現了一個簡易的虛擬碼

本文只是簡單的用虛擬碼的形式做了演示,關於具體實現細節,如果你想更深入的瞭解,大家可以直接去檢視Vue3 響應式部分的原始碼

分享到: