Vue2 與 Vue3 響應式原理與依賴收集詳解

前言

繼 Angular 和 React 之後,尤大在 2016 年發佈瞭如今 “前端三劍客” 之一的 Vue 2.0,並憑藉其簡單易用、輕量高效的特點受到了廣泛的歡迎,特別是在國內環境中。然而 Vue 2 中基於 Object.defineProperty 實現的響應式系統,隨着 JavaScript 與瀏覽器技術的不斷升級,其缺陷也愈加明顯~

所以 Vue 團隊在 Vue 2 的基礎上,通過基於 Proxy 的全新響應式系統發佈了 Vue 3.0,帶來了更好的性能表現。

本文將深入剖析 Vue 2 與 Vue 3 的響應式系統實現,解釋其實現原理和優勢對比,如果你正在學習和了解 Vue 源碼相關的內容,那本文肯定值得一看~

Vue 的響應式系統設計思路

雖然 Vue 2 與 Vue 3 實現響應式系統的方式不同,但是他們的核心思想還是一致的,都是通過 發佈 - 訂閱模式 來實現(因爲發佈者和觀察者之間多了一個 dependence 依賴收集者,與傳統觀察者模式不同)。

個人理解,觀察者模式 與 發佈 - 訂閱模式 都是 消息傳遞的一種實現方式,用來實現 對象間的通信(消息傳遞),只是發佈訂閱模式可以看做是觀察者模式的 升級版,避免了 被觀察對象與觀察者之間的直接聯繫

觀察者模式:一個被觀察對象對應多個觀察者,兩者直接聯繫;被觀察這改變時直接向所有觀察者發送消息(調用觀察者的更新方法)

發佈訂閱模式:被觀察對象與觀察者之間可能是 多對多 的關係,兩者都可以綁定多個另一角色;而兩個角色之間還有一個 依賴收集和管理 的角色(提供一些觀察者的操作方法)。

在 Vue 中我們視圖中依賴的每一個數據其實就是一個被觀察者,我們的視圖渲染函數(renderWatcher)和其他的 watcher/effect 函數則都是訂閱者

當數據改變時,就會發送一個事件去觸發我們的觀察者進行更新,即視圖更新。

Vue 2 的響應式實現

剛剛我們簡述了一下 Vue 實現響應式的基礎方案,就是通過發佈訂閱模式收集數據依賴,當數據更新時觸發 render 等一系列 Watcher 函數的執行來實現視圖更新的。

大家也都知道 Vue 2 是通過 Object.defineProperty 來實現數據 讀取和更新時的操作劫持,通過更改默認的 getter/setter 函數,在 get 過程中收集依賴,在 set 過程中派發更新的。

我們可以通過下面的簡易代碼來分析:

// 響應式數據處理,構造一個響應式對象
class Observer {
  constructor(data) {
    this.data = data
    this.walk(data)
  }

  // 遍歷對象的每個 已定義 屬性,分別執行 defineReactive
  walk(data) {
    if (!data || typeof data !== 'object') {
      return
    }

    Object.keys(data).forEach(key ={
      this.defineReactive(data, key, data[key])
    })
  }

  // 爲對象的每個屬性重新設置 getter/setter
  defineReactive(obj, key, val) {
    // 每個屬性都有單獨的 dep 依賴管理
    const dep = new Dep()

    // 通過 defineProperty 進行操作代理定義
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      // 值的讀取操作,進行依賴收集
      get() {
        if (Dep.target) {
          dep.depend()
        }
        return val
      },
      // 值的更新操作,觸發依賴更新
      set(newVal) {
        if (newVal === val) {
          return
        }
        val = newVal
        dep.notify()
      }
    })
  }
}

// 觀察者的構造函數,接收一個表達式和回調函數
class Watcher {
  constructor(vm, expOrFn, cb) {
    this.vm = vm
    this.getter = parsePath(expOrFn)
    this.cb = cb
    this.value = this.get()
  }

  // watcher 實例觸發值讀取時,將依賴收集的目標對象設置成自身,
     // 通過 call 綁定當前 Vue 實例進行一次函數執行,在運行過程中收集函數中用到的數據
  // 此時會在所有用到數據的 dep 依賴管理中插入該觀察者實例
  get() {
    Dep.target = this
    const value = this.getter.call(this.vm, this.vm)
    // 函數執行完畢後將依賴收集目標清空,避免重複收集
    Dep.target = null
    return value
  }

  // dep 依賴更新時會調用,執行回調函數
  update() {
    const oldValue = this.value
    this.value = this.get()
    this.cb.call(this.vm, this.value, oldValue)
  }
}

