全面瞭解Vue3的 reactive 和相關函式

金色海洋(jyk)發表於2021-03-31

Vue3的 reactive 怎麼用,原理是什麼,官網上和reactive相關的那些函式又都是做什麼用處的?這裡會一一解答。

ES6的Proxy

Proxy 是 ES6 提供的一個可以攔截物件基礎操作的代理。因為 reactive 採用 Proxy 代理的方式,實現引用型別的響應性,所以我們先看看 Proxy 的基礎使用方法,以便於我理解 reactive 的結構。

我們先來定義一個函式,瞭解一下 Proxy 的基本使用方式:

// 定義一個函式,傳入物件原型,然後建立一個Proxy的代理
const myProxy = (_target) => {
  // 定義一個 Proxy 的例項
  const proxy = new Proxy(_target, {
    // 攔截 get 操作
    get: function (target, key, receiver) {
      console.log(`getting ${key}!`, target[key])
      // 用 Reflect 呼叫原型方法
      return Reflect.get(target, key, receiver)
    },
    // 攔截 set 操作
    set: function (target, key, value, receiver) {
      console.log(`setting ${key}:${value}!`)
      // 用 Reflect 呼叫原型方法
      return Reflect.set(target, key, value, receiver)
    }
  })
  // 返回例項
  return proxy
}

// 使用方法,是不是和reactive有點像?
const testProxy = myProxy({
  name: 'jyk',
  age: 18,
  contacts: {
    QQ: 11111,
    phone: 123456789
  }
})
console.log('自己定義的Proxy例項:')
console.log(testProxy)
// 測試攔截情況
testProxy.name = '新的名字' // set操作 
console.log(testProxy.name) // get 操作

Proxy 有兩個引數 target 和 handle。
* target:要代理的物件,也可以是陣列,但是不能是基礎型別。
* handler:設定要攔截的操作,這裡攔截了 set 和 get 操作,當然還可以攔截其他操作。

我們先來看一下執行結果:
自己寫的 Proxy 例項的執行結果

  • Handler 可以看到我們寫的攔截函式 get 和 set;
  • Target 可以看到物件原型。

注意:這裡只是實現了 get 和 set 的攔截,並沒有實現資料的雙向繫結,模板也不會自動更新內容,Vue內部做了很多操作才實現了模板的自動更新功能。

用 Proxy 給 reactive 套個娃,會怎麼樣?

有個奇怪的地方,既然 Proxy 可以實現對 set 等操作的攔截,那麼 reactive 為啥不返回一個可以監聽的鉤子呢?為啥要用 watch 來實現監聽的工作?

為啥會這麼想?因為看到了 Vuex4.0 的設計,明明已經把 state 整體自動變成了 reactive 的形式,那麼為啥還非得在 mutations 裡寫函式,實現 set 操作呢?好麻煩的樣子。

外部直接對 reactive 進行操作,然後 Vuex 內部監聽一下,這樣大家不就都省事了嗎?要實現外掛功能,還是跟蹤功能,不都是可以自動實現了嘛。

所以我覺得還是可以套個娃的。

實現模板的自動重新整理

本來以為上面那個 myProxy 函式,傳入一個 reactive 之後,就可以自動實現更新模板的功能了,結果模板沒理我。

這不對呀,我只是監聽了一下,不是又交給 reactive 了嗎?為啥模板不理我?

經過各種折騰,終於找到了原因,於是函式改成了這樣:

  /**
   * 用 Proxy定義一個 reactive 的套娃,實現可以監聽任意屬性變化的目的。(不包含巢狀物件的屬性)
   * @param {*} _target  要攔截的目標
   * @param {*} callback 屬性變化後的回撥函式
   */
  const myReactive = (_target, callback) => {
    let _change = (key, value) => {console.log('內部函式')}
    const proxy = new Proxy(_target, {
      get: function (target, key, receiver) {
        if (typeof key !== 'symbol') {
          console.log(`getting ${key}!`, target[key])
        } else {
          console.log('getting symbol:', key, target[key])
        }
        // 呼叫原型方法
        return Reflect.get(target, key, receiver)
      },
      set: function (target, key, value, receiver) {
        console.log(`setting ${key}:${value}!`)
        // 源頭監聽
        if (typeof callback === 'function') {
          callback(key, value)
        }
        // 任意位置監聽
        if (typeof _target.__watch === 'function') {
          _change(key, value)
        }
        // 呼叫原型方法
        return Reflect.set(target, key, value, target)  // 這裡有變化,最後一個引數改成 target
      }
    })
    // 實現任意位置的監聽,
    proxy.__watch = (callback) => {
      if (typeof callback === 'function') {
        _change = callback
      }
    }
    // 返回例項
    return proxy
  }

