筆者最近在瀏覽React狀態管理庫的時候,發現了一些響應式的狀態管理庫如 hodux
,react-easy-state
,內部有一個基於proxy實現響應式的基礎倉庫observer-util
,它的代碼實現和Vue3中的響應式原理很是類似,這篇文章就從這個倉庫入手,一步一步帶你剖析響應式的實現。javascript
本文的代碼是我參考observer-util
用ts的重寫的,而且會加上很是詳細的註釋。java
閱讀本文可能須要的一些前置知識:react
首先看一下observer-util
給出的代碼示例:github
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響應式系統之間的差別吧。typescript
關於Vue2的響應式原理,感興趣的也能夠去看我以前的一篇文章:
實現一個最精簡的響應式系統來學習Vue的data、computed、watch源碼npm
其實這個問題本質上就是基於Proxy和基於Object.defineProperty之間的差別,來看Vue2中的一個案例:api
<template>
{{ obj.c }}
</template>
<script> export default { data: { obj: { a: 1 }, }, mounted() { this.obj.c = 3 } } </script>
複製代碼
這個例子中,咱們對obj上本來不存在的c
屬性進行了一個賦值,可是在Vue2中,這是不會觸發視圖的響應式更新的,數組
這是由於Object.defineProperty必須對於肯定的key
值進行響應式的定義,app
這就致使了若是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重構,因此會有一個類型定義的文件,能夠當作接口先大體看一下
首先響應式的思路無外乎這樣一個模型:
響應式數據
,它會擁有收集訪問它的函數
的能力。響應式數據
。以開頭的例子來講
// 響應式數據
const counter = observable({ num: 0 });
// 觀察函數
observe(() => console.log(counter.num));
複製代碼
這已經一目瞭然了,
observable
包裹的數據叫作響應式數據,observe
內部執行的函數叫觀察函數
。觀察函數首先開啓某個開關,
observe函數會幫你去執行console.log(counter.num)
,
這時候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) {
// 拿到舊值
const oldValue = target[key]
// 設置新值
const result = Reflect.set(target, key, value, receiver)
+ // 先檢查一下這個key是否是新增的
+ const hadKey = hasOwnProperty.call(target, key)
+ 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帶來的快落吧。