筆者最近在瀏覽React狀態管理庫的時候,發現了一些響應式的狀態管理庫如 hodux
,react-easy-state
,內部有一個基於proxy實現響應式的基礎倉庫observer-util
,它的代碼實現和Vue3中的響應式原理很是類似,這篇文章就從這個倉庫入手,一步一步帶你剖析響應式的實現。react
本篇是系列第一篇,主要講解了普通對象的響應式源碼git
系列終結篇也已經發布,講解Map和Set的特殊響應式流程
帶你完全搞懂Vue3的Proxy響應式原理!基於函數劫持實現Map和Set的響應式github
本文的代碼是我參考observer-util
用ts的重寫的,而且會加上很是詳細的註釋。typescript
閱讀本文可能須要的一些前置知識:npm
首先看一下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響應式系統之間的差別吧。markdown
關於Vue2的響應式原理,感興趣的也能夠去看我以前的一篇文章:
實現一個最精簡的響應式系統來學習Vue的data、computed、watch源碼app
其實這個問題本質上就是基於Proxy和基於Object.defineProperty之間的差別,來看Vue2中的一個案例:函數
<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
的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下全部的觀察函數
,輪流執行一遍。
這樣就實現了響應式模型。
上文中關於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
了
/** 劫持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
函數,
// 收集響應依賴的的函數 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訪問 觸發收集到的觀察函數 */ 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
這個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帶來的快落吧。