1. 程式人生 > 程式設計 >Vue3 的響應式和以前有什麼區別,Proxy 無敵?

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

前言
大家都知道,Vue2 裡的響應式其實有點像是一個半完全體,對於物件上新增的屬性無能為力,對於陣列則需要攔截它的原型方法來實現響應式。
舉個例子:
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,來使得新增的屬性也擁有響應式的效果。

但是對於很多新手來說,很多時候需要小心翼翼的去判斷到底什麼情況下需要用 $set,什麼時候可以直接觸發響應式。
總之,在 Vue3 中,這些都將成為過去。本篇文章會帶你仔細講解,proxy 到底會給 Vue3 帶來怎麼樣的便利。並且會從原始碼級別,告訴你這些都是如何實現的。
響應式倉庫
Vue3 不同於 Vue2 也體現在原始碼結構上,Vue3 把耦合性比較低的包分散在 packages 目錄下單獨釋出成 npm 包。 這也是目前很流行的一種大型專案管理方式 Monorepo。
其中負責響應式部分的倉庫就是 @vue/rectivity,它不涉及 Vue 的其他的任何部分,是非常非常 「正交」 的一種實現方式。
甚至可以輕鬆的整合進 React。
這也使得本篇的分析可以更加聚焦的分析這一個倉庫,排除其他無關部分。
區別
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 響應式的最小案例,本文的相關案例都只會用 reactive 和 effect 這兩個 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 也就是陷阱操作符中,攔截各種取值、賦值操作,依託 track 和 trigger 兩個函式進行依賴收集和派發更新。
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)
})
對於這個例子的依賴關係,

全域性的 targetMap 是:

targetMap: {
{ count: 1 }: dep
}

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,value)
}
})

arr.push(1)
在這個案例中,我們只是打印出了對於 raw 這個陣列上的所有 get、set 操作,並且呼叫 Reflect 這個 api 原樣處理取值和賦值操作後返回。看看 arr.push(1) 後控制檯打印出了什麼?
get push
get length
set 0
set length
原來一個小小的 push,會觸發兩對 get 和 set,我們來想象一下流程:

讀取 push 方法
讀取 arr 原有的 length 屬性
對於陣列第 0 項賦值
對於 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,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.ownKeys、 Object.keys 和 for 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_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;
}
效能

首先 Proxy 作為瀏覽器的新標準,效能上是一定會得到廠商的大力優化的,拭目以待。
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,receiver)
// 這段就是惰性定義
return isObject(res)
? reactive(res)
: res
}
推薦閱讀
其實 Vue3 對於 Map 和 Set 這兩種資料型別也是完全支援響應式的,對於它們的原型方法也都做了完善的攔截,限於篇幅原因本文不再贅述。
說實話 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,receiver) {
return Reflect.get(target,receiver)
}
})
可以看 StackOverflow 上的問答:what-is-a-receiver-in-javascript
也可以看我的總結
Proxy 和 Reflect 中的 receiver 到底是什麼?