// 依賴收集管理者的構造函數
class Dep {
  constructor() {
    // 保存所有 watcher 觀察者依賴數組
    this.subs = []
  }

  // 插入一個觀察者到依賴數組中
  addSub(sub) {
    this.subs.push(sub)
  }

  // 收集依賴,只有此時的依賴目標(watcher 實例)存在時才收集依賴
  depend() {
    if (Dep.target) {
      this.addSub(Dep.target)
    }
  }

  // 發送更新,遍歷依賴數組分別執行每個觀察者定義好的 update 方法
  notify() {
    this.subs.forEach(sub ={
      sub.update()
    })
  }
}

Dep.target = null

// 表達式解析
function parsePath(path) {
  const segments = path.split('.')
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) {
        return
      }
      obj = obj[segments[i]]
    }
    return obj
  }
}

這裏省略了數組部分,但是 數組本身的響應式監聽 是通過重寫數組方法來實現的,而 每個數組元素 則會再次進行 Observer 處理(需要數組在定義時就已經聲明的數組元素)。

當然,因爲 Object.definePorperty 只能對 對象的已知屬性 進行操作,所有才會導致 沒有在 data 中進行聲明的對象屬性直接賦值時無法觸發視圖更新,需要通過魔法($set)來處理。而數組也應爲是通過重新數組方法和遍歷數組元素進行的響應式處理,也會導致按照數組下標進行賦值或者更改元素時無法觸發視圖更新。

例如:

<body>
  <div id="app" class="demo-vm-1">
    <p>{{arr[0]}}</p>
    <p>{{arr[2]}}</p>
    <p>{{arr[3].c}}</p>
  </div>
</body>

<script>
  new Vue({
    el: "#app",
    data() {
      return {
        arr: [1, 2, { a: 3 },{ c: 5 }]
      }
    },
    mounted() {
      console.log("demo Instance: ", this.$data);

      setTimeout(() ={
        console.log('update')
        this.arr[0] = { o: 1 }
        this.arr[2] = { a: 1 }
      },2000)
    },
  })
</script>

因爲數組元素的前三個元素 在定義時都是簡單類型,所以即使在模板中使用了該數據,也無法進行依賴收集和更新響應:

而本身的 data 返回對象與 arr, arr[3] 都有各自的 dep 依賴數組,並且 arr 和 arr[3] 的依賴中都有同一個 Watcher —— RenderWacther 模板渲染。

當然整個響應系統還包含依賴清理等其他操作,具體過程可以查看 Vue 2 閱讀理解(十八)之響應式系統(一 ~ 四)Watcher

Vue 3 的響應式實現

礙於 Object.defineProperty 的侷限性,Vue 3 採用了全新的 Proxy 對象來實現整個響應式系統基礎。

什麼是 Proxy ?

Proxy 是 ES6 新增的一個構造函數,用來創建一個 目標對象的代理對象,攔截對原對象的所有操作;用戶可以通過註冊相應的攔截方法來實現對象操作時的自定義行爲

目前 Proxy 支持的攔截方法包含一下內容:

與 Object,defineProperty 比起來,有非常明顯的優勢:

在 Vue 中體現最爲明顯的一點就是:Proxy 代理對象之後不僅可以攔截對象屬性的讀取、更新、方法調用之外,對整個對象的新增、刪除、枚舉等也能直接攔截,而 Object.defineProperty 只能針對對象的已知屬性進行讀取和更新的操作攔截。

例如:

const obj = { name: 'MiyueFE', age: 28 };
const proxyObj = new Proxy(obj, {
  get(target, property) {
    console.log(`Getting ${property} value: ${target[property]}`);
    return target[property];
  },
  set(target, property, value) {
    console.log(`Setting ${property} value: ${value}`);
    target[property] = value;
  },
  deleteProperty(target, property) {
    console.log(`Deleting ${property} property`);
    delete target[property];
  },
});

console.log(proxyObj.name); // Getting name value: MiyueFE, 輸出 "MiyueFE"
proxyObj.name = 'MY'; // Setting name value: MY
console.log(proxyObj.name); // Getting name value: MY, 輸出 "MY"
delete proxyObj.age; // Deleting age property
console.log(proxyObj.age); // undefined

但是 只有通過 proxyObj 進行操作的時候才能通過定義的操作攔截方法進行處理,直接使用原對象則無法觸發攔截器

這也是 Vue 3 中要求的 reactive 聲明的對象修改原對象無法觸發視圖更新的原因。

並且 Proxy 也只針對 引用類型數據 才能進行代理,所以這也是 Vue 的基礎數據都需要通過 ref 進行聲明的原因,內部會建立一個新對象保存原有的基礎數據值。

