由淺入深,帶你用JavaScript實現響應式原理(Vue2、Vue3響應式原理)

MomentYY發表於2022-03-27

由淺入深,帶你用JavaScript實現響應式原理

前言

為什麼前端框架Vue能夠做到響應式?當依賴資料發生變化時,會對頁面進行自動更新,其原理還是在於對響應式資料的獲取和設定進行了監聽,一旦監聽到資料發生變化,依賴該資料的函式就會重新執行,達到更新的效果。那麼我們如果想監聽物件中的屬性被設定和獲取的過程,可以怎麼做呢?

1.Object.defineProperty

在ES6之前,如果想監聽物件屬性的獲取和設定,可以藉助Object.defineProperty方法的存取屬性描述符來實現,具體怎麼用呢?我們來看一下。

const obj = {
  name: 'curry',
  age: 30
}

// 1.拿到obj所有的key
const keys = Object.keys(obj)

// 2.遍歷obj所有的key,並設定存取屬性描述符
keys.forEach(key => {
  let value = obj[key]

  Object.defineProperty(obj, key, {
    get: function() {
      console.log(`obj物件的${key}屬性被訪問啦!`)
      return value
    },
    set: function(newValue) {
      console.log(`obj物件的${key}屬性被設定啦!`)
      value = newValue
    }
  })
})

// 設定:
obj.name = 'kobe' // obj物件的name屬性被設定啦!
obj.age = 24 // obj物件的age屬性被設定啦!
// 訪問:
console.log(obj.name) // obj物件的name屬性被訪問啦!
console.log(obj.age) // obj物件的age屬性被訪問啦!

在Vue2.x中響應式原理實現的核心就是使用的Object.defineProperty,而在Vue3.x中響應式原理的核心被換成了Proxy,為什麼要這樣做呢?主要是Object.defineProperty用來監聽物件屬性變化,有以下缺點:

  • 首先,Object.defineProperty設計的初衷就不是為了去監聽物件屬性的,因為它的主要使用功能就是用來定義物件屬性的;
  • 其次,Object.defineProperty在監聽物件屬性功能上有所缺陷,如果想監聽物件新增屬性、刪除屬性等等,它是無能為力的;

2.Proxy

在ES6中,新增了一個Proxy類,翻譯為代理,它可用於幫助我們建立一個代理物件,之後我們可以在這個代理物件上進行許多的操作。

2.1.Proxy的基本使用

如果希望監聽一個物件的相關操作,當Object.defineProperty不能滿足我們的需求時,那麼可以使用Proxy建立一個代理物件,在代理物件上,我們可以監聽對原物件進行了哪些操作。下面將上面的例子用Proxy來實現,看看效果。

基本語法:const p = new Proxy(target, handler)

  • target:需要代理的目標物件;
  • handler:定義的各種操作代理物件的行為(也稱為捕獲器);
const obj = {
  name: 'curry',
  age: 30
}

// 建立obj的代理物件
const objProxy = new Proxy(obj, {
  // 獲取物件屬性值的捕獲器
  get: function(target, key) {
    console.log(`obj物件的${key}屬性被訪問啦!`)
    return target[key]
  },
  // 設定物件屬性值的捕獲器
  set: function(target, key, newValue) {
    console.log(`obj物件的${key}屬性被設定啦!`)
    target[key] = newValue
  }
})

// 之後的操作都是拿代理物件objProxy
// 設定:
objProxy.name = 'kobe' // obj物件的name屬性被設定啦!
objProxy.age = 24 // obj物件的age屬性被設定啦!
// 訪問:
console.log(objProxy.name) // obj物件的name屬性被訪問啦!
console.log(objProxy.age) // obj物件的age屬性被訪問啦!
// 可以發現原物件obj同時發生了改變
console.log(obj) // { name: 'kobe', age: 24 }

2.2.Proxy的set和get捕獲器

在上面的例子中,其實已經使用到了set和get捕獲器,而set和get捕獲器是最為常用的捕獲器,下面具體來看看這兩個捕獲器吧。

(1)set捕獲器

