帶你徹底搞懂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的重寫的,並且會加上非常詳細的注釋。

閱讀本文可能需要的一些前置知識:

Proxy WeakMap Reflect

首先看一下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重構,所以會有一個類型定義的文件,可以當做接口先大致看一下

github.com/sl1673495/t…

思路

首先響應式的思路無外乎這樣一個模型:

  1. 定義某個數據為響應式數據,它會擁有收集訪問它的函數的能力。
  2. 定義觀察函數,在這個函數內部去訪問響應式數據,訪問到響應式數據的某個key的時候,會建立一個依賴關係key -> reaction觀察函數
  3. 檢測到響應式數據key的值更新的時候,會去重新執行一遍它所收集的所有reaction觀察函數

以開頭的例子來說

// 響應式數據  const counter = observable({ num: 0 });    // 觀察函數  observe(() => console.log(counter.num));  複製代碼

這已經一目了然了,

  • observable包裹的數據叫做響應式數據,
  • observe內部執行的函數叫觀察函數

定義時

observable({ num: 0 }),會讓{ num: 0 }這個普通的對象變成一個proxy,而後續對於這個proxy所有的getset等操作都會被我們內部攔截下來。

訪問時

observe函數會先開啟一個開始觀察的開關,然後幫你去執行console.log(counter.num),執行到counter.num的時候

我們註冊在counter這個proxyget攔截到了對於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里就是對於數據的getset之類的劫持,

這裡有兩個WeakMap: proxyToRawrawToProxy

可以看到在定義響應式數據為一個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里的getset

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的攔截,

這時候getreactionStack找當前正在運行的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。  複製代碼

所以在觸發更新的時候,

  1. 如果目標是個數組,那就從length的依賴里收集。
  2. 如果目標是對象,就從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,所以會觸發內部做了循環操作的觀察函數重新執行。

源碼地址

github.com/sl1673495/t…

總結

由於篇幅原因,有一些優化的操作我沒有在文中寫出來,在倉庫里做了幾乎是逐行注釋,而且也可以用npm run dev對example文件夾中的例子進行調試。感興趣的同學可以自己看一下。

如果讀完了還覺得有興緻,也可以直接去看observe-util這個庫的源碼,裏面對於更多的邊界情況做了處理,代碼也寫的非常優雅,值得學習。

從本文里講解的一些邊界情況也可以看出,基於Proxy的響應式方案比Object.defineProperty要強大很多,希望大家盡情的享受Vue3帶來的快落吧。