Vue3 的響應式和之前有什麼區別,Proxy 無敵?

前言

你們都知道,Vue2 裏的響應式其實有點像是一個半徹底體,對於對象上新增的屬性無能爲力,對於數組則須要攔截它的原型方法來實現響應式。javascript

舉個例子:前端

let vm = new Vue({
  data() {
    return {
        a: 1
    }
  }
})

// ❌  oops,沒反應!
vm.b = 2
let vm = new Vue({
  data() {
    return {
        a: 1
    }
  },
  watch: {
    b() {
      console.log('change !!')
    }
  }
})

// ❌  oops,沒反應!
vm.b = 2

這種時候,Vue 提供了一個 api:this.$set,來使得新增的屬性也擁有響應式的效果。vue

可是對於不少新手來講,不少時候須要當心翼翼的去判斷到底什麼狀況下須要用 $set,何時能夠直接觸發響應式。java

總之,在 Vue3 中,這些都將成爲過去。本篇文章會帶你仔細講解,proxy 到底會給 Vue3 帶來怎麼樣的便利。而且會從源碼級別,告訴你這些都是如何實現的。react

響應式倉庫

Vue3 不一樣於 Vue2 也體如今源碼結構上,Vue3 把耦合性比較低的包分散在 packages 目錄下單獨發佈成 npm 包。 這也是目前很流行的一種大型項目管理方式 Monorepoios

其中負責響應式部分的倉庫就是 @vue/rectivity,它不涉及 Vue 的其餘的任何部分,是很是很是 「正交」 的一種實現方式。git

甚至能夠輕鬆的集成進 Reactes6

這也使得本篇的分析能夠更加聚焦的分析這一個倉庫,排除其餘無關部分。github

區別

Proxy 和 Object.defineProperty 的使用方法看似很類似,其實 Proxy 是在 「更高維度」 上去攔截屬性的修改的,怎麼理解呢?算法

Vue2 中,對於給定的 data,如 { count: 1 },是須要根據具體的 key 也就是 count,去對「修改 data.count 」 和 「讀取 data.count」進行攔截,也就是

Object.defineProperty(data, 'count', {
  get() {},
  set() {},
})

必須預先知道要攔截的 key 是什麼,這也就是爲何 Vue2 裏對於對象上的新增屬性無能爲力。

而 Vue3 所使用的 Proxy,則是這樣攔截的:

new Proxy(data, {
  get(key) { },
  set(key, value) { },
})

能夠看到,根本不須要關心具體的 key,它去攔截的是 「修改 data 上的任意 key」 和 「讀取 data 上的任意 key」。

因此,不論是已有的 key 仍是新增的 key,都逃不過它的魔爪。

可是 Proxy 更增強大的地方還在於 Proxy 除了 get 和 set,還能夠攔截更多的操做符。

簡單的例子🌰

先寫一個 Vue3 響應式的最小案例,本文的相關案例都只會用 reactiveeffect 這兩個 api。若是你瞭解過 React 中的 useEffect,相信你會對這個概念秒懂,Vue3 的 effect 不過就是去掉了手動聲明依賴的「進化版」的 useEffect

React 中手動聲明 [data.count] 這個依賴的步驟被 Vue3 內部直接作掉了,在 effect 函數內部讀取到 data.count 的時候,它就已經被收集做爲依賴了。

Vue3:

// 響應式數據
const data = reactive({ 
  count: 1
})

// 觀測變化
effect(() => console.log('count changed', data.count))

// 觸發 console.log('count changed', data.count) 從新執行
data.count = 2

React:

// 數據
const [data, setData] = useState({
  count: 1
})

// 觀測變化 須要手動聲明依賴
useEffect(() => {
  console.log('count changed', data.count)
}, [data.count])

// 觸發 console.log('count changed', data.count) 從新執行
setData({
  count: 2
})

其實看到這個案例,聰明的你也能夠把 effect 中的回調函數聯想到視圖的從新渲染、 watch 的回調函數等等…… 它們是一樣基於這套響應式機制的。

而本文的核心目的,就是探究這個基於 Proxy 的 reactive api,到底能強大到什麼程度,能監聽到用戶對於什麼程度的修改。

先講講原理

先最小化的講解一下響應式的原理,其實就是在 Proxy 第二個參數 handler 也就是陷阱操做符中,攔截各類取值、賦值操做,依託 tracktrigger 兩個函數進行依賴收集和派發更新。

track 用來在讀取時收集依賴。

trigger 用來在更新時觸發依賴。

track

function track(target: object, type: TrackOpTypes, key: unknown) {
  const depsMap = targetMap.get(target);
  // 收集依賴時 經過 key 創建一個 set
  let dep = new Set()
  targetMap.set(ITERATE_KEY, dep)
  // 這個 effect 能夠先理解爲更新函數 存放在 dep 裏
  dep.add(effect)    
}