set函式可接收四個引數:

  • target:目標物件(被代理物件);
  • property:將被設定的屬性key;
  • value:設定的新屬性值;
  • receiver:呼叫的代理物件;

(2)get捕獲器

get函式可接收三個引數:

  • target:目標物件;
  • property:被獲取的屬性key;
  • receiver:呼叫的代理物件;

2.3.Proxy的apply和construct捕獲器

上面所講的都是對物件屬性的操作進行監聽,其實Proxy提供了更為強大的功能,可以幫助我們監聽函式的呼叫方式。

  • apply:監聽函式是否使用apply方式呼叫。
  • construct:監聽函式是否使用new操作符呼叫。
function fn(x, y) {
  return x + y
}

const fnProxy = new Proxy(fn, {
  /*
    target: 目標函式(fn)
    thisArg: 指定的this物件,也就是被呼叫時的上下文物件({ name: 'curry' })
    argumentsList: 被呼叫時傳遞的引數列表([1, 2])
  */
  apply: function(target, thisArg, argumentsList) {
    console.log('fn函式使用apply進行了呼叫')
    return target.apply(thisArg, argumentsList)
  },
  /*
    target: 目標函式(fn)
    argumentsList: 被呼叫時傳遞的引數列表
    newTarget: 最初被呼叫的建構函式(fnProxy)
  */
  construct: function(target, argumentsList, newTarget) {
    console.log('fn函式使用new進行了呼叫')
    return new target(...argumentsList)
  }
})

fnProxy.apply({ name: 'curry' }, [1, 2]) // fn函式使用apply進行了呼叫
new fnProxy() // fn函式使用new進行了呼叫

2.4.Proxy所有的捕獲器

除了上面提到的4種捕獲器,Proxy還給我們提供了其它9種捕獲器,一共是13個捕獲器,下面對這13個捕獲器進行簡單總結,下面表格的捕獲器分別對應物件上的一些操作方法。

捕獲器handler 捕獲物件
get() 屬性讀取操作
set() 屬性設定操作
has() in操作符
deleteProperty() delete操作符
apply() 函式呼叫操作
construct() new操作符
getPrototypeOf() Object.getPrototypeOf()
setPrototypeOf() Object.setPrototypeOf()
isExtensible() Object.isExtensible()
preventExtensions() Object.perventExtensions()
getOwnPropertyDescriptor() Object.getOwnPropertyDescriptor()
defineProperty() Object.defineProperty()
ownKeys() Object.getOwnPropertySymbols()

Proxy捕獲器具體用法可查閱MDN:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy

3.Reflect

在ES6中,還新增了一個API為Reflect,翻譯為反射,為一個內建物件,一般用於搭配Proxy進行使用。

3.1.Reflect有什麼作用呢?

可能會有人疑惑,為什麼在這裡提到Reflect,它具體有什麼作用呢?怎麼搭配Proxy進行使用呢?

  • Reflect上提供了很多操作JavaScript物件的方法,類似於Object上操作物件的方法;
  • 比如:Reflect.getPrototypeOf()類似於Object.getPrototypeOf()Reflect.defineProperty()類似於Object.defineProperty()
  • 既然Object已經提供了這些方法,為什麼還提出Reflect這個API呢?
    • 這裡涉及到早期ECMA規範問題,Object本是作為一個建構函式用於建立物件,然而卻將這麼多方法放到Object上,本就是不合適的;
    • 所以,ES6為了讓Object職責單一化,新增了Reflect,將Object上這些操作物件的方法新增到Reflect上,且Reflect不能作為建構函式進行new呼叫

3.2.Reflect的基本使用

在上述Proxy中,操作物件的方法都可以換成對應的Reflect上的方法,基本使用如下:

const obj = {
  name: 'curry',
  age: 30
}

