【原理】851- 從觀察者模式到響應式的設計原理

響應式對使用過 Vue 或 RxJS 的小夥伴來說,應該都不會陌生。響應式也是 Vue 的核心功能特性之一,因此如果要想掌握 Vue,我們就必須深刻理解響應式。接下來阿寶哥將從觀察者模式說起,然後結合 observer-util 這個庫,帶大家一起深入學習響應式的原理。

一、觀察者模式

觀察者模式,它定義了一種 一對多 的關係,讓多個觀察者對象同時監聽某一個主題對象,這個主題對象的狀態發生變化時就會通知所有的觀察者對象,使得它們能夠自動更新自己。在觀察者模式中有兩個主要角色:Subject(主題)和 Observer(觀察者)。

由於觀察者模式支持簡單的廣播通信,當消息更新時,會自動通知所有的觀察者。下面我們來看一下如何使用 TypeScript 來實現觀察者模式:

1.1 定義 ConcreteObserver

interface Observer {
  notify: Function;
}

class ConcreteObserver implements Observer{
  constructor(private name: string) {}
  notify() {
    console.log(`${this.name} has been notified.`);
  }
}

1.2 定義 Subject 類

class Subject { 
  private observers: Observer[] = [];

  public addObserver(observer: Observer): void {
    this.observers.push(observer);
  }

  public notifyObservers(): void {
    console.log("notify all the observers");
    this.observers.forEach(observer => observer.notify());
  }
}

1.3 使用示例

// ① 創建主題對象
const subject: Subject = new Subject();

// ② 添加觀察者
const observerA = new ConcreteObserver("ObserverA");
const observerC = new ConcreteObserver("ObserverC");
subject.addObserver(observerA); 
subject.addObserver(observerC);

// ③ 通知所有觀察者
subject.notifyObservers();

對於以上的示例來說,主要包含三個步驟:① 創建主題對象、② 添加觀察者、③ 通知觀察者。上述代碼成功運行後,控制檯會輸出以下結果:

notify all the observers
ObserverA has been notified.
ObserverC has been notified.

在前端大多數場景中,我們所觀察的目標是數據,當數據發生變化的時候,頁面能實現自動的更新,對應的效果如下圖所示:

要實現自動更新,我們需要滿足兩個條件:一個是能實現精準地更新,另一個是能檢測到數據的異動。要能實現精準地更新就需要收集對該數據異動感興趣的更新函數(觀察者),在完成收集之後,當檢測到數據異動,就可以通知對應的更新函數。

上面的描述看起來比較繞,其實要實現自動更新,我們就是要讓 ① 創建主題對象、② 添加觀察者、③ 通知觀察者 這三個步驟實現自動化,這就是實現響應式的核心思路。接下來,我們來舉一個具體的示例:

相信熟悉 Vue2 響應式原理的小夥伴,對上圖中的代碼都不會陌生,其中第二步驟也被稱爲收集依賴。通過使用 Object.defineProperty API,我們可以攔截對數據的讀取和修改操作。

若在函數體中對某個數據進行讀取,則表示此函數對該數據的異動感興趣。當進行數據讀取時,就會觸發已定義的 getter 函數,這時就可以把數據的觀察者存儲起來。而當數據發生異動的時候,我們就可以通知觀察者列表中的所有觀察者,從而執行相應的更新操作。

Vue3 使用了 Proxy API 來實現響應式,Proxy API 相比 Object.defineProperty API 有哪些優點呢?這裏阿寶哥不打算展開介紹了,後面打算寫一篇專門的文章來介紹  Proxy API。下面阿寶哥將開始介紹本文的主角 —— observer-util:

Transparent reactivity with 100% language coverage. Made with ❤️ and ES6 Proxies.

https://github.com/nx-js/observer-util

該庫內部也是利用了 ES6 的 Proxy API 來實現響應式,在介紹它的工作原理前,我們先來看一下如何使用它。

二、observer-util 簡介

observer-util 這個庫使用起來也很簡單,利用該庫提供的 observableobserve 函數,我們就可以方便地實現數據的響應式。下面我們先來舉個簡單的例子:

2.1 已知屬性

import { observable, observe } from '@nx-js/observer-util';

