从零开始用 proxy 实现 Mobx

dynamic-object 只对外暴露了三个 api:observable observe Action,分别是 动态化对象变化监听懒追踪辅助函数javascript

下面以开发角度描述实现思路,同时做为反思,若是有更优的思路,我会随时更新。java

1. 术语解释

本库包含许多抽象概念,为了简化描述,使用固定单词指代,约定以下:react

单词 含义
observable dynamic-object 提供的最重要功能,将对象动态化的函数
observe 监听其回调函数中当前访问到的 observable 化的对象的修改,并在值变化时从新出发执行
observer 指代 observe 中的回调函数

2. 整体思路

若是单纯的实现 observable,使用 proxy 很简单,能够彻底监听对象的变化,难点在于如何在 observe 中执行依赖追踪,并当 observable 对象触发 set 时,触发对应 observe 中的 observergit

每一个 observable 对象触发 get 时,都将当前所在的 object + key 与当前 observer 对应关系存储起来,当其被 set 时,拿到对应的 observer 执行便可。github

咱们必须依赖持久化变量才能作到这一点,由于 observableset 过程,与 observerget 的过程是分开的。typescript

3. 定义持久化变量

变量名 类型 含义
proxies WeakMap 全部代理对象都存储于此,当重复执行 observable 或访问对象子属性时,若是已是 proxy 就从 proxies 中取出返回
observers WeakMap >> 任何对象的 key 只要被 get,就会被记录在这里,同时记录当前的 observer,当任意对象被 set 时,根据此 map 查询全部绑定的 observer 并执行,就达到 observe 的效果了
currentObserver Observer 当前的 observer。当执行 observe 时,当前 observer 指向其第一个回调函数,这样当代理被访问时,保证其绑定的 observer 是其当前所在的回调函数。

4. 从 observable 函数开始

对于 observable(obj),按照如下步骤分析:redux

4.1. 去重

若是传入的 obj 自己已经是 proxy,也就是存在于 proxies,直接返回 proxies.get(obj)。这种状况考虑到可能将对象 observable 执行了屡次。(proxies 保存原对象与代理各一份,保证传入的是已代理的原对象,仍是代理自己,均可以被查找到)api

4.2. new Proxy

若是没有重复,new Proxy 生成代理返做为返回值。代理涉及到三处监听处理:get set deleteProperty缓存

4.3. get 处理

get(target, key, receiver)复制代码

先判断 currentObserver 是否为空,若是为空,说明是在 observer 以外访问了对象,此时不作理会。babel

若是 currentObserver 不为空,将 object + key -> currentObserver 的映射记录到 observers 对象中。同时为 currentObserver.observedKeys 添加当前的映射引用,当 unobserve 时,须要读取 observer.observedKeys 属性,将 observers 中全部此 observer 的依赖关系删除。

最后,若是 get 取的值不是对象(typeof obj !== "object"),那么是基本类型,直接返回便可。若是是对象,那么:

  1. 若是在 proxies 存在,直接返回 proxy 引用。eg: const name = obj.name,这时 name 变量也是一个代理,其依赖也可追踪。
  2. 若是在 proxies 不存在,将这个对象从新按照如上流程处理一遍,这就是惰性代理,好比访问到 a.b.c,那么会分别将 a b c 各走一遍 get 处理,这样不管其中哪一环,都是代理对象,可追踪,相反,若是 a 对象还存在其余字段,由于没有被访问到,因此不会进行处理,其值也不是代理,由于没有访问的对象也不必追踪。

4.4. set 处理

set(target, key, value, receiver)复制代码

若是新值与旧值不一样,或 key === "length" 时,就认为产生了变化,找到当前 object + key 对应的 observers 队列依次执行便可。有两个注意点:

  1. 执行前先将当前执行的 observer 绑定关系清空:由于 observer 时会触发新一轮绑定,这样实现了条件的动态绑定。
  2. 执行前设置 currentObserver 为当前 observer,再执行 observer 时就能够将 set 正确绑定上。

4.5 deleteProperty

删除属性时,直接触发对应 observer

4.6 Map WeakMap Set WeakSet 的状况

这些类型的特色是有明确封装方法,其实更容易设置追踪,此次不使用 proxy,而是复写这些对象的方法,在 get set 中加上钩子。

5. observe 函数

