帶你徹底搞懂Vue3的Proxy響應式原理!TypeScript從零實現基於Proxy的響應式庫。
- 2020 年 4 月 11 日
- 筆記
前言
筆者最近在瀏覽React狀態管理庫的時候,發現了一些響應式的狀態管理庫如 hodux
,react-easy-state
,內部有一個基於proxy實現響應式的基礎倉庫observer-util
,它的代碼實現和Vue3中的響應式原理非常相似,這篇文章就從這個倉庫入手,一步一步帶你剖析響應式的實現。
本篇是系列第一篇,主要講解了普通對象的響應式源碼
系列終結篇也已經發佈,講解Map和Set的特殊響應式流程 帶你徹底搞懂Vue3的Proxy響應式原理!基於函數劫持實現Map和Set的響應式
本文的代碼是我參考observer-util
用ts的重寫的,並且會加上非常詳細的注釋。
閱讀本文可能需要的一些前置知識:
首先看一下observer-util
給出的代碼示例:
import { observable, observe } from '@nx-js/observer-util'; const counter = observable({ num: 0 }); // 會在控制台打印出0 const countLogger = observe(() => console.log(counter.num)); // 會在控制台打印出1 counter.num++; 複製代碼
這就是一個最精簡的響應式模型了,乍一看好像和Vue2里的響應式系統也沒啥區別,那麼還是先看一下Vue2和Vue3響應式系統之間的差異吧。
和Vue2的差異
關於Vue2的響應式原理,感興趣的也可以去看我之前的一篇文章: 實現一個最精簡的響應式系統來學習Vue的data、computed、watch源碼
其實這個問題本質上就是基於Proxy和基於Object.defineProperty之間的差異,來看Vue2中的一個案例:
Object.defineProperty
<template> {{ obj.c }} </template> <script> export default { data: { obj: { a: 1 }, }, mounted() { this.obj.c = 3 } } </script> 複製代碼
這個例子中,我們對obj上原本不存在的c
屬性進行了一個賦值,但是在Vue2中,這是不會觸發視圖的響應式更新的,
這是因為Object.defineProperty必須對於確定的key
值進行響應式的定義,
這就導致了如果data在初始化的時候沒有c
屬性,那麼後續對於c
屬性的賦值都不會觸發Object.defineProperty中對於set
的劫持,
在Vue2中,這裡只能用一個額外的api Vue.set
來解決,
Proxy
再看一下Proxy
的api,
const raw = {} const data = new Proxy(raw, { get(target, key) { }, set(target, key, value) { } }) 複製代碼
可以看出來,Proxy在定義的時候並不用關心key值,
只要你定義了get方法,那麼後續對於data上任何屬性的訪問(哪怕是不存在的),
都會觸發get
的劫持,set
也是同理。
這樣Vue3中,對於需要定義響應式的值,初始化時候的要求就沒那麼高了,只要保證它是個可以被Proxy接受的對象或者數組類型即可。
當然,Proxy對於數據攔截帶來的便利還不止於此,往下看就知道。
實現
接下來就一步步實現這個基於Proxy的響應式系統:
類型描述
本倉庫基於TypeScript重構,所以會有一個類型定義的文件,可以當做接口先大致看一下
思路
首先響應式的思路無外乎這樣一個模型:
- 定義某個數據為
響應式數據
,它會擁有收集訪問它的函數
的能力。 - 定義觀察函數,在這個函數內部去訪問
響應式數據
,訪問到響應式數據
的某個key的時候,會建立一個依賴關係key -> reaction觀察函數
。 - 檢測到
響應式數據
的key
的值更新的時候,會去重新執行一遍它所收集的所有reaction觀察函數
。
以開頭的例子來說
// 響應式數據 const counter = observable({ num: 0 }); // 觀察函數 observe(() => console.log(counter.num)); 複製代碼
這已經一目了然了,
- 用
observable
包裹的數據叫做響應式數據, - 在
observe
內部執行的函數叫觀察函數
。
定義時
observable({ num: 0 })
,會讓{ num: 0 }
這個普通的對象變成一個proxy,而後續對於這個proxy所有的get
、set
等操作都會被我們內部攔截下來。
訪問時
observe函數會先開啟一個開始觀察
的開關,然後幫你去執行console.log(counter.num)
,執行到counter.num的時候
我們註冊在counter這個proxy
的get
攔截到了對於counter.num
的訪問,
這時候又可以知道訪問者是() => console.log(counter.num)
這個函數,
那麼就把這個函數作為num
這個key值的觀察函數
收集在一個地方。
修改時
下次對於counter.num
修改的時候,會去找num
這個key下所有的觀察函數
,輪流執行一遍。
這樣就實現了響應式模型。
reactive的實現(定義響應式數據)
上文中關於observable
的api,我換了個名字: reactive
,感覺更好理解一些。
// 需要定義響應式的原值 export type Raw = object // 定義成響應式後的proxy export type ReactiveProxy = object // 用來存儲原始值和響應式proxy的映射 export const proxyToRaw = new WeakMap<ReactiveProxy, Raw>() // 用來存儲響應式proxy和原始值的映射 export const rawToProxy = new WeakMap<Raw, ReactiveProxy>() function createReactive<T extends Raw>(raw: T): T { const reactive = new Proxy(raw, baseHandlers) // 雙向存儲原始值和響應式proxy的映射 rawToProxy.set(raw, reactive) proxyToRaw.set(reactive, raw) // 建立一個映射 // 原始值 -> 存儲這個原始值的各個key收集到的依賴函數的Map storeObservable(raw) // 返迴響應式proxy return reactive as T } 複製代碼
首先是定義proxy
const reactive = new Proxy(raw, baseHandlers) 複製代碼
這個baseHandlers里就是對於數據的get
、set
之類的劫持,
這裡有兩個WeakMap: proxyToRaw
和rawToProxy
,
可以看到在定義響應式數據為一個Proxy的時候,會進行一個雙向的存儲,
這樣後續無論是拿到原始對象還是拿到響應式proxy,都可以很容易的拿到它們的另一半
。
之後storeObservable
,是用原始對象建立一個map:
const connectionStore = new WeakMap<Raw, ReactionForRaw>() function storeObservable(value: object) { // 存儲對象和它內部的key -> reaction的映射 connectionStore.set(value, new Map() as ReactionForRaw) } 複製代碼
通過connectionStore的泛型也可以知道,
這是一個Raw
-> ReactionForRaw
的map。
也就是原始數據
-> 這個數據收集到的觀察函數依賴
更清晰的描述可以看Type定義:
// 收集響應依賴的的函數 export type ReactionFunction = Function & { cleaners?: ReactionForKey[] unobserved?: boolean } // reactionForRaw的key為對象key值 value為這個key值收集到的Reaction集合 export type ReactionForRaw = Map<Key, ReactionForKey> // key值收集到的Reaction集合 export type ReactionForKey = Set<ReactionFunction> // 收集響應依賴的的函數 export type ReactionFunction = Function & { cleaners?: ReactionForKey[] unobserved?: boolean } 複製代碼
那接下來的重點就是proxy的第二個參數baseHandler
里的get
和set
了
proxy的handler
/** 劫持get訪問 收集依賴 */ function get(target: Raw, key: Key, receiver: ReactiveProxy) { const result = Reflect.get(target, key, receiver) // 收集依賴 registerRunningReaction({ target, key, receiver, type: "get" }) return result } 複製代碼
關於receiver這個參數,這裡可以先簡單理解為響應式proxy
本身,不影響流程。
這裡就是簡單的做了一個求值,然後進入了registerRunningReaction
函數,
get收集依賴
// 收集響應依賴的的函數 type ReactionFunction = Function & { cleaners?: ReactionForKey[] unobserved?: boolean } // 操作符 用來做依賴收集和觸發依賴更新 interface Operation { type: "get" | "iterate" | "add" | "set" | "delete" | "clear" target: object key?: Key receiver?: any value?: any oldValue?: any } /** 依賴收集棧 */ const reactionStack: ReactionFunction[] = [] /** 依賴收集 在get操作的時候要調用 */ export function registerRunningReaction(operation: Operation) { const runningReaction = getRunningReaction() if (runningReaction) { // 拿到原始對象 -> 觀察者的map const reactionsForRaw = connectionStore.get(target) // 拿到key -> 觀察者的set let reactionsForKey = reactionsForRaw.get(key) if (!reactionsForKey) { // 如果這個key之前沒有收集過觀察函數 就新建一個 reactionsForKey = new Set() // set到整個value的存儲里去 reactionsForRaw.set(key, reactionsForKey) } if (!reactionsForKey.has(reaction)) { // 把這個key對應的觀察函數收集起來 reactionsForKey.add(reaction) // 把key收集的觀察函數集合 加到cleaners隊列中 便於後續取消觀察 reaction.cleaners.push(reactionsForKey) } } } /** 從棧的末尾取到正在運行的observe包裹的函數 */ function getRunningReaction() { const [runningReaction] = reactionStack.slice(-1) return runningReaction } 複製代碼
這裡做的一系列操作,就是把用原始數據
從connectionStore
里拿到依賴收集的ma【p,
然後在reaction
觀察函數把對於某個key
訪問的時候,把reaction
觀察函數本身增加到這個key
的觀察函數集合里,對於observe(() => console.log(counter.num));
這個例子來說,就會收集到 { num -> Set<Reaction >}
。
注意這裡對於數組來說,也是一樣的流程,只是數組訪問的key是下標數字而已。 所以會收集類似於 { 1 -> Set<Reaction>}
這樣的結構。
那麼這個runningReaction
正在運行的觀察函數是哪來的呢,劇透一下,當然是observe
這個api內部開啟觀察模式後去做的。
// 此時 () => console.log(counter.num) 會被包裝成reaction函數 observe(() => console.log(counter.num)); 複製代碼
set觸發更新
/** 劫持set訪問 觸發收集到的觀察函數 */ function set(target: Raw, key: Key, value: any, receiver: ReactiveProxy) { // 拿到舊值 const oldValue = target[key] // 設置新值 const result = Reflect.set(target, key, value, receiver) queueReactionsForOperation({ target, key, value, oldValue, receiver, type: 'set' }) return result } /** 值更新時觸發觀察函數 */ export function queueReactionsForOperation(operation: Operation) { getReactionsForOperation(operation).forEach(reaction => reaction()) } /** * 根據key,type和原始對象 拿到需要觸發的所有觀察函數 */ export function getReactionsForOperation({ target, key, type }: Operation) { // 拿到原始對象 -> 觀察者的map const reactionsForTarget = connectionStore.get(target) const reactionsForKey: ReactionForKey = new Set() // 把所有需要觸發的觀察函數都收集到新的set里 addReactionsForKey(reactionsForKey, reactionsForTarget, key) return reactionsForKey } 複製代碼
set
賦值操作的時候,本質上就是去檢查這個key
收集到了哪些reaction
觀察函數,然後依次觸發。(數組也是同理)
observe 觀察函數
observe
這個api接受一個用戶傳入的函數,在這個函數內訪問響應式數據才會去收集觀察函數作為自己的依賴。
/** * 觀察函數 * 在傳入的函數里去訪問響應式的proxy 會收集傳入的函數作為依賴 * 下次訪問的key發生變化的時候 就會重新運行這個函數 */ export function observe(fn: Function): ReactionFunction { // reaction是包裝了原始函數只後的觀察函數 // 在runReactionWrap的上下文中執行原始函數 可以收集到依賴。 const reaction: ReactionFunction = (...args: any[]) => { return runReactionWrap(reaction, fn, this, args) } // 先執行一遍reaction reaction() // 返回出去 讓外部也可以手動調用 return reaction } 複製代碼
核心的邏輯在runReactionWrap
里,
/** 把函數包裹為觀察函數 */ export function runReactionWrap( reaction: ReactionFunction, fn: Function, context: any, args: any[], ) { try { // 把當前的觀察函數推入棧內 開始觀察響應式proxy reactionStack.push(reaction) // 運行用戶傳入的函數 這個函數里訪問proxy就會收集reaction函數作為依賴了 return Reflect.apply(fn, context, args) } finally { // 運行完了永遠要出棧 reactionStack.pop() } } 複製代碼
簡化後的核心邏輯很簡單,
把reaction
推入reactionStack
後開始執行用戶傳入的函數,
在函數內訪問響應式proxy
的屬性,又會觸發get
的攔截,
這時候get
去reactionStack
找當前正在運行的reaction
,就可以成功的收集到依賴了。
下一次用戶進行賦值的時候
const counter = reactive({ num: 0 }); // 會在控制台打印出0 const counterReaction = observe(() => console.log(counter.num)); // 會在控制台打印出1 counter.num = 1; 複製代碼
以這個示例來說,observe內部對於counter的key值num的
訪問,會收集counterReaction
作為num
的依賴。
counter.num = 1
的操作,會觸發對於counter的set
劫持,此時就會從key
值的依賴收集裏面找到counterReaction
,再重新執行一遍。
邊界情況
以上實現只是一個最基礎的響應式模型,還沒有實現的點有:
- 深層數據的劫持
- 數組和對象新增、刪除項的響應
接下來在上面的代碼的基礎上來實現這兩種情況:
深層數據的劫持
在剛剛的代碼實現中,我們只對Proxy的第一層屬性做了攔截,假設有這樣的一個場景
const counter = reactive({ data: { num: 0 } }); // 會在控制台打印出0 const counterReaction = observe(() => console.log(counter.data.num)); counter.data.num = 1; 複製代碼
這種場景就不能實能觸發counterReaction
自動執行了。
因為counter.data.num其實是對data
上的num
屬性進行賦值,而counter雖然是一個響應式proxy
,但counter.data
卻只是一個普通的對象,回想一下剛剛的proxyget
的攔截函數:
/** 劫持get訪問 收集依賴 */ function get(target: Raw, key: Key, receiver: ReactiveProxy) { const result = Reflect.get(target, key, receiver) // 收集依賴 registerRunningReaction({ target, key, receiver, type: "get" }) return result } 複製代碼
counter.data
只是通過Reflect.get拿到了原始的 { data: {number } }對象,然後對這個對象的賦值不會被proxy攔截到。
那麼思路其實也有了,就是在深層訪問的時候,如果訪問的數據是個對象,就把這個對象也用reactive
包裝成proxy再返回,這樣在進行counter.data.num = 1;
賦值的時候,其實也是針對一個響應式proxy
賦值了。
/** 劫持get訪問 收集依賴 */ function get(target: Raw, key: Key, receiver: ReactiveProxy) { const result = Reflect.get(target, key, receiver) // 收集依賴 registerRunningReaction({ target, key, receiver, type: "get" }) + // 如果訪問的是對象 則返回這個對象的響應式proxy + if (isObject(result)) { + return reactive(result) + } return result } 複製代碼
數組和對象新增屬性的響應
以這樣一個場景為例
const data: any = reactive({ a: 1, b: 2}) observe(() => console.log( Object.keys(data))) data.c = 5 複製代碼
其實在用Object.keys訪問data的時候,後續不管是data上的key發生了新增或者刪除,都應該觸發這個觀察函數,那麼這是怎麼實現的呢?
首先我們需要知道,Object.keys(data)訪問proxy的時候,會觸發proxy的ownKeys
攔截。
那麼我們在baseHandler
中先新增對於ownKeys
的訪問攔截:
/** 劫持get訪問 收集依賴 */ function get() {} /** 劫持set訪問 觸發收集到的觀察函數 */ function set() { } /** 劫持一些遍歷訪問 比如Object.keys */ + function ownKeys (target: Raw) { + registerRunningReaction({ target, type: 'iterate' }) + return Reflect.ownKeys(target) + } 複製代碼
還是和get方法一樣,調用registerRunningReaction
方法註冊依賴,但是這裡type我們需要定義成了一個特殊的值: iterate
,
這個type怎麼用呢。我們繼續改造registerRunningReaction
函數:
+ const ITERATION_KEY = Symbol("iteration key") export function registerRunningReaction(operation: Operation) { const runningReaction = getRunningReaction() if (runningReaction) { + if (type === "iterate") { + key = ITERATION_KEY + } // 拿到原始對象 -> 觀察者的map const reactionsForRaw = connectionStore.get(target) // 拿到key -> 觀察者的set let reactionsForKey = reactionsForRaw.get(key) if (!reactionsForKey) { // 如果這個key之前沒有收集過觀察函數 就新建一個 reactionsForKey = new Set() // set到整個value的存儲里去 reactionsForRaw.set(key, reactionsForKey) } if (!reactionsForKey.has(reaction)) { // 把這個key對應的觀察函數收集起來 reactionsForKey.add(reaction) // 把key收集的觀察函數集合 加到cleaners隊列中 便於後續取消觀察 reaction.cleaners.push(reactionsForKey) } } } 複製代碼
也就是type: iterate
觸發的依賴收集,我們會把key改成ITERATION_KEY
這個特殊的Symbol,然後把收集到的觀察函數放在ITERATION_KEY
的收集中,那麼再來看看觸發更新時的set
改造:
/** 劫持set訪問 觸發收集到的觀察函數 */ function set(target: Raw, key: Key, value: any, receiver: ReactiveProxy) { + // 先檢查一下這個key是不是新增的 + const hadKey = hasOwnProperty.call(target, key) // 拿到舊值 const oldValue = target[key] // 設置新值 const result = Reflect.set(target, key, value, receiver) + if (!hadKey) { + // 新增key值時觸發觀察函數 + queueReactionsForOperation({ target, key, value, receiver, type: 'add' }) } else if (value !== oldValue) { // 已存在的key的值發生變化時觸發觀察函數 queueReactionsForOperation({ target, key, value, oldValue, receiver, type: 'set' }) } return result } 複製代碼
這裡對新增的key也進行了的判斷,傳入queueReactionsForOperation
的type變成了add
,接下來的一步就會針對add
進行一些特殊的操作
/** 值更新時觸發觀察函數 */ export function queueReactionsForOperation(operation: Operation) { getReactionsForOperation(operation).forEach(reaction => reaction()) } /** * 根據key,type和原始對象 拿到需要觸發的所有觀察函數 */ export function getReactionsForOperation({ target, key, type }: Operation) { // 拿到原始對象 -> 觀察者的map const reactionsForTarget = connectionStore.get(target) const reactionsForKey: ReactionForKey = new Set() // 把所有需要觸發的觀察函數都收集到新的set里 addReactionsForKey(reactionsForKey, reactionsForTarget, key) // add和delete的操作 需要觸發某些由循環觸發的觀察函數收集 // observer(() => rectiveProxy.forEach(() => proxy.foo)) + if (type === "add" || type === "delete") { + const iterationKey = Array.isArray(target) ? "length" : ITERATION_KEY + addReactionsForKey(reactionsForKey, reactionsForTarget, iterationKey) } return reactionsForKey } 複製代碼
這裡需要注意的是,如果我們在觀察函數中對數據做了遍歷操作,那麼後續加入對數據進行了新增或刪除操作,也需要觸發它的重新執行,這是很合理的,
這裡又有一個知識點,對於數組遍歷的操作,都會觸發它對length
的讀取,然後把觀察函數收集到length
這個key的依賴中,比如
observe(() => proxyArray.forEach(() => {})) // 會訪問proxyArray的length。 複製代碼
所以在觸發更新的時候,
- 如果目標是個數組,那就從
length
的依賴里收集。 - 如果目標是對象,就從
ITERATION_KEY
的依賴里收集。(也就是剛剛所說的,對於對象做Object.keys讀取時收集的依賴)。
如此一來,就實現了對遍歷和新增屬性這些邊界情況的支持。
刪除屬性的攔截
/** 劫持刪除操作 觸發收集到的觀察函數 */ function deleteProperty (target: Raw, key: Key) { // 先檢查一下是否存在這個key const hadKey = hasOwnProperty.call(target, key) // 拿到舊值 const oldValue = target[key] // 刪除這個屬性 const result = Reflect.deleteProperty(target, key) // 只有這個key存在的時候才觸發更新 if (hadKey) { // type為delete的話 會觸發遍歷相關的觀察函數更新 queueReactionsForOperation({ target, key, oldValue, type: 'delete' }) } return result } 複製代碼
基本是同一個套路,只是queueReactionsForOperation
尋找收集觀察函數的時候,type換成了delete
,所以會觸發內部做了循環操作
的觀察函數重新執行。
源碼地址
總結
由於篇幅原因,有一些優化的操作我沒有在文中寫出來,在倉庫里做了幾乎是逐行注釋,而且也可以用npm run dev
對example文件夾中的例子進行調試。感興趣的同學可以自己看一下。
如果讀完了還覺得有興緻,也可以直接去看observe-util
這個庫的源碼,裏面對於更多的邊界情況做了處理,代碼也寫的非常優雅,值得學習。
從本文里講解的一些邊界情況也可以看出,基於Proxy的響應式方案比Object.defineProperty要強大很多,希望大家盡情的享受Vue3帶來的快落吧。