// 建立obj的代理物件
const objProxy = new Proxy(obj, {
  // 獲取物件屬性值的捕獲器
  get: function(target, key) {
    console.log(`obj物件的${key}屬性被訪問啦!`)
    return Reflect.get(target, key)
  },
  // 設定物件屬性值的捕獲器
  set: function(target, key, newValue) {
    console.log(`obj物件的${key}屬性被設定啦!`)
    Reflect.set(target, key, newValue)
  },
  // 刪除物件屬性的捕獲器
  deleteProperty: function(target, key) {
    console.log(`obj物件的${key}屬性被刪除啦!`)
    Reflect.deleteProperty(target, key)
  }
})

// 設定:
objProxy.name = 'kobe' // obj物件的name屬性被設定啦!
objProxy.age = 24 // obj物件的age屬性被設定啦!
// 訪問:
console.log(objProxy.name) // obj物件的name屬性被訪問啦!
console.log(objProxy.age) // obj物件的age屬性被訪問啦!
// 刪除:
delete objProxy.name // obj物件的name屬性被刪除啦!

3.3.Reflect上常見的方法

對比Object,我們來看一下Reflect上常見的操作物件的方法(靜態方法):

Reflect方法 類似於
get(target, propertyKey [, receiver]) 獲取物件某個屬性值,target[name]
set(target, propertyKey, value [, receiver]) 將值分配給屬性的函式,返回一個boolean
has(target, propertyKey) 判斷一個物件是否存在某個屬性,和in運算子功能相同
deleteProperty(target, propertyKey) delete操作符,相當於執行delete target[name]
apply(target, thisArgument, argumentsList) 對一個函式進行呼叫操作,可以傳入一個陣列作為呼叫引數,Function.prototype.apply()
construct(target, argumentsList [, newTarget]) 對建構函式進行new操作,new target(...args)
getPrototypeOf(target) Object.getPrototype()
setPrototypeOf(target, prototype) 設定物件原型的函式,返回一個boolean
isExtensible(target) Object.isExtensible()
preventExtensions(target) Object.preventExtensions(),返回一個boolean
getOwnPropertyDescriptor(target, propertyKey) Object.getOwnPropertyDescriptor(),如果物件中存在該屬性,則返回對應屬性描述符,否則返回undefined
defineProperty(target, propertyKey, attributes) Object.defineProperty(),設定成功返回true
ownKeys(target) 返回一個包含所有自身屬性(不包含繼承屬性)的陣列,類似於Object.keys(),但是不會受enumerable影響

具體Reflect和Object物件之間的關係和使用方法,可以參考MDN:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Reflect

3.4.Reflect的construct方法

construct方法有什麼作用呢?具體的應用場景是什麼?這裡提一個需求,就明白construct方法的作用了。

需求:建立Person和Student兩個建構函式,最終的例項物件執行的是Person中的程式碼,帶上例項物件的型別是Student。

construct可接收的引數:

  • target:被執行的目標建構函式(Person);
  • argumentsList:類陣列物件,引數列表;
  • newTarget:作為新建立物件原型物件的constructor屬性(Student);
function Person(name, age) {
  this.name = name
  this.age = age
}

function Student() {}

const stu = Reflect.construct(Person, ['curry', 30], Student)
console.log(stu)
console.log(stu.__proto__ === Student.prototype)

列印結果:例項物件的型別為Student,並且例項物件原型指向Student建構函式的原型。

Reflect的construct方法就可以用於類繼承的實現,可在babel工具中檢視ES6轉ES5後的程式碼,就是使用的Reflect的construct方法:

4.receiver的作用

在介紹Proxy的set和get捕獲器的時候,其中有個引數叫receiver,具體什麼是呼叫的代理物件呢?它的作用是什麼?

如果原物件(需要被代理的物件)它有自己的getter和setter伺服器屬性時,那麼就可以通過receiver來改變裡面的this。

// 假設obj的age為私有屬性,需要通過getter和setter來訪問和設定
const obj = {
  name: 'curry',
  _age: 30,
  get age() {
    return this._age
  },
  set age(newValue) {
    this._age = newValue
  }
}

const objProxy = new Proxy(obj, {
  get: function(target, key, reveiver) {
    console.log(`obj物件的${key}屬性被訪問啦!`)
    return Reflect.get(target, key)
  },
  set: function(target, key, newValue, reveiver) {
    console.log(`obj物件的${key}屬性被設定啦!`)
    Reflect.set(target, key, newValue)
  }
})