马上执行当前回调 observer,执行规则与 4.4 小节的 observers 队列执行机制相同。

有人会有疑惑,为何 observe 要当即执行内部回调呢?若是初始化不不输出,结果可能会好看一些:

import { observable, observe } from "dynamic-object"

const dynamicObj = observable({
    a: 1
})

observe(() => {
    console.log('a:', dynamicObj.a)
})

dynamicObj.a = 2复制代码

以上会输出两次,分别是 a: 1a: 2。另外,可能会以为这样与 react 结合,会不会致使初始化时增长没必要要的渲染?

这两个都是很好的问题,但结论是:初始化执行是必要的:

  1. 若是初始化不执行,就没有办法执行初始数据绑定,那么后续的赋值彻底找不到对应的 observer 是什么(除非作静态分析,但稍稍复杂些就不可能了)。
  2. 结合 react 时,经过生命周期 mixins 来覆写 render 函数,将初始化的 observe 绑定与后续 render 函数分离,达到首次 render 是 observe 初始化触发,后续 render 依靠依赖追踪自动触发 的效果,在 dynamic-react 章节会有深刻介绍。

6. Action

Action 是用于写标准 action 的装饰器,有如下两种写法:

@Action setUserName() {..}
Action(setUserName)复制代码

起做用是将回调函数中发生的变动临时存储起来,当执行完时统一触发,而且同一个 observer 的屡次 set 行为只会触发一次,而且执行时,获取到的是最终值,全部值的中间变化过程都会被忽略。

好比: 当 dynamicObj.a 初始值为 1 时,下面的代码不会触发 observer 执行:

Action(()=> {
  dynamicObj.a = 2
  dynamicObj.a = 1
})复制代码

7. 调用栈深度统计

要达到上面效果,须要额外定义一个持久化变量 trackingDeep,每次 Action 执行时,这个变量先自增 1,执行 observer 时,若是 trackingDeep 不为 0,就把 observer 存储在队列中,当回调函数执行完后,深度减 1,开始执行存储的队列,一样,若是深度不为 1 就跳过,深度为 0 就执行。

咱们假象这种场景:

class Test {
  @Action setUser(info) {
    this.userStore.account = info.account
    this.setName(info.name)
  }

  @Action setName(name) {
    this.userStore.name = name
  }
}复制代码

当调用 setUser 时,其内部又调用了 setName,那么执行 setUser 时,trackingDeep 为 1,以后又执行到 setName 使得 trackingDeep 变成 2,内层 Action 执行完毕,trackingDeep 变回 1,此时队列不会执行,调用栈回退到 setName 后,trackingDeep 终于变成 0,队列执行,此时observer 仅触发了一次。

Tips: 这里有个优化点,当 trackingDeep 不为 0 时,终止 dynamic-object 的依赖收集行为。这么作的好处是,当 react render 函数中,同步调用 action 时,不会绑定到这个 action 用到的变量。

7.1 缺点

Action 的概念存在一个严重的缺点(但不致命),同时也是 mobx 库一直没有解决的问题,那就是对于异步 action 迫不得已(除非为异步 action 分段使用 Action,这也是 mobx 官方推荐的方式,也有 babel 插件来解决,但这样很 hack)。

咱们思考以下代码:

class Test {
  @Action async getUser() {
    this.isLoading = true
    const result = await fetch()
    this.isLoading = false
    this.user = result 
  }
}复制代码

首先咱们不但愿它是忽略中间态的,不然初始将 isLoading 设置为 true 就没有意义了。

比较好的途径是,将这个异步 action 触发的 observer 塞入到队列中,每当遇到 await 就执行并清空队列,同时还能够支持 timeout 设定,好比设置为 100ms 时,若是 fetch 函数在 100ms 内执行完毕,就不会执行以前的队列,达到肉眼没法识别的间隔内不触发 loading 的效果。

理想很美好,惋惜难点不在如何实现如上的设定,而是咱们没办法将队列分隔开,考虑以下代码:

handleClick() {
  this.props.Test.getUser()
  this.props.Test.getArticle()
}复制代码

getUsergetArticle 都是异步的,若是咱们将缓存队列共用一个,那么 getArticle 执行到 await 时,顺便会邪恶的把 getUser 队列中 observer 给执行了,纵使 getUserawait 尚未结束(可能出现 loading 在数据还没加载完成就消失)。

