从 observable-util 来看响应式

#技术 #原理 #可观察 #observable-util

故事

大四那一年我开始寻找前端相关的实习,在投递n份简历的情况下终于有一家小型公司发了面试邀约。漂亮的hr姐姐与我一番攀谈交心后,漂亮姐姐说你的底子还不错,让这边的前端主管给你进一步面试。
具体细节我已经记不清了,但我怎么也不会忘记他问的我一个问题。
他问:“你知道vue的双向绑定是怎么实现的吗?”
我答:“应该是观察者模式吧”。
回答完之后我发现他的表情不对劲,我连忙说道:“或者是发布订阅模式吧”。
他说话了:“不对”。
我瞬间不淡定了,我好想知道答案,我好想知道这到底是如何实现的:“那是怎么实现的”。
他说了一遍答案:“&@%$&...”。
我没听清:”啊?啥?“
”是JS实现的“

引言

分享一个迷你响应式库@nx-js/observer-util来了解如何实现响应式数据。这将让你了解:

  • 怎么创建一个可观察对象
  • 响应函数如何收集内部使用到的可观察值,又是如何在依赖值改变时触发响应
  • 为什么状态管理库内的批处理是重要的

基本使用

示例

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

// 创建可观察对象
const originObj = {
    name: 'nian',
    age: 1,
    abilities: {
            shakeHands: true
    }
}
const observableObj = observable(originObj)
// 添加reaction,自动运行
const reactionFn = observe(() => {
    console.log(observableObj.name)
}, {})

observableObj.name = 'gao'
复制代码

observable: 接收一个值,可以将其创建为可观察,且是深度可观察
observe: 创建reaction,运行时收集依赖,在依赖变化后自动运行改reaction函数

  • observe 接受一个函数。 在函数运行时,为其同步任务收集依赖,在依赖更改的时候,运行该函数;
  • 返回reaction函数,可直接调用执行,执行期间可以重新收集依赖。
  • 可接受第二个参数options选项如下:
    • lazy 如果赋true,则不会自动首次运行,只有在手动调用返回的reactionFn之后才会收集依赖进行相应
    • debugger 调试用,接受一个函数,每次有operation操作的时候,传入operation作为参数调用debugger,一般只在调试或者开发者工具内使用
    • scheduler调度函数

读源码

下文涉及术语、变量及其含义

  • reaction: 对可观察对象的观察者函数,在目标发生变更时将会被调用,该库利用observeapi接受函数所得的返回值就是一个reaction
  • reactionStack: reaction执行栈,一个函数调用栈,保存的是正在运行的reaction,当某个reaction执行期间引发其他reaction观察目标变更,则新的reaction将会被调用,此时把该新调用的reaction压到栈顶。而正处于栈顶的reaction,则视为全局正在运行的reaction,在可观察对象属性发生变更时,会查看当前是否有正在运行的reaction,如果有,则视当前栈顶的reaction为自己的观察者,此时执行添加观察者操作。
  • handlers: 传递给 new Proxy()的处理器,该库核心在于定义的get/set处理器
  • connectionStore: 一个weakMap结构,其键为用于创建可观察对象的原始对象,值为一个map结构,该map结构用于存储可观察对象的键与其reaction(键为可观察对象的属性名,值为观察该属性的reaction)。用途是在可观察对象上的属性值变更的时候调用相应的reaction
  • rawToProxy: 一个weakMap结构,键为原始对象,值为使用原始对象创建的可观察对象引用。
  • proxyToRaw: 一个weakMap结构,与rawToProxy相反。

创建可观察对象

该库并未考虑es5兼容,仅使用proxy api 实现观察者。

/**
* 创建可观察对象,接受一个目标,目标为内部规定的map/set/array/object,返回观察值
*/
function createObservable (obj) {
    // ...
    // builtIns.getHandlers 区分出(map、set)和其他数据类型,非set、map数据使用基础处理器对象
    const handlers = builtIns.getHandlers(obj) || baseHandlers
    const observable = new Proxy(obj, handlers)
    // 在全局使用两个map对象,储存原始对象与可观察对象相互引用
    rawToProxy.set(obj, observable)
    proxyToRaw.set(observable, obj)

    // 为可观察对象创建属性的reaction
    storeObservable(obj)
    return observable
}
复制代码

baseHandlers为一个处理器对象,该对象含有get/set/has/ownKeys/deleteProperty方法,下面列出其中的get/set方法的部分代码来理解其用途

{
  /**
  * 利用get来为reaction收集依赖
  */
  get(target, key, receiver) {
    // 获取值
    const result = Reflect.get(target, key, receiver)
    // 为目标对象key属性注册reaction,收集依赖
    registerRunningReactionForOperation({ target, key, receiver, type: 'get' })
    // 获取对应可观察对象
    const observableResult = rawToProxy.get(result)
    // ... 如果observableResult是可观察对象则返回,不是可观察对象时在该键为可配置时创建为可观察对象返回(因此实现深度可观察)
    return observableResult || result
  },
  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) {
      // 之前没有该键值,则是创建,排队运行reactions
      queueReactionsForOperation({ target, key, value, receiver, type: 'add' })
    } else if (value !== oldValue) {
      // 是更新update,此时新旧值不相等,则排队运行reactions
      queueReactionsForOperation({
        target,
        key,
        value,
        oldValue,
        receiver,
        type: 'set'
      })
    }
    return result
  }
}
复制代码