// 設定:
objProxy.name = 'kobe'
objProxy.age = 24
// 訪問:
console.log(objProxy.name)
console.log(objProxy.age)

在沒有使用receiver的情況下的列印結果為:name和age屬性都被訪問一次和設定一次。

但是由於原物件obj中對age進行了攔截操作,我們看一下age具體的訪問步驟

  • 首先,列印objProxy.age會被代理物件objProxy中的get捕獲器所捕獲;
  • 緊接著Reflect.get(target, key)對obj中的age進行了訪問,又會被obj中的get訪問器所攔截,返回this._age
  • 很顯然在執行this._age的時候_age在這裡是被訪問了的,而這裡的this指向的原物件obj;
  • 一般地,通過this._age的時候,應該也是要被代理物件的get捕獲器所捕獲的,那麼就需要將這裡的this修改成objProxy,相當於objProxy._age,在代理物件objProxy中就可以被get捕獲到了;
  • receiver的作用就在這裡,把原物件中this改成其代理物件,同理age被設定也是一樣的,訪問和設定資訊都需要被列印兩次;
// 假設obj的age為私有屬性,需要通過getter和setter來訪問和設定
const obj = {
  name: 'curry',
  _age: 30,
  get age() {
    return this._age
  },
  set age(newValue) {
    this._age = newValue
  }
}

const objProxy = new Proxy(obj, {
  get: function(target, key, receiver) {
    console.log(`obj物件的${key}屬性被訪問啦!`)
    return Reflect.get(target, key, receiver)
  },
  set: function(target, key, newValue, receiver) {
    console.log(`obj物件的${key}屬性被設定啦!`)
    Reflect.set(target, key, newValue, receiver)
  }
})

// 設定:
objProxy.name = 'kobe'
objProxy.age = 24
// 訪問:
console.log(objProxy.name)
console.log(objProxy.age)

再來看一下列印結果:

也可以列印receiver,在瀏覽器中進行檢視,其實就是這裡的objProxy:

5.響應式原理的實現

5.1.什麼是響應式呢?

當某個變數值發生變化時,會自動去執行某一些程式碼。如下程式碼,當變數num發生變化時,對num有所依賴的程式碼可以自動執行。

let num = 30

console.log(num) // 當num方式變化時,這段程式碼能自動執行
console.log(num * 30) // 當num方式變化時,這段程式碼能自動執行

num = 1
  • 像上面這一種自動響應資料變化的程式碼機制,就稱之為響應式;
  • 在開發中,一般都是監聽某一個物件中屬性的變化,然後自動去執行某一些程式碼塊,而這些程式碼塊一般都存放在一個函式中,因為函式可以方便我們再次執行這些程式碼,只需再次呼叫函式即可;

5.2.收集響應式函式的實現

在響應式中,需要執行的程式碼可能不止一行,而且也不可能一行行去執行,所以可以將這些程式碼放到一個函式中,當資料發生變化,自動去執行某一個函式。但是在開發中有那麼多函式,怎麼判斷哪些函式需要響應式?哪些又不需要呢?

  • 封裝一個watchFn的函式,將需要響應式的函式傳入;
  • watchFn的主要職責就是將這些需要響應式的函式收集起來,存放到一個陣列reactiveFns中;
const obj = {
  name: 'curry',
  age: 30
}

// 定義一個存放響應式函式的陣列
const reactiveFns = []
// 封裝一個用於收集響應式函式的函式
function watchFn(fn) {
  reactiveFns.push(fn)
}

watchFn(function() {
  let newName = obj.name
  console.log(newName)
  console.log('1:' + obj.name)
})

watchFn(function() {
  console.log('2:' + obj.name)
})

obj.name = 'kobe'
// 當obj中的屬性值傳送變化時,遍歷執行那些收集的響應式函式
reactiveFns.forEach(fn => {
  fn()
})

5.3.收集響應式函式的優化