target 是原對象。

type 是本次收集的類型,也就是收集依賴的時候用來標識是什麼類型的操做,好比上文依賴中的類型就是 get,這個後續會詳細講解。

key 是指本次訪問的是數據中的哪一個 key,好比上文例子中收集依賴的 key 就是 count

首先全局會存在一個 targetMap,它用來創建 數據 -> 依賴 的映射,它是一個 WeakMap 數據結構。

targetMap 經過數據 target,能夠獲取到 depsMap,它用來存放這個數據對應的全部響應式依賴。

depsMap 的每一項則是一個 Set 數據結構,而這個 Set 就存放着對應 key 的更新函數。

是否是有點繞?咱們用一個具體的例子來舉例吧。

const target = { count: 1}
const data = reactive(target)

const effection = effect(() => {
  console.log(data.count)
})

對於這個例子的依賴關係,

  1. 全局的 targetMap 是:
targetMap: {
  { count: 1 }: dep    
}
  1. dep 則是
dep: {
  count: Set { effection }
}

這樣一層層的下去,就能夠經過 target 找到 count 對應的更新函數 effection 了。

trigger

這裏是最小化的實現,僅僅爲了便於理解原理,實際上要複雜不少,

其實 type 的做用很關鍵,先記住,後面會詳細講。

export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
) {
  // 簡化來講 就是經過 key 找到全部更新函數 依次執行
  const dep = targetMap.get(target)
  dep.get(key).forEach(effect => effect())
}

新增屬性

這個上文已經講了,因爲 Proxy 徹底不關心具體的 key,因此沒問題。

// 響應式數據
const data = reactive({ 
  count: 1
})

// 觀測變化
effect(() => console.log('newCount changed', data.newCount))

// ✅ 觸發響應
data.newCount = 2

數組新增索引:

// 響應式數據
const data = reactive([])

// 觀測變化
effect(() => console.log('data[1] changed', data[1]))

// ✅ 觸發響應
data[1] = 5

數組調用原生方法:

const data = reactive([])
effect(() => console.log('c', data[1]))

// 沒反應
data.push(1)

// ✅ 觸發響應 由於修改了下標爲 1 的值
data.push(2)

其實這一個案例就比較有意思了,咱們僅僅是在調用 push,可是等到數組的第二項被 push的時候,咱們以前關注 data[1] 爲依賴的回調函數也執行了,這是什麼原理呢?寫個簡單的 Proxy 就知道了。

const raw = []
const arr = new Proxy(raw, {
  get(target, key) {
    console.log('get', key)
    return Reflect.get(target, key)
  },
  set(target, key, value) {
    console.log('set', key)
    return Reflect.set(target, key, value)
  }
})

arr.push(1)

在這個案例中,咱們只是打印出了對於 raw 這個數組上的全部 get、set 操做,而且調用 Reflect 這個 api 原樣處理取值和賦值操做後返回。看看 arr.push(1) 後控制檯打印出了什麼?

get push
get length
set 0
set length

原來一個小小的 push,會觸發兩對 get 和 set,咱們來想象一下流程:

  1. 讀取 push 方法
  2. 讀取 arr 原有的 length 屬性
  3. 對於數組第 0 項賦值
  4. 對於 length 屬性賦值

這裏的重點是第三步,對於第 index 項的賦值,那麼下次再 push,能夠想象也就是對於第 1 項觸發 set 操做。

而咱們在例子中讀取 data[1],是必定會把對於 1 這個下標的依賴收集起來的,這也就清楚的解釋了爲何 push 的時候也能精準的觸發響應式依賴的執行。

對了,記住這個對於 length 的 set 操做,後面也會用到,很重要。

遍歷後新增

// 響應式數據
const data = reactive([])