例如:

// 接上面的代碼
obj.name = "newName"; // "newName"
console.log(proxyObj.name); // Getting name value: newName;  "newName"

響應式的實現

在選擇了使用 Proxy 代理來進行數據的操作攔截時,Vue 對依賴收集的邏輯也進行了修改,讓我們分別來解析一下這兩者的實現。

核心方法

Vue 提供了一下幾個 響應式數據聲明 的核心 API:

其中 ref 雖然常常有文章說一般只用來 聲明基礎數據的響應式,但是其實 ** 所有的數據類型聲明響應式時都可以使用 ref**,只是內部爲了同時實現基礎數據的響應式處理,封裝成了一個具有 value 屬性的對象,所以我們訪問時必須通過 xxx.value 的形式訪問。

ref 函數內部執行時會創建一個 RefImpl 類型的實例,其中的 _value 就保存響應式數據,並定義了對象的 get 和 set 方法,用來收集依賴和發佈更新事件。如果不是 shallowRef 聲明的淺層響應式數據的話,其 _value 其實也會通過 reactive 方法進行深層數據處理。

而 reactive 與 readonly 則比較相似,都是通過 createReactiveObject 方法來創建一個 Proxy 對象返回,只是 readonly 的 set 和 deleteProperty 操作會直接攔截報錯,提示禁止更新。

至於 get 操作的攔截兩者的大體思路差不多,不過 readonly 因爲只讀的原因,數據不會發生改變,所以不會進行依賴收集操作,如果有深層數據則會繼續向內部進行同樣的處理操作。

帶有 shallow 前綴的數據聲明方法,結合官方的解釋其實就明白了,整個定義 類似深拷貝與淺拷貝的區別,即只處理首層數據。代表假設使用 shadowRefshallowReactive 聲明的對象,如果對象的某個屬性也是對象,則 只有當這個屬性的引用地址發生改變時纔會觸發更新;至於 shallowReadobly 的話,其實就是讀取什麼返回什麼,基本沒有別的處理了。

操作攔截

因爲篇幅的問題,我們直接從核心方法 reactive 開始。

當我們通過 reactive 創建一個響應式數據時,會調用以下方法:

const reactiveMap = new WeakMap();
function reactive(target) {
  return createReactiveObject(target, false, mutableHandlers, mutableCollectionHandlers, reactiveMap);
}
function createReactiveObject(target, isReadonly, baseHandlers, collectionHandlers, proxyMap) {
  if (target["__v_raw"] && !(isReadonly && target["__v_isReactive"])) {
    return target;
  }
  const existingProxy = proxyMap.get(target);
  if (existingProxy) {
    return existingProxy;
  }
  const targetType = getTargetType(target);
  if (targetType === 0) {
    return target;
  }
  const proxy = new Proxy(target, targetType === 2 ? collectionHandlers : baseHandlers);
  proxyMap.set(target, proxy);
  return proxy;
}

省略了 readonly 判斷和非引用數據判斷。

在 createReactiveObject 中首先判斷了是否是 保留原始數據 和 只讀數據校驗,然後判斷這個目標數據時候已經處理過,這兩種情況都直接返回。

然後通過 getTargetType 判斷數據類型:

function targetTypeMap(rawType) {
  switch (rawType) {
    case "Object":
    case "Array":
      return 1 /* COMMON */;
    case "Map":
    case "Set":
    case "WeakMap":
    case "WeakSet":
      return 2 /* COLLECTION */;
    default:
      return 0 /* INVALID */;
  }
}

這裏將數組和對象與 ES6 新增的 Set、Map 等進行了區分,非引用類型直接返回 invalid 錯誤。

最後則是根據數據類型選擇合適的處理程序進行攔截定義。

這裏我們假設是一個對象或者數組,此時 targetTypeMap 函數返回 1,也就是使用上面定義的 mutableHandlers 進行處理。