在访问可观察对象上的某个key属性时,会使用handlers中的get方法,此过程发生的事情有:

  • 1: 如果当前是在一个reaction中访问可观察对象上的属性(后文介绍该库如何判断是否在一个reaction中访问属性),此时为可观察对象的被访问属性添加观察者。
  • 2: 在reaction上的待清空观察的cleaners上存上该可观察对象被访问属性的reactions集合的引用,在需要取消观察的时候,在该集合上删除自身,这样在被观察对象该属性发生变更,触发reactions的时候不会再引发该reaction的调用。
function registerReactionForOperation(reaction, { target, key, type }) {
  if (type === 'iterate') {
    key = ITERATION_KEY
  }
  // 对应上述过程发生的情形 1 ,为可观察对象被访问属性添加观察者`reaction`
  // 获取到目标对象的reaction的map结构
  const reactionsForObj = connectionStore.get(target)
  // 获取目标对象上键为[key]的对应的reactions
  let reactionsForKey = reactionsForObj.get(key)
  // 如果没有map结构上没有对应该键的reactions集合
  if (!reactionsForKey) {
    // 创建一个空的集合并且赋值在map上
    reactionsForKey = new Set()
    reactionsForObj.set(key, reactionsForKey)
  }
  // save the fact that the key is used by the reaction during its current run
  if (!reactionsForKey.has(reaction)) {
    // 如果当前reactions集合上没有该reaction,则在集合内添加上该reaction
    reactionsForKey.add(reaction)
    // 针对上述过程的情形 2,保留reactions集合引用,方便在当前reaction取消观察时候清楚该观察
    // 为reaction上的cleaners添加 针对该key的 reaction集合
    reaction.cleaners.push(reactionsForKey)
  }
}
复制代码

reaction

利用observe创建一个reaction,在该reaction调用期间,执行的操作:释放原有依赖,重新收集依赖。在收集完依赖之后,当依赖发生变化,该reaction将会被再次调用。

function observe(fn, options = {}) {
  // 如果传入的函数不是一个reaction函数,则用reaction包裹一下
  const reaction = fn[IS_REACTION]
    ? fn
    : function reaction() {
      return runAsReaction(reaction, fn, this, arguments)
    }
  reaction.scheduler = options.scheduler
  reaction.debugger = options.debugger
  reaction[IS_REACTION] = true
  // 不是懒运行函数,则直接先运行一次,reaction只有在被运行一次之后才会收集到依赖,所以如果reaction之前没有运行过,observe监听的值不会被监听
  if (!options.lazy) {
    reaction()
  }
  return reaction
}

// 把传入的fn方法以`reaction`的形式运行
function runAsReaction(reaction, fn, context, args) {
  if (reaction.unobserved) {
    // 如果已经被取消观察了,则直接运行返回结果
    return Reflect.apply(fn, context, args)
  }
  // 如果reactionStack中已有该reaction,则不运行
  if (reactionStack.indexOf(reaction) === -1) {
    // 解绑,释放依赖
    releaseReaction(reaction)
    try {
      // 推到reaction栈顶
      reactionStack.push(reaction)
      // 运行fn
      return Reflect.apply(fn, context, args)
    } finally {
      // 从reaction栈中推出
      reactionStack.pop()
    }
  }
}
复制代码

拓展应用

react-easy-stateobservable-util的基础上,增加了对react组件响应式处理。

ReactiveComp = (props) => {
  // 用于forceUpdate的state
  const [, setState] = useState();
  // 直接用observe api包裹一个组件,option参数传入scheduler和lazy参数
  const render = useMemo(
    () =>
      observe(originComponent, {
        // 每次originComponent内部依赖更改之后,都会调用scheduler方法,实现强制更新组件,组件更新之后会释放原有依赖,再次收集依赖
        scheduler: () => setState({}),
        // lazy 懒运行函数,仅当页面挂载该组件之后才开始进行收集依赖
        lazy: true,
      }),
    [originComponent],
  );
  // 卸载,解绑,释放依赖
  useEffect(() => {
    return () => unobserve(render);
  }, []);
  isInsideFunctionComponent = true;
  try {
    return render(props);
  } finally {
    isInsideFunctionComponent = false;
  }
};
复制代码

这与mobxmobx-react(-lite)类似,mobx实现要复杂的多,抛开兼容性及性能的差异,mobx还有批处理的概念。

mobx批处理:如果一个代码块连续修改store中的状态,可以使用runInAction函数进行包裹(或者是action/flow函数),在runInAction运行期间不会引发reaction调用,只有在runInAction运行结束之后,才会引发reactionobservable-util中却没有类似的批处理,当一个点击事件内部更改了多个可观察对象的值,此时每次修改都会引发reaction,如果是执行一个比较耗时的同步任务函数(组件渲染),性能将成问题。

observable-utilreact-easy-state实现的思想或许可以对理解mobx有所帮助,也有助于理解响应式。

整理流程图

(img)设值.png

(img)注册reaction.png

(img)设值.png

(img)排队运行reaction.png

(img)reaction调用.png

故事续集

hr姐姐再次与我攀谈,告知我:你的底子还不错,我们挺看好你的,我们愿意培养你,现在有几个方案让你选,一是你与我们签不低于五年的长约。二是你在这工作半年没有工资,随后一年无论你在哪工作,需要每个月交给我们一千块的培养费。
原来是个培训机构,甘霖凉。

猜你喜欢

转载自juejin.im/post/7106762051896213540
今日推荐