// 觀測變化
effect(() => console.log('data map +1', data.map(item => item + 1))

// ✅ 觸發響應 打印出 [2]
data.push(1)

這個攔截很神奇,可是也很合理,轉化成現實裏的一個例子來看,

假設咱們要根據學生 id 的集合 ids, 去請求學生詳細信息,那麼僅僅是須要這樣寫便可:

const state = reactive({})
const ids = reactive([1])

effect(async () => {
  state.students = await axios.get('students/batch', ids.map(id => ({ id })))
})

// ✅ 觸發響應 
ids.push(2)

這樣,每次調用各類 api 改變 ids 數組,都會從新發送請求獲取最新的學生列表。

若是我在監聽函數中調用了 map、forEach 等 api,

說明我關心這個數組的長度變化,那麼 push 的時候觸發響應是徹底正確的。

可是它是如何實現的呢?感受彷佛很複雜啊。

由於 effect 第一次執行的時候, data 仍是個空數組,怎麼會 push 的時候能觸發更新呢?

仍是用剛剛的小測試,看看 map 的時候會發生什麼事情。

const raw = [1, 2]
const arr = new Proxy(raw, {
  get(target, key) {
    console.log('get', key)
    return Reflect.get(target, key)
  },
  set(target, key, value) {
    console.log('set', key)
    return Reflect.set(target, key, value)
  }
})

arr.map(v => v + 1)
get map
get length
get constructor
get 0
get 1

和 push 的部分有什麼相同的?找一下線索,咱們發現 map 的時候會觸發 get length,而在觸發更新的時候, Vue3 內部會對 「新增 key」 的操做進行特殊處理,這裏是新增了 0 這個下標的值,會走到 trigger 中這樣的一段邏輯裏去:

源碼地址

// 簡化版
if (isAddOrDelete) {
  add(depsMap.get('length'))
}

把以前讀取 length 時收集到的依賴拿到,而後觸發函數。

這就一目瞭然了,咱們在 effect 裏 map 操做讀取了 length,收集了 length 的依賴。

在新增 key 的時候, 觸發 length 收集到的依賴,觸發回調函數便可。

對了,對於 for of 操做,也同樣可行:

// 響應式數據
const data = reactive([])

// 觀測變化
effect(() => {
  for (const val of data) {
    console.log('val', val)
  }
})

// ✅ 觸發響應 打印出 val 1
data.push(1)

能夠按咱們剛剛的小試驗本身跑一下攔截, for of 也會觸發 length 的讀取。

length 真是個好同志…… 幫了大忙了。

遍歷後刪除或者清空

注意上面的源碼裏的判斷條件是 isAddOrDelete,因此刪除的時候也是同理,藉助了 length 上收集到的依賴。

// 簡化版
if (isAddOrDelete) {
  add(depsMap.get('length'))
}
const arr = reactive([1])
  
effect(() => {
  console.log('arr', arr.map(v => v))
})

// ✅ 觸發響應 
arr.length = 0

// ✅ 觸發響應 
arr.splice(0, 1)

真的是什麼操做都能響應,愛了愛了。

獲取 keys

const obj = reactive({ a: 1 })
  
effect(() => {
  console.log('keys', Reflect.ownKeys(obj))
})

effect(() => {
  console.log('keys', Object.keys(obj))
})

effect(() => {
  for (let key in obj) {
    console.log(key)
  }
})

// ✅ 觸發全部響應 
obj.b = 2

這幾種獲取 key 的方式都能成功的攔截,其實這是由於 Vue 內部攔截了 ownKeys 操做符。

const ITERATE_KEY = Symbol( 'iterate' );

function ownKeys(target) {
    track(target, "iterate", ITERATE_KEY);
    return Reflect.ownKeys(target);
}

ITERATE_KEY 就做爲一個特殊的標識符,表示這是讀取 key 的時候收集到的依賴。它會被做爲依賴收集的 key。

那麼在觸發更新時,其實就對應這段源碼:

if (isAddOrDelete) {
    add(depsMap.get(isArray(target) ? 'length' : ITERATE_KEY));
}

其實就是咱們聊數組的時候,代碼簡化掉的那部分。判斷非數組,則觸發 ITERATE_KEY 對應的依賴。

小彩蛋:

Reflect.ownKeysObject.keysfor in 其實行爲是不一樣的,

Reflect.ownKeys 能夠收集到 Symbol 類型的 key,不可枚舉的 key。

舉例來講:

var a = {
  [Symbol(2)]: 2,
}

Object.defineProperty(a, 'b', {
  enumerable: false,
})

Reflect.ownKeys(a) // [Symbol(2), 'b']
Object.keys(a) // []

回看剛剛提到的 ownKeys 攔截,

function ownKeys(target) {
    track(target, "iterate", ITERATE_KEY);
    // 這裏直接返回 Reflect.ownKeys(target)
    return  Reflect.ownKeys(target);
}

內部直接之間返回了 Reflect.ownKeys(target),按理來講這個時候 Object.keys 的操做通過了這個攔截,也會按照 Reflect.ownKeys 的行爲去返回值。

然而最後返回的結果卻仍是 Object.keys 的結果,這是比較神奇的一點。

刪除對象屬性

有了上面 ownKeys 的基礎,咱們再來看看這個例子

const obj = reactive({ a: 1, b: 2})
  
effect(() => {
  console.log(Object.keys(obj))
})

// ✅ 觸發響應 
delete obj['b']

這也是個神奇的操做,原理在於對於 deleteProperty 操做符的攔截:

function deleteProperty(target: object, key: string | symbol): boolean {
  const result = Reflect.deleteProperty(target, key)
  trigger(target, TriggerOpTypes.DELETE, key)
  return result
}

這裏又用到了 TriggerOpTypes.DELETE 的類型,根據上面的經驗,必定對它有一些特殊的處理。

其實仍是 trigger 中的那段邏輯:

const isAddOrDelete = type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE
if (isAddOrDelete) {
  add(depsMap.get(isArray(target) ? 'length' : ITERATE_KEY))
}

這裏的 target 不是數組,因此仍是會去觸發 ITERATE_KEY 收集的依賴,也就是上面例子中剛提到的對於 key 的讀取收集到的依賴。

判斷屬性是否存在

const obj = reactive({})

effect(() => {
  console.log('has', Reflect.has(obj, 'a'))
})

effect(() => {
  console.log('has', 'a' in obj)
})

// ✅ 觸發兩次響應 
obj.a = 1

這個就很簡單了,就是利用了 has 操做符的攔截。

function has(target, key) {
  const result = Reflect.has(target, key);
  track(target, "has", key);
  return result;
}

性能

  1. 首先 Proxy 做爲瀏覽器的新標準,性能上是必定會獲得廠商的大力優化的,拭目以待。
  2. Vue3 對於響應式數據,再也不像 Vue2 中那樣遞歸對全部的子數據進行響應式定義了,而是再獲取到深層數據的時候再去利用 reactive 進一步定義響應式,這對於大量數據的初始化場景來講收益會很是大。

好比,對於

const obj = reactive({
  foo: {
    bar: 1
  }
})

初始化定義 reactive 的時候,只會對 obj 淺層定義響應式,而真正讀取到 obj.foo 的時候,纔會對 foo 這一層對象定義響應式,簡化源碼以下:

function get(target: object, key: string | symbol, receiver: object) {
  const res = Reflect.get(target, key, receiver)
  // 這段就是惰性定義
  return isObject(res)
    ? reactive(res)
    : res
}

推薦閱讀

其實 Vue3 對於 MapSet 這兩種數據類型也是徹底支持響應式的,對於它們的原型方法也都作了完善的攔截,限於篇幅緣由本文再也不贅述。

說實話 Vue3 的響應式部分代碼邏輯分支仍是有點過多,對於代碼理解不是很友好,由於它還會涉及到 readonly 等只讀化的操做,若是看完這篇文章你對於 Vue3 的響應式原理很是感興趣的話,建議從簡化版的庫入手去讀源碼。

這裏我推薦 observer-util,我解讀過這個庫的源碼,和 Vue3 的實現原理基本上是如出一轍!可是簡單了不少。麻雀雖小,五臟俱全。裏面的註釋也很齊全。

固然,若是你的英文不是很熟練,也能夠看我精心用 TypeScript + 中文註釋基於 observer-util 重寫的這套代碼:
typescript-proxy-reactive

對於這個庫的解讀,能夠看我以前的兩篇文章:

帶你完全搞懂Vue3的Proxy響應式原理!TypeScript從零實現基於Proxy的響應式庫。

帶你完全搞懂Vue3的Proxy響應式原理!基於函數劫持實現Map和Set的響應式

在第二篇文章裏,你也能夠對於 Map 和 Set 能夠作什麼攔截操做,得到源碼級別的理解。

總結

Vue3 的 Proxy 真的很強大,把 Vue2 裏我認爲心智負擔很大的一部分給解決掉了。(在我剛上手 Vue 的時候,我是真的不知道什麼狀況下該用 $set),它的 composition-api 又能夠完美對標 React Hook,而且得益於響應式系統的強大,在某些方面是優勝於它的。精讀《Vue3.0 Function API》

但願這篇文章能在 Vue3 正式到來以前,提早帶你熟悉 Vue3 的一些新特性。

擴展閱讀

Proxy 的攔截器裏有個 receiver 參數,在本文中爲了簡化沒有體現出來,它是用來作什麼的?國內的網站比較少能找到這個資料:

new Proxy(raw, {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver)
  }
})

能夠看 StackOverflow 上的問答:what-is-a-receiver-in-javascript

也能夠看個人總結
Proxy 和 Reflect 中的 receiver 究竟是什麼?

廣告時間

優秀的小冊做者修言大佬爲前端想學算法的小夥伴們推出了一本零基礎也能入門的算法小冊,幫助你掌握一些基礎算法核心思想或簡單算法問題,這本小冊我參與了內測過程,也給修言大大提出了不少意見。他的目標就是作面向算法零基礎前端人羣的「保姆式服務」,很是貼心了~

求點贊

若是本文對你有幫助,就點個贊支持下吧,你的「贊」是我持續進行創做的動力,讓我知道你喜歡看個人文章吧~

❤️感謝你們

關注公衆號「前端從進階到入院」便可加我好友,我拉你進「前端進階交流羣」,你們一塊兒共同交流和進步。

相關文章
相關標籤/搜索