細說 Vue 響應式原理的 10 個細節!

在講解之前,我們先了解一下數據響應式是什麼?所謂數據響應式就是建立響應式數據依賴(調用了響應式數據的操作)之間的關係,當響應式數據發生變化時,可以通知那些使用了這些響應式數據的依賴操作進行相關更新操作,可以是 DOM 更新,也可以是執行一些回調函數。從 Vue2 到 Vue3 都使用了響應式,那麼它們之間有什麼區別?

那麼它們之間有什麼區別?爲什麼 Vue3 會選擇 Proxy 替代 defineProperty?我們先看看下面兩個例子:

// 
defineReactive(data,key,val){
    Object.defineProperty(data,key,{
      enumerable:true,
      configurable:true,
      get:function(){
        console.log(`對象屬性:${key}訪問defineReactive的get!`)
        return val;
      },
      set:function(newVal){
        if(val===newVal){
          return;
        }
        val = newVal;
        console.log(`對象屬性:${key}訪問defineReactive的get!`)
      }
    })
}
let obj = {};
this.defineReactive(obj,'name','sapper');
// 修改obj的name屬性
obj.name = '工兵';
console.log('obj',obj.name);
// 爲obj添加age屬性
obj.age = 12;
console.log('obj',obj);
console.log('obj.age',obj.age);
// 爲obj添加數組屬性
obj.hobby = ['遊戲', '原神'];
obj.hobby[0] = '王者';
console.log('obj.hobby',obj.hobby);

// 爲obj添加對象屬性
obj.student = {school:'大學'};
obj.student.school = '學院';
console.log('obj.student.school',obj.student.school);

從上圖可以看出使用 defineProperty 定義了包含 name 屬性的對象 obj,然後添加 age 屬性、添加 hobby 屬性(數組)、添加 student 屬性並分別訪問,都沒有觸發 obj 對象中的 get、set 方法。也就是說defineProperty定義對象不能監聽添加額外屬性或修改額外添加的屬性的變化,我們再看看這樣一個例子:

let obj = {};
// 初始化就添加hobby
this.defineReactive(obj,'hobby',['遊戲', '原神']);
// 改變數組下標0的值
obj.hobby[0] = '王者';
console.log('obj.hobby',obj.hobby);

假如我們一開始就爲 obj 添加 hobby 屬性,我們發現修改數組下標 0 的值,並沒有觸發 obj 裏的 set 方法,也就是說defineProperty定義對象不能監聽根據自身數組下標修改數組元素的變化。那麼我們繼續看一下 Proxy 代理的對象例子:

// proxy實現
let targetProxy = {name:'sapper'};
let objProxy = new Proxy(targetProxy,{
    get(target,key){
      console.log(`對象屬性:${key}訪問Proxy的get!`)
      return target[key];
    },
    set(target,key,newVal){
      if(target[key]===newVal){
        return;
      }
      console.log(`對象屬性:${key}訪問Proxy的set!`)
      target[key]=newVal;
      return target[key];
    }
})
// 修改objProxy的name屬性
objProxy.name = '工兵';
console.log('objProxy.name',objProxy.name);
// 爲objProxy添加age屬性
objProxy.age = 12;
console.log('objProxy.age',objProxy.age);
// 爲objProxy添加hobby屬性
objProxy.hobby = ['遊戲', '原神'];
objProxy.hobby[0] = '王者';
console.log('objProxy.hobby',objProxy.hobby);
// 爲objProxy添加對象屬性
objProxy.student = {school:'大學'};
objProxy.student.school = '學院';
console.log('objProxy.student.school',objProxy.student.school);