上面實現的收集響應式函式,目前是存放到一個陣列中來儲存的,而且只是對name屬性的的依賴進行了收集,如果age屬性也需要收集,不可能都存放到一個陣列裡面,而且屬性值改變後,還需要通過手動去遍歷呼叫,顯而易見是很麻煩的,下面做一些優化。

  • 封裝一個類,專門用於收集這些響應式函式;
  • 類中新增一個notify的方法,用於遍歷呼叫這些響應式函式;
  • 對於不同的屬性,就分別去例項化這個類,那麼每個屬性就可以對應一個物件,並且物件中有一個存放它的響應式陣列的屬性reactiveFns
class Depend {
  constructor() {
    // 用於存放響應式函式
    this.reactiveFns = []
  }

  // 使用者新增響應式函式
  addDependFn(fn) {
    this.reactiveFns.push(fn)
  }

  // 用於執行響應式函式
  notify() {
    this.reactiveFns.forEach(fn => {
      fn()
    })
  }
}

const obj = {
  name: 'curry',
  age: 30
}

const dep = new Depend()
// 在watchFn中使用dep的addDependFn來收集
function watchFn(fn) {
  dep.addDependFn(fn)
}

watchFn(function() {
  let newName = obj.name
  console.log(newName)
  console.log('1:' + obj.name)
})

watchFn(function() {
  console.log('2:' + obj.name)
})

obj.name = 'kobe'
// name屬性發生改變,直接呼叫notify
dep.notify()

5.4.自動監聽物件的變化

在修改物件屬性值後,還是需要手動去呼叫其notify函式來通知響應式函式執行,其實可以做到自動監聽物件屬性的變化,來自動呼叫notify函式,這個想必就很容易了,在前面做了那麼多功課,就是為了這裡,不管是用Object.defineProperty還是Proxy都可以實現物件的監聽,這裡我使用功能更加強大的Proxy,並結合Reflect來實現。

class Depend {
  constructor() {
    // 用於存放響應式函式
    this.reactiveFns = []
  }

  // 使用者新增響應式函式
  addDependFn(fn) {
    this.reactiveFns.push(fn)
  }

  // 用於執行響應式函式
  notify() {
    this.reactiveFns.forEach(fn => {
      fn()
    })
  }
}

const obj = {
  name: 'curry',
  age: 30
}

const dep = new Depend()
// 在watchFn中使用dep的addDependFn來收集
function watchFn(fn) {
  dep.addDependFn(fn)
}

// 建立一個Proxy
const objProxy = new Proxy(obj, {
  get: function(target, key, receiver) {
    return Reflect.get(target, key, receiver)
  },
  set: function(target, key, newValue, receiver) {
    Reflect.set(target, key, newValue, receiver)
    // 當set捕獲器捕獲到屬性變化時,自動去呼叫notify
    dep.notify()
  }
})

watchFn(function() {
  let newName = objProxy.name
  console.log(newName)
  console.log('1:' + objProxy.name)
})

watchFn(function() {
  console.log('2:' + objProxy.name)
})

objProxy.name = 'kobe'
objProxy.name = 'klay'
objProxy.name = 'james'

注意:後面使用到的obj物件,需都換成代理物件objProxy,這樣儲能監聽到屬性值是否被設定了。

列印結果:name屬性修改了三次,對應依賴函式就執行了三次。

5.5.物件依賴的管理(資料儲存結構設計)

在上面實現響應式過程中,都是基於一個物件的一個屬性,如果有多個物件,這多個物件中有不同或者相同的屬性呢?我們應該這樣去單獨管理不同物件中每個屬性所對應的依賴呢?應該要做到當某一個物件中的某一個屬性發生變化時,只去執行對這個物件中這個屬性有依賴的函式,下面就來講一下怎樣進行資料儲存,能夠達到我們的期望。