const mutableHandlers = {
  get: createGetter(),
  set: createSetter(),
  deleteProperty,
  has,
  ownKeys
};
function createGetter(isReadonly2 = false, shallow = false) {
  return function get(target, key, receiver) {
    if (key === "__v_isReactive" /* IS_REACTIVE */) {
      return !isReadonly2;
    } else if (key === "__v_isReadonly" /* IS_READONLY */) {
      return isReadonly2;
    } else if (key === "__v_isShallow" /* IS_SHALLOW */) {
      return shallow;
    } else if (key === "__v_raw" /* RAW */ && receiver === (isReadonly2 ? shallow ? shallowReadonlyMap : readonlyMap : shallow ? shallowReactiveMap : reactiveMap).get(target)) {
      return target;
    }
    const targetIsArray = isArray(target);
    if (!isReadonly2 && targetIsArray && hasOwn(arrayInstrumentations, key)) {
      return Reflect.get(arrayInstrumentations, key, receiver);
    }
    const res = Reflect.get(target, key, receiver);
    if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
      return res;
    }
    if (!isReadonly2) {
      track(target, "get" /* GET */, key);
    }
    if (shallow) {
      return res;
    }
    if (isRef(res)) {
      return targetIsArray && isIntegerKey(key) ? res : res.value;
    }
    if (isObject(res)) {
      return isReadonly2 ? readonly(res) : reactive(res);
    }
    return res;
  };
}
function has(target, key) {
  const result = Reflect.has(target, key);
  if (!isSymbol(key) || !builtInSymbols.has(key)) {
    track(target, "has" /* HAS */, key);
  }
  return result;
}
function ownKeys(target) {
  track(target, "iterate" /* ITERATE */, isArray(target) ? "length" : ITERATE_KEY);
  return Reflect.ownKeys(target);
}


function createSetter(shallow = false) {
  return function set(target, key, value, receiver) {
    let oldValue = target[key];
    if (isReadonly(oldValue) && isRef(oldValue) && !isRef(value)) {
      return false;
    }
    if (!shallow) {
      if (!isShallow(value) && !isReadonly(value)) {
        oldValue = toRaw(oldValue);
        value = toRaw(value);
      }
      if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
        oldValue.value = value;
        return true;
      }
    } else {
    }
    const hadKey = isArray(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwn(target, key);
    const result = Reflect.set(target, key, value, receiver);
    if (target === toRaw(receiver)) {
      if (!hadKey) {
        trigger(target, "add" /* ADD */, key, value);
      } else if (hasChanged(value, oldValue)) {
        trigger(target, "set" /* SET */, key, value, oldValue);
      }
    }
    return result;
  };
}
function deleteProperty(target, key) {
  const hadKey = hasOwn(target, key);
  const oldValue = target[key];
  const result = Reflect.deleteProperty(target, key);
  if (result && hadKey) {
    trigger(target, "delete" /* DELETE */, key, void 0, oldValue);
  }
  return result;
}

在 mutableHandlers 定義了 get, set, deleteProperty, has, ownKeys 五個方法的攔截操作,其中 set、deleteProperty 屬於數據修改操作,會改變原有數據,所以在這兩個方法中 主要進行 “更新消息派發”,也就是 trigger 方法,而剩下的 get、has、ownKeys 三個方法則只會訪問數據的值,不改變原數據,所以這三個方法中 主要進行 “數據依賴收集”, 也就是 track 方法

依賴收集

在數據讀取和更新時定義好了依賴收集和更新派發事件的執行時機之後,我們再回頭看一下 Vue 3 的依賴收集系統。

通過上文我們也知道在 Vue 2 中是給每個對象增加一個 Dep 實例來保存每個對象所關聯的 Watcher 數組,然後更新時遍歷執行,那麼 Vue 3 是不是也是這麼操作的呢?

答案是沒有的。

因爲 Vue 3 採用的 Proxy 可以直接攔截對象的訪問和更新,而無需像 Object.defineProperty 一樣單獨爲每個屬性定義攔截,所以 一個引用類型數據我們只需要收集一個依賴即可,通過一個全局變量進行所有的依賴數據的依賴管理

const targetMap = new WeakMap();
let shouldTrack = true;
let activeEffect = null;

function track(target, type, key) {
  if (shouldTrack && 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 = createDep()))
    const eventInfo = { effect: activeEffect, target, type, key }
    trackEffects(dep, eventInfo)
  }
}
function trackEffects(dep, debuggerEventExtraInfo) {
  let shouldTrack2 = false;
  if (effectTrackDepth <= maxMarkerBits) {
    if (!newTracked(dep)) {
      dep.n |= trackOpBit;
      shouldTrack2 = !wasTracked(dep);
    }
  } else {
    shouldTrack2 = !dep.has(activeEffect);
  }
  if (shouldTrack) {
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
  }
}

以上代碼就是依賴收集部分的大致邏輯,通過 track 函數,在 shouldTrack 標識爲 true 且存在激活副作用函數 activeEffect 時,會在 全局的依賴管理中心 targetMap 中插入該數據,併爲該數據添加一個 Set 格式的 dep 依賴,將激活狀態的副作用 activeEffect 插入到 dep 中

收集到的依賴內容如下:

副作用定義