從上圖是不是發現了 Proxy 與 defineProperty 的明顯區別之處了,Proxy 能支持對象添加或修改觸發 get、set 方法,不管對象內部有什麼屬性。所以

 data() {
   return {
     name: 'sapper',
     student: {
       name: 'sapper',
       hobby: ['原神', '天涯明月刀'],
     },
   };
 },
 methods: {
   deleteName() {
     delete this.student.name;
     console.log('刪除了name', this.student);
   },
   addItem() {
     this.student.age = 21;
     console.log('添加了this.student的屬性', this.student);
   },
   updateArr() {
     this.student.hobby[0] = '王者';
     console.log('更新了this.student的hobby', this.student);
   },
}
![](https://mmbiz.qpic.cn/mmbiz/pfCCZhlbMQToQ8GUib0uia7klolUfLrBntn45mL47OoKITmeFjfFRFiabhZEHwqSkkY439N7bV6yvgyMvblhs7Apg/640?wx_fmt=other) 從圖中確實可以修改 data 裏的屬性,但是不能及時渲染,所以 Vue2 提供了兩個屬性方法解決了這個問題:`Vue.$set``Vue.$delete`。

> 注意不能直接 this._ data.age 這樣去添加 age 屬性,也是不支持的。

```js
this.$delete(this.student, 'name');// 刪除student對象屬性name
this.$set(this.student, 'age''21');// 添加student對象屬性age
this.$set(this.student.hobby, 0, '王者');// 更新student對象屬性hobby數組

```

![](https://mmbiz.qpic.cn/mmbiz/pfCCZhlbMQToQ8GUib0uia7klolUfLrBnt2dEWSIS24viaujXgs6GwoTVZsELudlYAljGeqbfQOibYv9QnXB4PNBYA/640?wx_fmt=other)image.png
const user = {name:'張三'}
const obj = new Proxy(user,{
  get:function (target,key){
    console.log("get run");
    return target[key];
  },
  set:function (target,key,val){
    console.log("set run");
    target[key]=val;
    return true;
  }
})
obj.age = 22;
console.log(obj); // 監聽對象添加額外屬性打印set run!
const obj = new Proxy([2,1],{
  get:function (target,key){
    console.log("get run");
    return target[key];
  },
  set:function (target,key,val){
    console.log("set run");
    target[key]=val;
    return true;
  }
})
obj[0] = 3;
console.log(obj); // 監聽到了數組元素的變化打印set run!

總的來說,Vue3 響應式使用 Proxy 解決了 Vue2 的響應式的詬病,從原理上說,它們所做的事情都是一樣的,依賴收集依賴更新

1 Vue2 響應式原理

這裏基於 Vue2.6.14 版本進行分析

Vue2 響應式:通過 Object.defineProperty() 對每個屬性進行監聽,當對屬性進行讀取的時候就會觸發 getter,對屬性修改的時候就會觸發 setter。首先我們都知道 Vue 實例中有 data 屬性定義響應式數據,它是一個對象。我們看看下面例子的 data:

data(){
 return {
  name: 'Sapper',
  hobby: ['遊戲', '原神'],
  obj: {
    name: '張三',
    student: {
      major: '軟件工程',
      class: '1班',
    }
  }
 }
}

從上圖我們可以看到,data 中的每一個屬性都會帶 __ob__ 屬性,它是一個 Observer 對象,其實 Vue2 中響應式的關鍵就是這個對象,在 data 中的每一個屬性都會帶 get、set 方法,而 Vue 源碼中其實把 get、set 分別定義爲 reactiveGetter、reactiveSetter,這些東西怎麼添加進去的。Vue2 又是怎麼數據變化同時實時渲染頁面?先看看下面的圖:

給 data 屬性創建 Observer 實例:通過初註冊響應式函數 initState 中調用了 initData 函數實現爲 data 創建 Observer 實例。

function initData(vm: Component) {
  // 獲取組件中聲明的data屬性
  let data: any = vm.$options.data
  // 對new Vue實例下聲明、組件中聲明兩種情況的處理
  data = vm._data = isFunction(data) ? getData(data, vm) : data || {}
  ...
  // observe data
  const ob = observe(data) // 爲data屬性創建Observer實例
  ob && ob.vmCount++
}

通過 Observer 實例把 data 中所有屬性轉換成 getter/setter 形式來實現響應性:對 data 屬性分爲兩種情況處理:對象屬性處理(defineReactive 實現)和數組屬性處理。數組怎麼處理(後面再詳細說明)

注意地,由於 Vue 實例的 data 永遠都是一個對象,所以 data 裏面包含的數組類型只有對象屬性數組屬性

在 getter 收集依賴,在 setter 中觸發依賴:當讀取 data 中的數據時,會在 get 方法中收集依賴,當修改 data 中的數據時,會在 set 方法中通知依賴更新。defineReactive 方法中主要是做四件事情:創建Dep實例給對象屬性添加get/set方法收集依賴通知依賴更新

從上面我們知道了 dep.depend() 實現了依賴收集,dep.notify() 實現了通知依賴更新,那麼 Dep 類究竟做了什麼?我們先看看下面的圖:

從圖中我們得明確一點,誰使用了變化的數據,也就是說哪個依賴使用了變化的數據,其實就是 Dep.taget,它就是我們需要收集的依賴,是一個 Watcher 實例對象,其實 Watcher 對象有點類似 watch 監聽器,我們先看一個例子:

vm.$watch('a.b.c',function(newVal,oldVal)){....}

怎麼監聽多層嵌套的對象,其實就是通過. 分割爲對象,循環數組一層層去讀數據,最後一後拿到的就是想要對的數據。

export function parsePath (path){
 const segment = path.split('.');
 return function(obj){
 ...
   for(let i=0;i<segment.length;i++){
     if(!obj) return;
     obj = obj[segment[i]]
   }
   return obj
 }
}

當嵌套對象 a.b.c 屬性發生變化時,就會觸發第二個參數中的函數。也就是說 a.b.c 就是變化的數據,當它的值發生變化時,通知 Watcher,接着 Watcher 觸發第二個參數執行回調函數。我們看看 Watcher 類源碼,是不是發現了 cb 其實就與 watch 的第二參數有異曲同工之妙。

export default class Watcher implements DepTarget {
  vm?: Component | null
  cb: Function
  deps: Array<Dep>
  ...
  constructor(vm: Component | null,expOrFn: string | (() => any),cb: Function,...) {
    ...
    this.getter = parsePath(expOrFn)// 解析嵌套對象
    ...
  }
  get() { // 讀取數據
    ...
    return value
  }

  addDep(dep: Dep) {
    ...
    dep.addSub(this)//添加依賴
    ...
  }
  cleanupDeps() {// 刪除依賴
    ...
    dep.removeSub(this)
    ...
  }
  update() {// 通知依賴更新
   this.run()
   ...
  }

  run() {
   ...
   this.cb.call(this.vm, value, oldValue)
  }
  ...
  depend() { // 收集依賴
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
  }
  ...
}

實現對數組的監聽:從最開始的例子,我們瞭解對象以及嵌套對象的監聽,但是 Object.defineProperty 是用來監聽對象指定屬性的變化,不支持數組監聽,那麼數組又是怎麼監聽?我們上面說了 data 中的數據被賦予響應性都是在 Observer 中實現的,那麼監聽的實現也是在 Observer 對象中實現的,先對數組的特定方法做自定義處理,爲了攔截數組元素通知依賴更新,然後才通過 observeArray 函數遍歷創建 Observer 實例,主要分爲兩種情況:

// 源碼Observer類中對數組處理的部分代碼
if (Array.isArray(value)) {
  if (hasProto) {
    protoAugment(value, arrayMethods)
  } else {
    copyAugment(value, arrayMethods, arrayKeys)
  }
  this.observeArray(value)
}

Vue2 響應式原理小結:

Vue3 響應式原理

這裏基於 Vue3.2.41 版本進行分析

其實 Vue3 的響應原理與 Vue2 的響應原理都差不多,唯一不同的就是它們的實現方式,Vue3 通過創建 Proxy 的實例對象而實現的,它們都是收集依賴、通知依賴更新。而 Vue3 中把依賴命名爲副作用函數 effect,也就是數據改變發生的副作用,我們先來看一下例子:

const house = {status:'未出租',price:1200,type:'一房一廳'};
const obj = new Proxy(house, {
  get (target, key) {
    return target[key];
  },
  set (target, key, newVal) {
    target[key] = newVal;
    return true;
  }
})
function effect () {
  console.log('房子狀態:'+obj.status);
}

effect () // 觸發了proxy對象的get方法
obj.status = '已出租!';
effect ()

通過 Proxy 創建一個代理對象,把 house 代理給 obj,obj 是代理對象,house 是被代理對象。house 對象中數據改變,由於 effect 函數讀取了對象屬性,所以當數據改變,也需要及時更新副作用函數 effect。但是問題來了,假如對象中多個屬性的,依賴於數據變化的多個副作用函數,數據變化一次都需要執行一次,代碼寫起來就會很冗餘,所以我們需要這樣處理:

const objSet = new Set();
const obj = new Proxy(house, {
  // 攔截讀取操作
  get (target, key) {
    objSet.add(effect) // 收集effect
    return target[key];
  },
  set (target, key, newVal) {
    target[key] = newVal;
    objSet.forEach(fn=>fn()) // 遍歷effect
    return true;
  }
})

把副作用函數都存到 Set 實例中,Set 可以過濾重複數據,然後在獲取數據中收集副作用函數,在修改數據中遍歷執行副作用函數,這樣就簡化了代碼,不需要每次改變都要執行一次了,也就是修改一次數據及時更新 effect。雖然上面已經實現了響應式的雛形了,但是還需要解決很多問題:

假如這個副作用函數是一個匿名函數,這時候需要怎麼處理? 添加一個全局變量臨時存儲。

effect (()=>console.log('房子狀態:'+obj.status)) // 上面的例子會直接報not define
// 添加一個全局變量activeEffect存儲依賴函數,這樣effect就不會依賴函數的名字了
let activeEffect;
function effect (fn) {
 activeEffect = fn;
 // 執行副作用函數
 fn()
}

假如讀取不存在的屬性的時候,副作用函數發生什麼? 副作用函數會被重新執行,由於目標字段與副作用函數沒有建立明確的函數聯繫。所以這就需要引入唯一 key 辨識每一個數據的副作用函數,以 target(目標數據)、key(字段名)、effectFn(依賴)。看下圖:

setTimeout(() => {
  obj.notExit = '不存在的屬性';
}, 1000)

分三種情況分析副作用函數存儲數據唯一標識

所以爲了解決這些不同情況的副作用保存問題,所以 Vue3 引入了 Weak、Map、Set 三個集合方法來保存對象屬性的相關副作用函數:

const weakMap = new WeakMap();
let activeEffect;
const track = ((target,key)=>{
  if(!activeEffect){
      return;
    }
    // 從weakMap中獲取當前target對象
    let depsMap = weakMap.get(target);
    if(!depsMap){
      weakMap.set(target,(depsMap=new Map()))
    }
    // 從Map中屬性key獲取當前對象指定屬性
    let deps = depsMap.get(key)
    if(!deps){
      // 副作用函數存儲
      depsMap.set(target,(deps=new Set()))
    }
    deps.add(activeEffect)  
})
const trigger = ((target,key)=>{
  // 從weakMap中獲取當前target對象
  const depsMap = weakMap.get(target);
    if(!depsMap) return;
    // 從Map中獲取指定key對象屬性的副作用函數集合
    const effects = depsMap.get(key);
    effects&&effects.forEach(fn=>fn())
})

WeakMap 與 Map 的區別是? 區別就是垃圾回收器是否回收的問題,WeakMap 對象對 key 是弱引用,如果 target 對象沒有任何引用,可以被垃圾回收器回收,這就需要它了。相對於 WeakMap,不管 target 是否引用,Map 都不會被垃圾回收,容易造成內存泄露。我們看一下下面例子:

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);
})() // 函數執行完,weakMap內的所有屬性都被垃圾回收器回收了
setTimeout(() => {
 console.log(weakMap);// 刷新頁面發現weakMap裏面沒有屬性了
}, 2000)