在ES16中,給我們新提供了兩個新特性,分別是Map和WeakMap,這兩個類都可以用於存放資料,類似於物件,存放的是鍵值對,但是Map和WeakMap的key可以存放物件,而且WeakMap對物件的引用是弱引用。如果對這兩個類不太熟悉,可以去看看上一篇文章:ES6-ES12簡單知識點總結

  • 將不同的物件存放到WeakMap中作為key,其value存放對應的Map;
  • Map中存放對應物件的屬性作為key,其value存放對應的依賴物件;
  • 依賴物件中存放有該屬性對應響應式函式陣列;

如果有以下obj1和obj2兩個物件,來看一下它們大致的儲存形式:

const obj1 = { name: 'curry', age: 30 }
const obj2 = { name: 'kobe', age: 24 }

5.6.物件依賴管理的實現

已經確定了怎麼儲存了,下面就來實現一下吧。

  • 封裝一個getDepend函式,主要用於根據物件和key,來找到對應的dep;
  • 如果沒有找到就先進行建立儲存;
// 1.建立一個WeakMap儲存結構,存放物件
const objWeakMap = new WeakMap()
// 2.封裝一個獲取dep的函式
function getDepend(obj, key) {
  // 2.1.根據物件,獲取對應的map
  let map = objWeakMap.get(obj)
  // 如果是第一次獲取這個map,那麼需要先建立一個map
  if (!map) {
    map = new Map()
    // 將map存到objWeakMap中對應key上
    objWeakMap.set(obj, map)
  }

  // 2.2.根據物件的屬性,獲取對應的dep
  let dep = map.get(key)
  // 如果是第一次獲取這個dep,那麼需要先建立一個dep
  if (!dep) {
    dep = new Depend()
    // 將dep存到map中對應的key上
    map.set(key, dep)
  }

  // 2.3最終將dep返回出去
  return dep
}

在Proxy的捕獲器中獲取對應的dep:

// 建立一個Proxy
const objProxy = new Proxy(obj, {
  get: function(target, key, receiver) {
    return Reflect.get(target, key, receiver)
  },
  set: function(target, key, newValue, receiver) {
    Reflect.set(target, key, newValue, receiver)
    // 根據當前物件target和設定的key,去獲取對應的dep
    const dep = getDepend(target, key)
    console.log(dep)
    // 當set捕獲器捕獲到屬性變化時,自動去呼叫notify
    dep.notify()
  }
})

5.7.物件的依賴收集優化

可以發現上面列印的結果中的響應式函式陣列全部為空,是因為在前面收集響應式函式是通過watchFn來收集的,而在getDepend中並沒有去收集對應的響應式函式,所以返回的dep物件裡面的陣列全部就為空了。如果對響應式函式,還需要通過自己一個個去收集,是不太容易的,所以可以監聽響應式函式中依賴了哪一個物件屬性,讓Proxy的get捕獲器去收集就行了。

  • 既然get需要監聽到響應式函式訪問了哪些屬性,那麼響應式函式在被新增之前肯定是要執行一次的;
  • 如何在Proxy中拿到當前需要被收集的響應式函式呢?可以藉助全域性變數;
  • 下面就來對watchFn進行改造;
// 定義一個全域性變數,存放當前需要收集的響應式函式
let currentReactiveFn = null
function watchFn(fn) {
  currentReactiveFn = fn
  // 先呼叫一次函式,提醒Proxy的get捕獲器需要收集響應式函式了
  fn()
  // 收集完成將currentReactiveFn重置
  currentReactiveFn = null
}

Proxy中get捕獲器具體需要執行的操作:

// 建立一個Proxy
const objProxy = new Proxy(obj, {
  get: function(target, key, receiver) {
    const dep = getDepend(target, key)
    // 拿到全域性的currentReactiveFn進行新增
    dep.addDependFn(currentReactiveFn)
    return Reflect.get(target, key, receiver)
  },
  set: function(target, key, newValue, receiver) {
    Reflect.set(target, key, newValue, receiver)
    // 根據當前物件target和設定的key,去獲取對應的dep
    const dep = getDepend(target, key)
    console.log(dep)
    // 當set捕獲器捕獲到屬性變化時,自動去呼叫notify
    dep.notify()
  }
})

下面測試一下看看效果:

watchFn(function() {
  console.log('1:我依賴了name屬性')
  console.log(objProxy.name)
})
watchFn(function() {
  console.log('2:我依賴了name屬性')
  console.log(objProxy.name)
})

watchFn(function() {
  console.log('1:我依賴了age屬性')
  console.log(objProxy.age)
})
watchFn(function() {
  console.log('2:我依賴了age屬性')
  console.log(objProxy.age)
})

console.log('----------以上為初始化執行,以下為修改後執行-------------')

objProxy.name = 'kobe'
objProxy.age = 24

5.8.Depend類優化

截止到上面,大部分響應式原理已經實現了,但是還存在一些小問題需要優化。

  • 優化一:既然currentReactiveFn可以在全域性拿到,何不在Depend類中就對它進行收集呢。改造方法addDependFn
  • 優化二:如果一個響應式函式中多次訪問了某個屬性,就都會去到Proxy的get捕獲器,該響應式函式會被重複收集,在呼叫時就會呼叫多次。當屬性發生變化後,依賴這個屬性的響應式函式被呼叫一次就可以了。改造reactiveFns,將陣列改成Set,Set可以避免元素重複,注意新增元素使用add
// 將currentReactiveFn放到Depend之前,方便其拿到
let currentReactiveFn = null

class Depend {
  constructor() {
    // 用於存放響應式函式
    this.reactiveFns = new Set()
  }

  // 使用者新增響應式函式
  addDependFn() {
    // 先判斷一下currentReactiveFn是否有值
    if (currentReactiveFn) {
      this.reactiveFns.add(currentReactiveFn)
    }
  }

  // 用於執行響應式函式
  notify() {
    this.reactiveFns.forEach(fn => {
      fn()
    })
  }
}

Proxy中就不用去收集響應式函式了,直接呼叫addDependFn即可:

// 建立一個Proxy
const objProxy = new Proxy(obj, {
  get: function(target, key, receiver) {
    const dep = getDepend(target, key)
    // 直接呼叫addDepend方法,讓它去收集
    dep.addDependFn()
    return Reflect.get(target, key, receiver)
  },
  set: function(target, key, newValue, receiver) {
    Reflect.set(target, key, newValue, receiver)
    // 根據當前物件target和設定的key,去獲取對應的dep
    const dep = getDepend(target, key)
    // 當set捕獲器捕獲到屬性變化時,自動去呼叫notify
    dep.notify()
  }
})

5.9.多個物件實現響應式

前面都只講了一個物件實現響應式的實現,如果有多個物件需要實現可響應式呢?將Proxy封裝一下,外面套一層函式即可,呼叫該函式,返回該物件的代理物件。

function reactive(obj) {
  return new Proxy(obj, {
    get: function(target, key, receiver) {
      const dep = getDepend(target, key)
      // 直接呼叫addDepend方法,讓它去收集
      dep.addDependFn()
      return Reflect.get(target, key, receiver)
    },
    set: function(target, key, newValue, receiver) {
      Reflect.set(target, key, newValue, receiver)
      // 根據當前物件target和設定的key,去獲取對應的dep
      const dep = getDepend(target, key)
      // 當set捕獲器捕獲到屬性變化時,自動去呼叫notify
      dep.notify()
    }
  })
}

看一下具體使用效果:

const obj1 = { name: 'curry', age: 30 }
const obj2 = { weight: '130', height: '180' }

const obj1Proxy = reactive(obj1)
const obj2Proxy = reactive(obj2)

watchFn(function() {
  console.log('我依賴了obj1的name屬性')
  console.log(obj1Proxy.name)
})
watchFn(function() {
  console.log('我依賴了age屬性')
  console.log(obj1Proxy.age)
})

watchFn(function() {
  console.log('我依賴了obj2的weight屬性')
  console.log(obj2Proxy.weight)
})
watchFn(function() {
  console.log('我依賴了obj2的height屬性')
  console.log(obj2Proxy.height)
})

console.log('----------以上為初始化執行,以下為修改後執行-------------')

obj1Proxy.name = 'kobe'
obj1Proxy.age = 24
obj2Proxy.weight = 100
obj2Proxy.height = 165

