帶你完全搞懂Vue3的響應式原理!TypeScript從零實現基於Proxy的響應式庫。

前言

筆者最近在瀏覽React狀態管理庫的時候,發現了一些響應式的狀態管理庫如 hodux,react-easy-state,內部有一個基於proxy實現響應式的基礎倉庫observer-util,它的代碼實現和Vue3中的響應式原理很是類似,這篇文章就從這個倉庫入手,一步一步帶你剖析響應式的實現。javascript

本文的代碼是我參考observer-util用ts的重寫的,而且會加上很是詳細的註釋。java

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

Proxy
WeakMap
Reflectgit

首先看一下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的差別

關於Vue2的響應式原理,感興趣的也能夠去看我以前的一篇文章:
實現一個最精簡的響應式系統來學習Vue的data、computed、watch源碼npm

其實這個問題本質上就是基於Proxy和基於Object.defineProperty之間的差別,來看Vue2中的一個案例:api

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值進行響應式的定義,app

這就致使了若是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. 定義觀察函數,在這個函數內部去訪問響應式數據

以開頭的例子來講

// 響應式數據
const counter = observable({ num: 0 });

// 觀察函數
observe(() => console.log(counter.num));
複製代碼

這已經一目瞭然了,

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

觀察函數首先開啓某個開關,

訪問時

observe函數會幫你去執行console.log(counter.num)

這時候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的get

/** 劫持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

/** 劫持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) {
  // 拿到舊值
  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。
複製代碼

因此在觸發更新的時候,

  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帶來的快落吧。

相關文章
相關標籤/搜索