假如在一個副作用函數中調用了對象的兩個屬性,但是有布爾值控制,按正常來說,副作用函數只能執行一次 get 獲取值的,但是我們現有的實現方法還實現不了,我們看看下面例子。

const effectFn = (() => {
  const str = obj.status ? '' : obj.type;
})
const obj = new Proxy(house, {
  get(target, key) {
    console.log('get run!');// 打印了兩次
    ...
  },
  set(target, key, newVal) {
   ...
  }
})

通過這個例子,我們是不是需要解決這個問題,也就是當每次副作用函數執行時,我們可以先把它從所有與之關聯的依賴集合中刪除。我們看看源碼例子:

// 清空副作用函數依賴的集合
function cleanupEffect(effect: ReactiveEffect) {
  const { deps } = effect
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].delete(effect)
    }
    deps.length = 0
  }
}

嵌套副作用函數處理:由於副作用函數可能是嵌套,比如副作用函數中 effectFn1 中有還有一個副作用函數 effectFn2,以上面的方法對於嵌套函數的處理用全局變量 activeEffect 來存儲通過 effect 函數註冊的副作用函數,這意味着同一時刻 activeEffect 所存儲的副作用函數只能有一個。當副作用函數發生嵌套時,內層副作用函數的執行會覆蓋 activeEffect 的值,並且永遠不會恢復到原來的值。看了很多資料舉例用 effect 棧存儲,是的沒錯,當執行副作用函數的時候把它入棧,執行完畢後把它出棧。現在我們一起看一下源碼怎麼處理的:

響應式可調度性 scheduler:trigger 動作觸發副作用函數重新執行時,有能力決定副作用函數執行的時機、次數以及方式。

Vue3 響應式的 6 個細節我們都瞭解了,我們可以對副作用工作流做一個全面總結如圖:

Vue3 響應式的關鍵在於兩個函數:track(收集依賴)和trigger(觸發依賴)。

// target: 響應式代理對象, type: 訂閱類型(get、hase、iterate), key: 要獲取的target的鍵值
export function track(target: object, type: TrackOpTypes, key: unknown) {
// 如果允許追蹤, 並且當前有正在運行的副作用
  if (shouldTrack && activeEffect) {
  // 獲取當前target訂閱的副作用集合, 如果不存在, 則新建一個
    let depsMap = targetMap.get(target)
    if (!depsMap) {
      // 獲取對應屬性key訂閱的副作用, 如果不存在, 則新建一個
      targetMap.set(target, (depsMap = new Map()))
    }
    let dep = depsMap.get(key)
    if (!dep) {
      depsMap.set(key, (dep = createDep()))
    }
    ...
    // 處理訂閱副作用
    trackEffects(dep, eventInfo)
  }
}

export function trackEffects(dep: Dep,debuggerEventExtraInfo?: DebuggerEventExtraInfo) {
  let shouldTrack = false
  if (effectTrackDepth <= maxMarkerBits) { // 如果當前追蹤深度不超過最大深度(30), 則添加訂閱
    if (!newTracked(dep)) { // 如果未訂閱過, 則新建
      dep.n |= trackOpBit // 據當前的追蹤標識位設置依賴的new值
      shouldTrack = !wasTracked(dep) // 開啓訂閱追蹤
    }
  } else {
    shouldTrack = !dep.has(activeEffect!)
  }

  if (shouldTrack) {
    dep.add(activeEffect!) // 將當前正在運行副作用作爲新訂閱者添加到該依賴中
    activeEffect!.deps.push(dep) // 緩存依賴到當前正在運行的副作用依賴數組
    ...
  }
}
// 根據不同的type從depsMap取出,放入effects,隨後通過run方法將當前的`effect`執行
export function trigger(target: object,type: TriggerOpTypes,key?: unknown,newValue?: unknown,oldValue?: unknown,oldTarget?: Map<unknown, unknown> | Set<unknown>) {
  const depsMap = targetMap.get(target) // 獲取響應式對象的副作用Map, 如果不存在說明未被追蹤, 則不需要處理
  if (!depsMap) {
    return
  }
  let deps: (Dep | undefined)[] = []
  // 如果是清除操作,那就要執行依賴原始數據的所有監聽方法。因爲所有項都被清除了。
  if (type === TriggerOpTypes.CLEAR) { // clear
    // 如果是調用了集合的clear方法, 則要對其所有的副作用進行處理
    deps = [...depsMap.values()]
  } else if (key === 'length' && isArray(target)) {
    const newLength = Number(newValue)
    depsMap.forEach((dep, key) => {
      if (key === 'length' || key >= newLength) {
        deps.push(dep)
      }
    })
  } else { // set add delete
    // key不爲void 0,則說明肯定是SET | ADD | DELETE這三種操作 
    // 然後將依賴這個key的所有監聽函數推到相應隊列中
    if (key !== void 0) {
      deps.push(depsMap.get(key))
    }
    switch (type) { // 根據不同type取出並存入deps
      case TriggerOpTypes.ADD:
         // 如果原始數據是數組,則key爲length,否則爲迭代行爲標識符
        if (!isArray(target)) {
          deps.push(depsMap.get(ITERATE_KEY))
          if (isMap(target)) {
            deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        } else if (isIntegerKey(key)) {
          deps.push(depsMap.get('length'))
        }
        break
      case TriggerOpTypes.DELETE:
       // 如果原始數據是數組,則key爲length,否則爲迭代行爲標識符
        if (!isArray(target)) {
          deps.push(depsMap.get(ITERATE_KEY))
          if (isMap(target)) {
            deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        }
        break
      case TriggerOpTypes.SET:
        if (isMap(target)) {
          deps.push(depsMap.get(ITERATE_KEY))
        }
        break
    }
  }
  ...
    const effects: ReactiveEffect[] = []
    for (const dep of deps) {
      if (dep) {
        effects.push(...dep)
      }
    }
    // 遍歷effects元素執行run函數
    triggerEffects(createDep(effects))
  }
}

Vue3 響應式原理小結:

Vue3 中的副作用函數其實就是 Vue2 的依賴

參考資料

關於本文:

來自: 前端有路燈

https://juejin.cn/post/7187285219257352250

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