程式碼稍微多了一些,我們一塊一塊看。

  • get
    這裡要做一下 symbol 的判斷,否則會報錯。好吧,其實我們似乎不需要 console.log。

  • set
    這裡改了一下最後一個引數,這樣模板就可以自己更新了。

  • 設定 callback 函式,實現源頭監聽
    設定一個回撥函式,才能在攔截到set操作的時候,通知外部的呼叫者。只是這樣只適合於定義例項的地方。那麼接收引數的地方怎麼辦呢?

呼叫方法如下:

    // 定義一個攔截reactive的Proxy
    // 並且實現源頭的監聽
    const myProxyReactive = myReactive(retObject,
      ((key, value) =>{
        console.log(`ret外部獲得通知:${key}:${value}`)
      })
    )

這樣我們就可以在回撥函式裡面得到修改的屬性名稱,以及屬性值。

這樣我們做狀態管理的時候,是不是就不用特意去寫 mutations 裡面的函式了呢?

  • 內部設定一個鉤子函式
    設定一個 _change() 鉤子函式,這樣接收引數的地方,可以通過這個鉤子來得到變化的通知。

呼叫方法如下:

   // 任意位置的監聽
    myProxyReactive.__watch((key, value) => {
      console.log(`任意位置的監聽:${key}:${value}`)
    })

只是好像哪裡不對的樣子。
首先這個鉤子沒找到合適的地方放,目前放在了原型物件上面,就是說破壞了原型物件的結構,這個似乎會有些影響。

然後,接收引數的地方,不是可以直接得到修改的情況嗎?是否還需要做這樣的監聽?

最後,好像沒有 watch 的 deep 監聽來的方便,那麼問題又來了,為啥 Vuex 不用 watch 呢?或者悄悄的用了?

深層響應式代理:reactive

說了半天,終於進入正題了。
reactive 會返回物件的響應式代理,這種響應式轉換是深層的,可以影響所有的巢狀物件。

注意:返回的是 object 的代理,他們的地址是相同的,並沒有對object進行clone(克隆),所以修改代理的屬性值,也會影響原object的屬性值;同時,修改原object的屬性值,也會影響reactive返回的代理的屬性值,只是代理無法攔截直接對原object的操作,所以模板不會有變化。

這個問題並不明顯,因為我們一般不會先定義一個object,然後再套上reactive,而是直接定義一個 reactive,這樣也就“不存在”原 object 了,但是我們要了解一下原理。

我們先定義一個 reactive 例項,然後執行看結果。

// js物件
const person = {
  name: 'jyk',
  age: 18,
  contacts: {
    QQ: 11111,
    phone: 123456789
  }
}
// person 的 reactive 代理 (驗證地址是否相同)
const personReactive = reactive(person)
// js 物件 的 reactive 代理 (一般用法)
const objectReactive = reactive({
  name: 'jykReactive',
  age: 18,
  contacts: {
    QQ: 11111,
    phone: 123456789
  }
})

// 檢視 reactive 例項結構
console.log('reactive', objectReactive )

// 獲取巢狀物件屬性
const contacts = objectReactive .contacts
// 因為深層響應,所以依然有響應性
console.log('contacts屬性:', contacts)
 
// 獲取簡單型別的屬性
let name = objectReactive.name 
// name屬性是簡單型別的,所以失去響應性
console.log('name屬性:', name) 

執行結果:
reactive的列印結果

  • Handler:可以看到 Vue 除重寫 set 和 get 外,還重寫了deleteProperty、has和ownKeys。

  • Target: 指向一個Object,這是建立reactive例項時的物件。

屬性的結構:
reactive的屬性列印結果