5.10.總結整理

通過上面9步完成了最終響應式原理的實現,下面對其進行整理一下:

  • watchFn函式:傳入該函式的函式都是需要被收集為響應式函式的,對響應式函式進行初始化呼叫,使Proxy的get捕獲器能捕獲到屬性訪問;

    function watchFn(fn) {
      currentReactiveFn = fn
      // 先呼叫一次函式,提醒Proxy的get捕獲器需要收集響應式函式了
      fn()
      // 收集完成將currentReactiveFn重置
      currentReactiveFn = null
    }
    
  • Depend類reactiveFns用於存放響應式函式,addDependFn方法實現對響應式函式的收集,notify方法實現當屬性值變化時,去呼叫對應的響應式函式;

    // 將currentReactiveFn放到Depend之前,方便其拿到
    let currentReactiveFn = null
    
    class Depend {
      constructor() {
        // 用於存放響應式函式
        this.reactiveFns = new Set()
      }
    
      // 使用者新增響應式函式
      addDependFn() {
        // 先判斷一下currentReactiveFn是否有值
        if (currentReactiveFn) {
          this.reactiveFns.add(currentReactiveFn)
        }
      }
    
      // 用於執行響應式函式
      notify() {
        this.reactiveFns.forEach(fn => {
          fn()
        })
      }
    }
    
  • reactive函式:實現將普通物件轉成代理物件,從而將其轉變為可響應式物件;

    function reactive(obj) {
      return new Proxy(obj, {
        get: function(target, key, receiver) {
          const dep = getDepend(target, key)
          // 直接呼叫addDepend方法,讓它去收集
          dep.addDependFn()
          return Reflect.get(target, key, receiver)
        },
        set: function(target, key, newValue, receiver) {
          Reflect.set(target, key, newValue, receiver)
          // 根據當前物件target和設定的key,去獲取對應的dep
          const dep = getDepend(target, key)
          // 當set捕獲器捕獲到屬性變化時,自動去呼叫notify
          dep.notify()
        }
      })
    }
    
  • getDepend函式:根據指定的物件和物件屬性(key)去查詢對應的dep物件;

    // 1.建立一個WeakMap儲存結構,存放物件
    const objWeakMap = new WeakMap()
    // 2.封裝一個獲取dep的函式
    function getDepend(obj, key) {
      // 2.1.根據物件,獲取對應的map
      let map = objWeakMap.get(obj)
      // 如果是第一次獲取這個map,那麼需要先建立一個map
      if (!map) {
        map = new Map()
        // 將map存到objWeakMap中對應key上
        objWeakMap.set(obj, map)
      }
    
      // 2.2.根據物件的屬性,獲取對應的dep
      let dep = map.get(key)
      // 如果是第一次獲取這個dep,那麼需要先建立一個dep
      if (!dep) {
        dep = new Depend()
        // 將dep存到map中對應的key上
        map.set(key, dep)
      }
    
      // 2.3最終將dep返回出去
      return dep
    }
    

總結:以上通過Proxy來監聽物件操作的實現響應式的方法就是Vue3響應式原理了。

6.Vue2響應式原理的實現

Vue3響應式原理已經實現了,那麼Vue2只需要將Proxy換成Object.defineProperty就可以了。

  • 將reactive函式改一下即可;
function reactive(obj) {
  // 1.拿到obj所有的key
  const keys = Object.keys(obj)

  // 2.遍歷所有的keys,新增存取屬性描述符
  keys.forEach(key => {
    let value = obj[key]

    Object.defineProperty(obj, key, {
      get: function() {
        const dep = getDepend(obj, key)
        // 直接呼叫addDepend方法,讓它去收集
        dep.addDependFn()
        return value
      },
      set: function(newValue) {
        value = newValue
        // 根據當前物件設定的key,去獲取對應的dep
        const dep = getDepend(obj, key)
        // 監聽到屬性變化時,自動去呼叫notify
        dep.notify()
      }
    })
  })

  // 3.將obj返回
  return obj
}