你們都知道,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
包。 這也是目前很流行的一種大型項目管理方式 Monorepo
。ios
其中負責響應式部分的倉庫就是 @vue/rectivity,它不涉及 Vue 的其餘的任何部分,是很是很是 「正交」 的一種實現方式。git
甚至能夠輕鬆的集成進 React。es6
這也使得本篇的分析能夠更加聚焦的分析這一個倉庫,排除其餘無關部分。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 響應式的最小案例,本文的相關案例都只會用 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
用來在更新時觸發依賴。
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: { count: Set { effection } }
這樣一層層的下去,就能夠經過 target
找到 count
對應的更新函數 effection
了。
這裏是最小化的實現,僅僅爲了便於理解原理,實際上要複雜不少,
其實 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,咱們來想象一下流程:
這裏的重點是第三步,對於第 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)
真的是什麼操做都能響應,愛了愛了。
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", 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; }
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 對於 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, { get(target, key, receiver) { return Reflect.get(target, key, receiver) } })
能夠看 StackOverflow 上的問答:what-is-a-receiver-in-javascript
也能夠看個人總結
Proxy 和 Reflect 中的 receiver 究竟是什麼?
優秀的小冊做者修言大佬爲前端想學算法的小夥伴們推出了一本零基礎也能入門的算法小冊,幫助你掌握一些基礎算法核心思想或簡單算法問題,這本小冊我參與了內測過程,也給修言大大提出了不少意見。他的目標就是作面向算法零基礎前端人羣的「保姆式服務」,很是貼心了~
若是本文對你有幫助,就點個贊支持下吧,你的「贊」是我持續進行創做的動力,讓我知道你喜歡看個人文章吧~
關注公衆號「前端從進階到入院」便可加我好友,我拉你進「前端進階交流羣」,你們一塊兒共同交流和進步。