然後再看一下兩個屬性的列印結果,因為 contacts 屬性是巢狀的物件,所以單獨拿出來也是具有響應性的。

而 name 屬性由於是 string 型別,所以單獨拿出來並不會自動獲得響應性,如果單獨拿出來還想保持響應性的話,可以使用toRef。

注意:如果在模板裡面使用{{personReactive.name}}的話,那麼也是有響應性的,因為這種用法是獲得物件的屬性值,可以被Proxy代理攔截,所以並不需要使用toRef。
如果想在模板裡面直接使用{{name}}並且要具有響應性,這時才需要使用toRef。

淺層響應式代理:shallowReactive

有的時候,我們並不需要巢狀屬性也具有響應性,這時可以使用shallowReactive 來獲得淺層的響應式代理,這種方式只攔截自己的屬性的操作,不涉及巢狀的物件屬性的操作。

const personShallowReactive = shallowReactive({
  name: 'jykShallowReactive',
  age: 18,
  contacts: {
    QQ: 11111,
    phone: 123456789
  }
})

// 檢視 shallowReactive 例項結構
console.log('shallowReactive', objectShallowReactive)

// 獲取巢狀物件屬性
const contacts = objectShallowReactive.contacts
// 因為淺層代理,所以沒有響應性
console.log('contacts屬性:', contacts)

// 獲取簡單型別的屬性
let name = objectShallowReactive.name 
// 因為淺層代理且簡單型別,所以失去響應性
console.log('name屬性:', name) 

shallowReactive的列印結果

shallowReactive 也是用 Proxy 實現響應性的,而單獨使用contacts屬性並沒有響應性,因為 shallowReactive 是淺層代理,所以不會讓巢狀物件獲得響應性。

注意:objectShallowReactive.contacts.QQ = 123 ,這樣修改屬性也是沒有響應性的。

單獨使用的屬性的形式:

shallowReactive的屬性

巢狀物件和name屬性,都沒有變成響應式。

做一個不允許響應的標記:markRaw

有的時候我們不希望js物件變成響應式的,這時我們可以用markRaw 做一個標記,這樣即使使用 reactive 也不會變成響應式。

如果確定某些資料是不會變化的,那麼也就不用變成響應式,這樣可以節省一些不必要的效能開銷。

// 標記js物件
const object = markRaw({
  name: 'jyk',
  age: 18,
  contacts: {
    QQ: 11111,
    phone: 123456789
  }
})

// 試圖對標記的物件做相應性代理
const retObject2 = reactive(object)
// 使用物件的屬性做相應性代理
const retObject1 = reactive({
  name: object.name
})
console.log('作為初始值:', retObject1) // 無法變成響應性代理
console.log('無法變成響應式:', retObject2) // 可以變成響應性代理

執行結果:

markRaw的列印結果

做標記後的js物件作為引數,不會變成響應式,但是使用屬性值作為引數,還是可以變成響應式。

那麼哪些地方可以用到呢?我們可以在給元件設定(引用型別的)屬性的時候使用,預設情況下元件的屬性都是自帶響應性的,但是如果父元件裡設定給子元件的屬性值永遠不會發生變化,那麼還變成響應式的話,就有點浪費效能的嫌疑了。

如果想節約一下的話,可以在父元件設定屬性的時候加上markRaw標記。

深層只讀響應式代理:readonly

有的時候雖然我們想得到一個響應式的代理,但是隻想被讀取,而不希望被修改(比如元件的props,元件內部不希望被修改),那麼這時候我們可以用readonly。

readonly可以返回object、reactive或者ref的深層只讀代理,我們來分別測試一下:

// object的只讀響應代理
const objectReadonly = readonly(person)
// reactive 的只讀響應代理
const reactiveReadonly = readonly(objectReactive)
// 檢視 readonly 例項結構
console.log('object 的readonly', objectReadonly)
console.log('reactive 的readonly', reactiveReadonly)

// 獲取巢狀物件屬性
const contacts = reactiveReadonly.contacts
console.log('contacts屬性:', contacts) // 因為深層響應,所以依然有響應性