有人说,将 getUsergetArticle 队列分开不就好了吗?是的,但目前 javascript 还作不到这一点,见此处讨论。不管是 defineProperty 仍是 proxy,都没法在 set 触发时,知道本身是从哪一个闭包中被触发的。只知道触发的对象,以及被访问的 key,是没办法将 getUser getArticle
放在不一样队列执行 observer 的。

目前个人作法与 mobx 同样,async 函数会打破 Action 的庇护,失去了收集后统一执行的特性,但保证了程序的正确运行。目前的解决方法是,为同步区域再套一层 Action,或者干脆将异步与同步分开写!

说实话,这个问题被 redux 用概念巧妙规避了,咱们必须将这个函数拆成两个 dispatch。回头想一想,若是咱们也这么作,也彻底能够规避这个问题,拆成两个 action 便可!但我但愿有一天,能找到完美的解决方法。
另外但愿表达一点,redux 的成功在于定义了许多概念与规则,只要咱们遵照,就能写出维护性很棒的代码,其实 oo 思想也是同样!咱们在使用 oo 时,将对 fp 的耐心拿出来,同样能写出维护性很棒的代码。

8. dynamic-react

dynamic-react 是 dynamic-object 在 react 上的应用,相似于 mobx-react 相比于 mobx。实现思路与 mobx-react 很接近,可是简化了许多。

dynamic-react 只暴露了两个接口 ProviderConnect,分别用于 数据初始化绑定更新与依赖注入

8.1 从 Provider 开始

Provider 将接收到的全部参数全局透传到组件,所以实现很简单,将接收到的全部字段存在 context 中便可。

8.2 Connect 的依赖注入

这个装饰器用于 react 组件,分别提供了绑定更新与依赖注入的功能。

因为 dynamic-react 是与 dynamic-object 结合使用的,所以会将全量 store 数据注入到 react 组件中,因为依赖追踪的特性,不会形成没必要要的渲染。

注入经过高阶组件方式,从 context 中取出 Provider 阶段注入的值,直接灌给自组件便可,注意组件自身的 props 须要覆盖注入数据:

export default function Connect(componentClass: any): any {
    return class InjectWrapper extends React.Component<any, any>{
        // 取 context
        static contextTypes = {
            dyStores: React.PropTypes.object
        }

        render() {
            return React.createElement(componentClass, {
                ...this.context.dyStores,
                ...this.props,
            })
        }
    }
}复制代码

8.3 Connect 的绑定更新

见如上代码,咱们经过拿到当前子组件的实例:componentClass.prototype || componentClass 将其生命周期函数重写为,先执行自定义函数钩子,再执行其自身,并且自定义函数钩子绑定上当前 this,能够在自定义勾子修改当前实例的任意字段,后续重写 render 也是依赖此实现的。

8.3.1 willMount 生命周期钩子

最重要阶段是在 willMount 生命周期完成的,由于对于 observer 来讲,只要在初始化时绑定了引用,以后更新都是从 observe 中自动触发的。

总体思路是复写 render 方法:

  1. 在第一次执行时,经过 observe 包裹住原始 render 方法执行,所以绑定了依赖,将此时 render 结果直接返回便可。
  2. 非第一次执行,是由第一次执行时 observe 自动触发的(或者 state、props 传参变化,这些无论),此时能够肯定是由数据流变更致使的刷新,所以能够调用 componentWillReact 生命周期。而后调用 forceUpdate 生命周期,由于重写了 render 的缘故,视图不会自动刷新。
  3. 由 state、props 变化致使的刷新,只要返回原始 render 便可。

注意第一次调用时,不管如何会触发一次 observer,为了忽略这次渲染,咱们设置一个是否渲染的 flag,当 observer 渲染了,普通 render 就再也不执行,由此避免 observe 初始化一定执行一次带来初始渲染两次的问题。

8.3.2 其余生命周期钩子

componentWillUnmountunobserve 掉当前组件的依赖追踪,给 shouldComponentUpdate 加上 pureRender,以及在 componentDidMountcomponentDidUpdate 时通知 devTools 刷新,这里与 mobx-react 实现思路彻底一致。

9. 写在最后

最后给出 dynamic-object 的项目地址,欢迎提出建议和把玩。

相关文章
相关标签/搜索