关于javascript:从观察者模式到响应式的设计原理

39次阅读

共计 11596 个字符,预计需要花费 29 分钟才能阅读完成。

响应式对应用过 Vue 或 RxJS 的小伙伴来说,应该都不会生疏。响应式也是 Vue 的外围性能个性之一,因而如果要想把握 Vue,咱们就必须深刻理解响应式。接下来阿宝哥将从观察者模式说起,而后联合 observer-util 这个库,带大家一起深刻学习响应式的原理。

一、观察者模式

观察者模式,它定义了一种 一对多 的关系,让多个观察者对象同时监听某一个主题对象,这个主题对象的状态发生变化时就会告诉所有的观察者对象,使得它们可能自动更新本人。在观察者模式中有两个次要角色:Subject(主题)和 Observer(观察者)。

关注「全栈修仙之路」浏览阿宝哥原创的 4 本收费电子书(累计下载 2.2 万 +)及 50 几篇“重学 TS”教程。

因为观察者模式反对简略的播送通信,当音讯更新时,会主动告诉所有的观察者。上面咱们来看一下如何应用 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/obse…

该库外部也是利用了 ES6 的 Proxy API 来实现响应式,在介绍它的工作原理前,咱们先来看一下如何应用它。

二、observer-util 简介

observer-util 这个库应用起来也很简略,利用该库提供的 observableobserve 函数,咱们就能够不便地实现数据的响应式。上面咱们先来举个简略的例子:

2.1 已知属性

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

const counter = observable({num: 0});
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({num: 0}); // 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],
  [Object, false],
  [Array, false],
  [Int8Array, false],
  [Uint8Array, false],
  // 省略局部代码
  [Float64Array, false]
])

看完 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, this, arguments)
    }
  // 省略局部代码
  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 对象。这部分内容,阿宝哥就不再开展介绍,感兴趣的小伙伴能够自行浏览相干代码。

关注「全栈修仙之路」浏览阿宝哥原创的 4 本收费电子书(累计下载 2.2 万 +)及 10 篇源码剖析系列教程。

四、参考资源

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

正文完
 0