// 獲取簡單型別的屬性
let name = reactiveReadonly.name 
console.log('name屬性:', name) // 屬性是簡單型別的,所以失去響應性

執行結果:

object的readonly

  • Handler,明顯攔截的函式變少了,set的引數也變少了,點進去看原始碼,也僅僅只有一行返回警告的程式碼,這樣實現攔截設定屬性的操作。
  • Target,指向object。

執行結果:

reactive的readonly

  • Handler,這部分是一樣的。
  • Target,指向的不是object,而是一個Proxy代理,也就是reactive。

淺層只讀響應代理:shallowReadonly

和readonly相對應,shallowReadonly是淺層的只讀響應代理,和readonly的使用方式一樣,只是不會限制巢狀物件只讀。

// object 的淺層只讀代理
const objectShallowReadonly = shallowReadonly(person)
// reactive 的淺層只讀代理
const reactiveShallowReadonly = shallowReadonly(objectReactive)

shallowReadonly的結構和 readonly 的一致,就不貼截圖了。

獲取原型:toRaw

toRaw 可以獲取 Vue 建立的代理的原型物件,但是不能獲取我們自己定義的Proxy的例項的原型。

toRaw大多是在Vue內部使用,目前只發現在向indexedDB裡面寫入資料的時候,需要先用 toRaw 取原型,否則會報錯。

// 獲取reactive、shallowReactive、readonly、shallowReadonly的原型
console.log('深層響應的原型', toRaw(objectReactive))
console.log('淺層響應的原型', toRaw(objectShallowReactive))
console.log('深層只讀的原型', toRaw(objectReadonly))
console.log('淺層只讀的原型', toRaw(objectShallowReadonly))

執行結果都是普通的object,就不貼截圖了。

型別判斷

Vue提供了三個用於判斷型別的函式:

* isProxy:判斷物件是否是Vue建立的Proxy代理,包含reactive、readonly、shallowReactive和shallowReadonly建立的代理,但是不會判斷自己寫的Proxy代理。

  • isReactive:判斷是否是reactive建立的代理。如果readonly的原型是reactive,那麼也會返回true。

* isReadonly:判斷是否是readonly、shallowReadonly建立的代理。這個最簡單,只看代理不看target。

我們用這三個函式判斷一下我們上面定義的這些Proxy代理,看看結果如何。

我們寫點程式碼對比一下:

    const myProxyObject = myProxy({title:'222', __v_isReactive: false})
    console.log('myProxyObject', myProxyObject)
    const myProxyReactive = myProxy(objectReactive)
    console.log('myProxyReactive', myProxyReactive)

    // 試一試 __v_isReadonly
    console.log('objectReactive', objectReactive)
    console.log('__v_isReadonly'
      , objectReactive.__v_isReadonly
      , objectReactive.__v_isReactive
      )

    return {
      obj: { // js物件
        check1: isProxy(person),
        check2: isReactive(person),
        check3: isReadonly(person)
      },
      myproxy: { // 自己定義的Proxy object
        check1: isProxy(myProxyObject),
        check2: isReactive(myProxyObject),
        check3: isReadonly(myProxyObject)
      },
      myproxyReactive: { // 自己定義的Proxy reactive
        check1: isProxy(myProxyReactive),
        check2: isReactive(myProxyReactive),
        check3: isReadonly(myProxyReactive)
      },
      // 深層響應  reactive(object)
      reto: { // reactive(object)
        check1: isProxy(objectReactive),
        check2: isReactive(objectReactive),
        check3: isReadonly(objectReactive)
      },
      // 淺層響應 引數:object
      shallowRetObj: {
        check1: isProxy(objectShallowReactive),
        check2: isReactive(objectShallowReactive),
        check3: isReadonly(objectShallowReactive)
      },
      // 淺層響應 引數:reactive
      shallowRetRet: {
        check1: isProxy(objectShallowReactive),
        check2: isReactive(objectShallowReactive),
        check3: isReadonly(objectShallowReactive)
      },

      // 深層只讀,引數 object =======================
      readObj: { // readonly object
        check1: isProxy(objectReadonly),
        check2: isReactive(objectReadonly),
        check3: isReadonly(objectReadonly)
      },
      // 深層只讀,引數 reactive
      readRet: { // readonly reactive
        check1: isProxy(reactiveReadonly),
        check2: isReactive(reactiveReadonly),
        check3: isReadonly(reactiveReadonly)
      },
      // 淺層只讀 引數:object
      shallowReadObj: {
        check1: isProxy(objectShallowReadonly),
        check2: isReactive(objectShallowReadonly),
        check3: isReadonly(objectShallowReadonly)
      },
      // 淺層只讀 引數:reactive
      shallowReadRet: {
        check1: isProxy(reactiveShallowReadonly),
        check2: isReactive(reactiveShallowReadonly),
        check3: isReadonly(reactiveShallowReadonly)
      },
      person
    }