const counter = observable({ num0 });
const countLogger = observe(() => console.log(counter.num)); // 輸出 0

counter.num++; // 輸出 1

在以上代碼中,我們從 @nx-js/observer-util 模塊中分別導入 observableobserve 函數。其中 observable 函數用於創建可觀察的對象,而 observe 函數用於註冊觀察者函數。以上的代碼成功執行後,控制檯會依次輸出 01。除了已知屬性外,observer-util 也支持動態屬性。

2.2 動態屬性

import { observable, observe } from '@nx-js/observer-util';

const profile = observable();
observe(() => console.log(profile.name));

profile.name = 'abao'// 輸出 'abao'

以上的代碼成功執行後,控制檯會依次輸出 undefinedabao。observer-util 除了支持普通對象之外,它還支持數組和 ES6 中的集合,比如 Map、Set 等。這裏我們以常用的數組爲例,來看一下如何讓數組對象變成響應式對象。

2.3 數組

import { observable, observe } from '@nx-js/observer-util';

const users = observable([]);

observe(() => console.log(users.join(', ')));

users.push('abao'); // 輸出 'abao'

users.push('kakuqo'); // 輸出 'abao, kakuqo'

users.pop(); // 輸出 'abao,'

這裏阿寶哥只介紹了幾個簡單的示例,對 observer-util 其他使用示例感興趣的小夥伴,可以閱讀該項目的 README.md 文檔。接下來,阿寶哥將以最簡單的例子爲例,來分析一下 observer-util 這個庫響應式的實現原理。

如果你想在本地運行以上示例的話,可以先修改 debug/index.js 目錄下的 index.js 文件,然後在根目錄下執行 npm run debug 命令。

三、observer-util 原理解析

首先,我們再來回顧一下最早的那個例子:

import { observable, observe } from '@nx-js/observer-util';

const counter = observable({ num0 }); // A
const countLogger = observe(() => console.log(counter.num)); // B

counter.num++; // C

在第 A 行中,我們通過 observable 函數創建了可觀察的 counter 對象,該對象的內部結構如下:

通過觀察上圖可知,counter 變量所指向的是一個 Proxy 對象,該對象含有 3 個 Internal slots。那麼 observable 函數是如何將我們的 { num: 0 } 對象轉換成 Proxy 對象呢?在項目的 src/observable.js 文件中,我們找到了該函數的定義:

// src/observable.js
export function observable (obj = {}{
  // 如果obj已經是一個observable對象或者不應該被包裝,則直接返回它
  if (proxyToRaw.has(obj) || !builtIns.shouldInstrument(obj)) {
    return obj
  }

  // 如果obj已經有一個對應的observable對象,則將其返回。否則創建一個新的observable對象
  return rawToProxy.get(obj) || createObservable(obj)
}

在以上代碼中出現了 proxyToRawrawToProxy 兩個對象,它們被定義在 src/internals.js 文件中:

// src/internals.js
export const proxyToRaw = new WeakMap()
export const rawToProxy = new WeakMap()

這兩個對象分別存儲了 proxy => rawraw => proxy 之間的映射關係,其中 raw 表示原始對象,proxy 表示包裝後的 Proxy 對象。很明顯首次執行時,proxyToRaw.has(obj)rawToProxy.get(obj) 分別會返回 falseundefined,所以會執行 || 運算符右側的邏輯。

下面我們來分析一下 shouldInstrument 函數,該函數的定義如下:

// src/builtIns/index.js
export function shouldInstrument ({ constructor }{
  const isBuiltIn =
    typeof constructor === 'function' &&
    constructor.name in globalObj &&
    globalObj[constructor.name] === constructor
  return !isBuiltIn || handlers.has(constructor)
}

shouldInstrument 函數內部,會使用參數 obj 的構造函數判斷其是否爲內置對象,對於 { num: 0 } 對象來說,它的構造函數是 ƒ Object() { [native code] },因此 isBuiltIn 的值爲 true,所以會繼續執行 || 運算符右側的邏輯。其中 handlers 對象是一個 Map 對象:

// src/builtIns/index.js
const handlers = new Map([
  [Map, collectionHandlers],
  [Set, collectionHandlers],
  [WeakMap, collectionHandlers],
  [WeakSet, collectionHandlers],
  [Objectfalse],
  [Arrayfalse],
  [Int8Arrayfalse],
  [Uint8Arrayfalse],
  // 省略部分代碼
  [Float64Arrayfalse]
])

看完 handlers 的結構,很明顯 !builtIns.shouldInstrument(obj) 表達式的結果爲 false。所以接下來,我們的焦點就是 createObservable 函數:

function createObservable (obj{
  const handlers = builtIns.getHandlers(obj) || baseHandlers
  const observable = new Proxy(obj, handlers)
  // 保存raw => proxy,proxy => raw 之間的映射關係
  rawToProxy.set(obj, observable)
  proxyToRaw.set(observable, obj)
  storeObservable(obj)
  return observable
}

通過觀察以上代碼,我們就知道了爲什麼調用 observable({ num: 0 }) 函數之後,返回的是一個 Proxy 對象。對於 Proxy 的構造函數來說,它支持兩個參數:

const p = new Proxy(target, handler)
  • target:要使用 Proxy 包裝的目標對象(可以是任何類型的對象,包括原生數組,函數,甚至另一個代理);
  • handler:一個通常以函數作爲屬性的對象,各屬性中的函數分別定義了在執行各種操作時代理 p 的行爲。

示例中的 target 指向的就是  { num: 0 } 對象,而 handlers 的值會根據 obj 的類型而返回不同的 handlers

// src/builtIns/index.js
export function getHandlers (obj{
  return handlers.get(obj.constructor) // [Object, false],
}

baseHandlers 是一個包含了 get、has 和 set 等 “陷阱“ 的對象:

export default { get, has, ownKeys, set, deleteProperty }

在創建完 observable 對象之後,會保存 raw => proxy,proxy => raw 之間的映射關係,然後再調用 storeObservable 函數執行存儲操作,storeObservable 函數被定義在 src/store.js 文件中:

// src/store.js
const connectionStore = new WeakMap()

export function storeObservable (obj{
  // 用於後續保存obj.key -> reaction之間映射關係
  connectionStore.set(obj, new Map())
}

介紹了那麼多,阿寶哥用一張圖來總結一下前面的內容:

至於 proxyToRawrawToProxy 對象有什麼用呢?相信看完以下代碼,你就會知道答案。

// src/observable.js
export function observable (obj = {}{
  // 如果obj已經是一個observable對象或者不應該被包裝,則直接返回它
  if (proxyToRaw.has(obj) || !builtIns.shouldInstrument(obj)) {
    return obj
  }

  // 如果obj已經有一個對應的observable對象,則將其返回。否則創建一個新的observable對象
  return rawToProxy.get(obj) || createObservable(obj)
}

下面我們來開始分析第 B 行:

const countLogger = observe(() => console.log(counter.num)); // B

observe 函數被定義在 src/observer.js 文件中,其具體定義如下:

// src/observer.js
export function observe (fn, options = {}{
  // const IS_REACTION = Symbol('is reaction')
  const reaction = fn[IS_REACTION]
    ? fn
    : function reaction ({
      return runAsReaction(reaction, fn, thisarguments)
    }
  // 省略部分代碼
  reaction[IS_REACTION] = true
  // 如果非lazy,則直接運行
  if (!options.lazy) {
    reaction()
  }
  return reaction
}

在上面代碼中,會先判斷傳入的 fn 是不是 reaction 函數,如果是的話,直接使用它。如果不是的話,會把傳入的 fn 包裝成  reaction 函數,然後再調用該函數。在 reaction 函數內部,會調用另一個函數 —— runAsReaction,顧名思義該函數用於運行 reaction 函數。

runAsReaction 函數被定義在 src/reactionRunner.js 文件中:

// src/reactionRunner.js
const reactionStack = []

export function runAsReaction (reaction, fn, context, args{
  // 省略部分代碼
  if (reactionStack.indexOf(reaction) === -1) {
    // 釋放(obj -> key -> reactions) 鏈接並復位清理器鏈接
    releaseReaction(reaction)

    try {
      // 壓入到reactionStack堆棧中,以便於在get陷阱中能建立(observable.prop -> reaction)之間的聯繫
      reactionStack.push(reaction)
      return Reflect.apply(fn, context, args)
    } finally {
      // 從reactionStack堆棧中,移除已執行的reaction函數
      reactionStack.pop()
    }
  }
}

runAsReaction 函數體中,會把當前正在執行的 reaction 函數壓入 reactionStack 棧中,然後使用 Reflect.apply API 調用傳入的 fn 函數。當 fn 函數執行時,就是執行 console.log(counter.num) 語句,在該語句內,會訪問 counter 對象的 num 屬性。counter 對象是一個 Proxy 對象,當訪問該對象的屬性時,會觸發 baseHandlersget 陷阱:

// src/handlers.js
function get (target, key, receiver{
  const result = Reflect.get(target, key, receiver)
  // 註冊並保存(observable.prop -> runningReaction)
  registerRunningReactionForOperation({ target, key, receiver, type'get' })
  const observableResult = rawToProxy.get(result)
  if (hasRunningReaction() && typeof result === 'object' && result !== null) {
    // 省略部分代碼
  }
  return observableResult || result
}

在以上的函數中,registerRunningReactionForOperation 函數用於保存 observable.prop -> runningReaction 之間的映射關係。其實就是爲對象的指定屬性,添加對應的觀察者,這是很關鍵的一步。所以我們來重點分析 registerRunningReactionForOperation 函數:

// src/reactionRunner.js
export function registerRunningReactionForOperation (operation{
  // 從棧頂獲取當前正在執行的reaction
  const runningReaction = reactionStack[reactionStack.length - 1]
  if (runningReaction) {
    debugOperation(runningReaction, operation)
    registerReactionForOperation(runningReaction, operation)
  }
}

registerRunningReactionForOperation 函數中,首先會從 reactionStack 堆棧中獲取正在運行的 reaction 函數,然後再次調用 registerReactionForOperation 函數爲當前的操作註冊 reaction 函數,具體的處理邏輯如下所示:

// src/store.js
export function registerReactionForOperation (reaction, { target, key, type }{
  // 省略部分代碼
  const reactionsForObj = connectionStore.get(target) // A
  let reactionsForKey = reactionsForObj.get(key) // B
  if (!reactionsForKey) { // C
    reactionsForKey = new Set()
    reactionsForObj.set(key, reactionsForKey)
  }
  if (!reactionsForKey.has(reaction)) { // D
    reactionsForKey.add(reaction)
    reaction.cleaners.push(reactionsForKey)
  }
}

在調用 observable(obj) 函數創建可觀察對象時,會爲以 obj 對象爲 key,保存在 connectionStoreconnectionStore.set(obj, new Map()) )對象中。

阿寶哥把 registerReactionForOperation 函數內部的處理邏輯分爲 4 個部分:

  • (A):從 connectionStore (WeakMap)對象中獲取 target 對應的值,會返回一個 reactionsForObj(Map)對象;
  • (B):從 reactionsForKey (Map)對象中獲取 key(對象屬性)對應的值,如果不存在的話,會返回 undefined;
  • (C):如果 reactionsForKey 爲 undefined,則會創建一個 Set 對象,並把該對象作爲 value,保存在 reactionsForObj(Map)對象中;
  • (D):判斷 reactionsForKey(Set)集合中是否含有當前的 reaction 函數,如果不存在的話,把當前的 reaction 函數添加到 reactionsForKey(Set)集合中。

爲了讓大家能夠更好地理解該部分的內容,阿寶哥繼續通過畫圖來總結上述的內容:

因爲對象中的每個屬性都可以關聯多個 reaction 函數,爲了避免出現重複,我們使用 Set 對象來存儲每個屬性所關聯的 reaction 函數。而一個對象又可以包含多個屬性,所以 observer-util 內部使用了 Map 對象來存儲每個屬性與 reaction 函數之間的關聯關係。

此外,爲了支持能把多個對象變成 observable 對象並在原始對象被銷燬時能及時地回收內存, observer-util 定義了 WeakMap 類型的 connectionStore 對象來存儲對象的鏈接關係。對於當前的示例,connectionStore 對象的內部結構如下所示:

最後,我們來分析 counter.num++; 這行代碼。簡單起見,阿寶哥只分析核心的處理邏輯,對完整代碼感興趣的小夥伴,可以閱讀該項目的源碼。當執行 counter.num++; 這行代碼時,會觸發已設置的 set 陷阱:

// src/handlers.js
function set (target, key, value, receiver{
  // 省略部分代碼
  const hadKey = hasOwnProperty.call(target, key)
  const oldValue = target[key]
  const result = Reflect.set(target, key, value, receiver)
  if (!hadKey) {
    queueReactionsForOperation({ target, key, value, receiver, type'add' })
  } else if (value !== oldValue) {
    queueReactionsForOperation({
      target,
      key,
      value,
      oldValue,
      receiver,
      type'set'
    })
  }
  return result
}

對於我們的示例,將會調用 queueReactionsForOperation 函數:

// src/reactionRunner.js
export function queueReactionsForOperation (operation{
  // iterate and queue every reaction, which is triggered by obj.key mutation
  getReactionsForOperation(operation).forEach(queueReaction, operation)
}

queueReactionsForOperation 函數內部會繼續調用 getReactionsForOperation 函數獲取當前 key 對應的 reactions:

// src/store.js
export function getReactionsForOperation ({ target, key, type }{
  const reactionsForTarget = connectionStore.get(target)
  const reactionsForKey = new Set()

  if (type === 'clear') {
    reactionsForTarget.forEach((_, key) => {
      addReactionsForKey(reactionsForKey, reactionsForTarget, key)
    })
  } else {
    addReactionsForKey(reactionsForKey, reactionsForTarget, key)
  }
 // 省略部分代碼
  return reactionsForKey
}

在成功獲取當前 key 對應的 reactions 對象之後,會遍歷該對象執行每個 reaction,具體的處理邏輯被定義在 queueReaction 函數中:

// src/reactionRunner.js
function queueReaction (reaction{
  debugOperation(reaction, this)
  // queue the reaction for later execution or run it immediately
  if (typeof reaction.scheduler === 'function') {
    reaction.scheduler(reaction)
  } else if (typeof reaction.scheduler === 'object') {
    reaction.scheduler.add(reaction)
  } else {
    reaction()
  }
}

因爲我們的示例並沒有配置 scheduler 參數,所以就會直接執行 else 分支的代碼,即執行 reaction() 該語句。

好的,observer-util 這個庫內部如何把普通對象轉換爲可觀察對象的核心邏輯已經分析完了。對於普通對象來說,observer-util 內部通過 Proxy API 提供 get 和 set 陷阱,實現自動添加觀察者(添加 reaction 函數)和通知觀察者(執行 reaction 函數)的處理邏輯。

如果你看完本文所介紹的內容,應該就可以理解 Vue3 中 reactivity 模塊內 targetMap 的相關定義:

// vue-next/packages/reactivity/src/effect.ts
type Dep = Set<ReactiveEffect>
type KeyToDepMap = Map<any, Dep>
const targetMap = new WeakMap<any, KeyToDepMap>()

除了普通對象和數組之外,observer-util 還支持 ES6 中的集合,比如 Map、Set 和 WeakMap 等。當處理這些對象時,在創建 Proxy 對象時,會使用 collectionHandlers 對象,而不是 baseHandlers 對象。這部分內容,阿寶哥就不再展開介紹,感興趣的小夥伴可以自行閱讀相關代碼。如果想了解 WeakMap 的相關知識,可以閱讀 你不知道的 WeakMap 這篇文章。

四、參考資源

  • what-is-an-internal-slot-of-an-object-in-javascript
  • MDN-Proxy
  • MDN-Reflect

1. JavaScript 重溫系列(22篇全)
2. ECMAScript 重溫系列(10篇全)
3. JavaScript設計模式 重溫系列(9篇全)
4.  正則 / 框架 / 算法等 重溫系列(16篇全)
5.  Webpack4 入門(上) ||  Webpack4 入門(下)
6.  MobX 入門(上)  ||   MobX 入門(下)
7. 100 +篇原創系列彙總

回覆“加羣”與大佬們一起交流學習~

點擊“閱讀原文”查看 100+ 篇原創文章

本文分享自微信公衆號 - 前端自習課(FE-study)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章