Vue 3 中的副作用函數稱爲 effect,官方也提供了該函數的直接調用,在 執行時能自動收集相關依賴。其定義如下:

function effect(fn, options) {
  if (fn.effect) {
    fn = fn.effect.fn;
  }
  const _effect = new ReactiveEffect(fn);
  if (options) {
    extend(_effect, options);
    if (options.scope)
      recordEffectScope(_effect, options.scope);
  }
  if (!options || !options.lazy) {
    _effect.run();
  }
  const runner = _effect.run.bind(_effect);
  runner.effect = _effect;
  return runner;
}

它的本質呢其實就是 返回一個綁定了當前作用域並且在內部封裝過其他操作的 fn 函數的新函數render 最終也會封裝成一個 副作用函數),effect 函數內部的核心邏輯還是創建一個 ReactiveEffect 實例。

那麼我們接着進入 ReactiveEffect 內部看看。

class ReactiveEffect {
    constructor(fn, scheduler) {
        this.fn = fn;
        this.scheduler = scheduler;
        this.active = true;
        this.deps = [];
    }
    run() {
        if (!this.active) {
            return this.fn();
        }
        shouldTrack = true;
        activeEffect = this;
        const result = this.fn();
        shouldTrack = false;
        activeEffect = undefined;
        return result;
    }
    stop() {
        if (this.active) {
            cleanupEffect(this);
            if (this.onStop) {
                this.onStop();
            }
            this.active = false;
        }
    }
}
function cleanupEffect(effect) {
    effect.deps.forEach((dep) ={
        dep.delete(effect);
    });
    effect.deps.length = 0;
}

這裏對部分代碼進行了簡化,保留了依賴收集和停止處理的相關邏輯。

在上文的 effect 函數執行時,會調用 ReactiveEffect 實例的 run 方法,執行副作用定義的函數 fn 並將當前的 activeEffect 設置爲該實例。

fn 函數執行時,遇到定義的響應式變量就會觸發相應的 get 操作,此時就會將當前的副作用實例(也就是 ReactiveEffect 實例)插入到該變量的 dep 數據,完成依賴收集過程。

並且 Vue 3 中的 ReactiveEffect 提供了一個 stop 方法,在調用時會清空該副作用相關的所有數據依賴,並且將 this.active 設置爲 false,避免後續執行時的依賴收集。

派發更新

當數據更新時,即觸發 set、deleteProperty 等攔截時,就需要觸發相應的依賴函數執行(也就是視圖更新、watch 執行等),其核心就是 遍歷 targetMap 中該數據對應的 dep 中的每個副作用函數執行

大致代碼如下:

function trigger(target, type, key, newValue, oldValue, oldTarget) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  let deps = [];
  if (type === "clear") {
    deps = [...depsMap.values()];
  } else if (key === "length" && isArray(target)) {
    depsMap.forEach((dep, key2) =((key2 === "length" || key2 >= newValue) && deps.push(dep)));
  } else {
    if (key !== void 0) {
      deps.push(depsMap.get(key));
    }
  }
  const effects = [];
  for (const dep of deps) {
    dep && effects.push(...dep);
  }
  triggerEffects(createDep(effects));
}

function triggerEffects(dep) {
  const effects = isArray(dep) ? dep : [...dep];
  for (const effect of effects) {
    triggerEffect(effect)
  }
}

function triggerEffect(effect) {
  if (effect !== activeEffect || effect.allowRecurse) {
    effect.scheduler ? effect.scheduler() : effect.run();
  }
}

簡單理解就是:

  1. 1. 通過 targetMap.get(target) 獲取到該數據對應的 depsMap 依賴數據

  2. 2. 在 deps 數組中插入這個操作 key 對應的副作用函數,也就是 deps.push(depsMap.get(key))

  3. 3. 通過 createDep 去除重複 effect 副作用,調用 triggerEffect

  4. 4. 遍歷 effects 並執行副作用函數

小結一下

對比起 Vue 2 通過 Object.defineProperty 在數據定義時爲指定具體屬性添加 getter/setter 攔截、爲每個對象添加 deps 依賴數組的響應式系統實現方式,Vue 3 採用 ES6 的 Proxy 結合 WeakMap、WeakSet ,通過代理在運行時攔截對象的基本操作來收集依賴並全局管理,在性能上就得到了很大的提升,也爲開發者帶來了更好的開發體驗。

並且通過全局的依賴管理模式,也讓 Vue 3 的組合式 API 和 hooks 開發模式得以實現,在進行大型項目開發時,公共邏輯拆分也會變得更加清晰。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/b580R42jSU4dfPwgpRrSlw