對比結果:

驗證型別的對比測試

總結一下:

  • isReadonly 最簡單,只有readonly、shallowReadonly建立的代理才會返回 true,其他的都是 false。

  • isProxy也比較簡單,Vue建立的代理才會返回true,如果是自己定義的Proxy,要看原型是誰,如果原型是 reactive(包括其他三個)的話,也會返回true。

  • isReactive就有點複雜,reactive 建立的代理會返回 true,其他的代理(包含自己寫的)還要看一下原型,如果是 reactive 的話,也會返回true。

判斷依據

那麼這三個函式是依據什麼判斷的呢?自己做的 Proxy 無意中監控到了“__v_isReactive”,難道是隱藏屬性?測試了一下,果然是這樣。

myProxy({title:'測試隱藏屬性', __v_isReactive: true}),這樣定義一個例項,也會返回true。

reactive直接賦值的方法

使用的時候我們會發現一個問題,如果直接給 reactive 的例項賦值的話,就會“失去”響應性,這個並不是因為 reactive 失效了,而是因為 setup 只會執行一次,return也只有一次給模板提供資料(地址)的機會,模板只能得到一開始提供的 reactive 的地址,如果後續直接對 reactive 的例項賦值操作,會覆蓋原有的地址,產生一個新的Proxy代理地址,然而模板並不會得到這個新地址,還在使用“舊”地址,因為無法獲知新地址的存在,所以模板不會有變化。

那麼就不能直接賦值了嗎?其實還是有方法的,只需要保證地址不會發生變化即可。

物件的整體賦值的方法。

有請 ES6 的 Object.assign 登場,這個方法是用來合併兩個或者多個物件的屬性的,如果屬性名稱相同後面的屬性會覆蓋前面的屬性。所以大家在使用的時候要謹慎使用,確保兩個物件的屬性就相容的,不會衝突。

程式碼如下:

Object.assign(objectReactive, {name: '合併', age: 20, newProp: '新屬性'})

陣列的整體賦值的方法。

陣列就方便多了,可以先清空再 push 的方式,程式碼如下:

// retArray.length = 0 // 這裡清空的話,容易照成閃爍,所以不要急
setTimeout(() => {
  const newArray = [
    { name: '11', age: 18 },
    { name: '22', age: 18 }
  ]
  // 等到這裡再清空,就不閃爍了。
  retArray.length = 0
  retArray.push(...newArray)
}, 1000)

var 和 let、const

ES6 新增了 let 和 const,那麼我們應該如何選擇呢?
簡單的說,var不必繼續使用了。

let 和 const 的最大區別就是,前者是定義“變數”的,後者是定義“常量”的。

可能你會覺得奇怪,上面的程式碼都是用const定義的,但是後續程式碼都是各種改呀,怎麼就常量了?其實const判斷的是,地址是否改變,只要地址不變就可以。

對於基礎型別,值變了地址就變了;而對於引用型別來說,改屬性值的話,物件地址是不會發生變化的。

而 const 的這個特點整合可以用於保護 reactive 的例項。由Vue的機制決定,reactive的例項的地址是不可以改變的,變了的話模板就不會自動更新,const可以確保地址不變,變了會報錯(開發階段需要eslint支援)。

於是const和reactive(包括 ref 等)就成了絕配。

原始碼:

https://gitee.com/naturefw/nf-vue-cdn/tree/master/cdn/project-compositionapi

線上演示:

https://naturefw.gitee.io/nf-vue-cdn/cdn/project-compositionapi/

相關文章