状态管理框架开发不彻底指南

原文连接前端

做者:非凡vue

框架传送门:github.com/kujiale/tac…react

导读

以前在公司自研了一款状态管理框架,多多少少积累了一些写框架的经验,在这里分享给你们,题目想了挺久,由于文章篇幅比较长,但又没有一本书那么长、那么体系化,因此就起名叫不彻底指南吧,一点拙见还请多指教。git

文章比较长,因此列个大纲,读者能够挑选本身感兴趣的章节以节省阅读时间。若是看完大纲你认为对你如今这个阶段应该没有什么帮助,那么也是一件好事,这一节就是为了帮助你节省下这个时间去作其余更有意义的事情。github

前世此生

一个前端应用一般由许多不一样的模块、组件经过不一样的组合方式拼装、渲染出来,在 react 应用生态中,一个 react 组件也是业务逻辑上最小的“内聚单元”,每一个 react 组件均可以有本身内部的状态和生命周期,这样的设计有助于解耦、由全局视角下降为局部视角,更利于应用的维护。web

复杂的业务永远都是复杂的,模块化、组件化这些架构设计方法自己并不会下降一个系统的业务复杂度,但它的好处是能够下降系统的熵、系统维护成本、提高系统的扩展能力,将“关注点”降到最低。vuex

这里我就不扩展太多,相信你们在工做中都深有体会,好比 A 同窗维护 B 同窗写的一个功能,A 同窗只是想加一个按钮的小功能,却不得不把 B 同窗写的一整块功能都看懂,这样的设计显然有些糟糕,浪费了 A 同窗许多的时间,若是 A 同窗只须要看懂某个组件或小模块的代码,那维护成本就很低了。typescript

说那么多,跟状态管理有啥关系呢?咱们知道任何一种设计都不是万金油,拆分带来了好处,天然也会带来问题,我该如何跨模块、跨组件通讯呢?我该如何在组件销毁后,依然保持组件“操做”过的状态呢?编程

因此光有组件内部的状态管理是不够的,应用级别的全局状态管理在这种状况下就颇有必要了,全局只是更高层次的抽象,为了更方便通讯,倒不是简单得把全部东西都扔到全局,即便是全局状态,咱们依然须要有模块、有规则得去管理起来,这就须要框架、工具去解决这类问题。redux

核心问题

状态管理框架的核心其实就是发布订阅模式,无论是 redux、mobx 仍是 rxjs,万变不离其宗,你就尽管花里胡哨,各类变形,但总得解决根本问题,再去考虑别的能力。以下就是一个最简单的发布订阅模式实现:

let Emitter = function() {
    this._listeners = {}
}
Emitter.prototype.on = function(eventName, callback) {
    let listeners = this._listeners[eventName] || []
    listeners.push(callback)
    this._listeners[eventName] = listeners
}
Emitter.prototype.emit = function(eventName) {
    let args = Array.prototype.slice.apply(arguments).slice(1)
    let listeners = this._listeners[eventName]
    let self = this
    if (!Array.isArray(listeners)) return
    listeners.forEach(function(callback) {
        try {
            callback.apply(this, args)
        } catch (e) {
            console.error(e)
        }
    })
}
复制代码

因此你看,有的一次性的内部小项目、组件库,其实根本都不须要用状态管理框架,20 行代码就能够知足需求,经过 $emitter.emit('event-name') 发布一个事件,$emitter.on('event-name', () => {}) 来接收事件,或者用 react 提供的 context、Provider、Consumer 等方案。

站在巨人肩膀上的时代,知足需求每每是容易的事情,这是从 0 -> 1,可是如何更好的知足需求并非一件容易的事情,这是从 1 -> 100,在实际业务开发中,简单的发布订阅模式会让代码变得难以维护,容易写出过程式代码,因此就须要框架来进一步的封装。

核心进阶

知道了什么是核心,咱们就比较容易想出如何将其应用到 react 项目中了。

订阅者

咱们首先得有一个订阅者,销毁组件时还得把订阅者卸载,以下伪代码所示,咱们手动在组件中绑定订阅者:

class Example extends React.Component<Props, State> {
  constructor(props) {
    super(props);
  }

  refreshView() {
    // 从新渲染 view,好比 this.forceUpdate() 或 this.setState()
  }

  componentDidMount() {
    this.unsubscribeHandler = store.subscribe(() => {
      this.refreshView();
    });
  }

  componentWillUnmount() {
    if (this.unsubscribeHandler) {
      this.unsubscribeHandler();
    }
  }

  render() {
    return (
      <div>balabala...</div>
    )
  }
}
复制代码

或者像 mobx 经过 autoRun() 函数来实现订阅,依赖到的属性变更都将触发 autoRun 的从新执行,这样就能够把从新渲染 view 的逻辑写进去了。

亦或是 redux 中的 connect(a, b)(view) 函数来装饰原始 view,隐藏了绑定订阅者和触发从新渲染的重复性代码。

发布者

订阅者有了,咱们还得有个发布者,能够是任何形式,总之能让订阅者接收到就行,好比像 mobx 直接经过属性赋值发布消息(经过 Object.definePropertyProxy 实现),以下伪代码(只是为了容易理解,实际并非这样):

@action
doSomething() {
  this.loading = true;
}
复制代码
Object.defineProperty(this, 'loading', {
  enumerable: true,
  configurable: true,
  get: function () {
    // do something...
  },
  set: function (newVal) {
    store.dispatch(newVal);
  },
});
复制代码

或者就是 redux 中直接调用 store.disaptch() 告诉订阅者,形式不重要。

好,其实到这里状态管理框架的核心就完成了,虽然有点简陋。但若是这篇文章就这么结束了,对于大部分童鞋们并起不到什么帮助,由于光了解这些皮毛,离开发一个完整框架还有些距离。因此接下来我会介绍一些更加细节的东西。

深刻细节

效率抉择

前一节“核心进阶”的例子我相信你们都看懂了,任何一个 dispatch 都会触发全部 subscribe 的 listener,具体能够去看一下 redux 是怎么实现的,代码不多,这里就不扩展了,源码传送门:github.com/reduxjs/red…

redux 在触发更新的做法上用了一层循环去遍历全部的 listener:

const listeners = (currentListeners = nextListeners)
for (let i = 0; i < listeners.length; i++) {
  const listener = listeners[i]
  listener()
}
复制代码

因此它的时间复杂度是 O(n),任何一次 dispatch 都会触发全部 connect 的组件的订阅者,不过 react-redux 在组件渲染以前仍是作了一层浅比较来优化性能,因此即便触发了订阅者,订阅者触发了视图重绘,若是视图的状态并无发生改变,最终的重绘操做仍是会被拦截掉:

class Example extends React.Component<Props, State> {
  constructor(props) {
    super(props);
  }

  refreshView() {
  }
  // 若是先后状态没有发生变化,则阻止重绘
  shouldComponentUpdate() {
    return !shallowEqual(previousState, nextState);
  }

  componentDidMount() {
    this.unsubscribeHandler = store.subscribe(() => {
      this.refreshView();
    });
  }

  componentWillUnmount() {
    if (this.unsubscribeHandler) {
      this.unsubscribeHandler();
    }
  }

  render() {
    return (
      <div>balabala...</div>
    )
  }
}
复制代码

浅比较的时间复杂度也为 O(n),并且不受对象嵌套层级的影响,为什么不使用深比较呢?答案很明显了,在嵌套层级特别深的状况下,深比较的时间开销是巨大的,比较数组也得一个一个遍历过去,但浅比较毕竟不能精确比较,咱们怎么才能在性能和精确中进行取舍?

其实过于精确的比较,在达到必定程度时,浪费的时间还不如直接从新生成虚拟 dom 再去 diff 一次,因此假如咱们能遵照 react 修改状态始终拷贝一个新对象的规范,咱们就能够直接比较对象的引用是否相同,这样对于某个状态属性的比较,就是 O(1) 的时间复杂度,也算是当前这个问题的完美解决方案了。

mobx 在效率上算是另外一种流派“依赖收集”的实现,什么是依赖收集呢?其实就是把依赖的映射关系在初始化或依赖发生改变时提早进行收集,这样在更新时咱们就不用遍历订阅者了,能够经过映射关系精肯定位须要触发的订阅者,举个简单的栗子:

class List extends React.Component {
  render() {
    return (
      <div> <span>{$tag.currentTagId}</span> <Pagination current={$column.current} defaultPageSize={$column.pageSize} totalPage={$column.totalPage} /> </div> ); } } 复制代码

上面这个组件依赖了 $column 实例上的三个属性,分别是 current, pageSize, totalPage,还依赖了 $tag 实例上的一个属性 currentTagId, OK,那咱们就能够把这个依赖关系存下来了,如何存呢?继续搬出以前那个例子:

Object.defineProperty($column, 'current', {
  enumerable: true,
  configurable: true,
  get: function () {
    collector.collect(namespace, propertyName);
  },
  set: function (newVal) {
    store.dispatch(newVal);
  },
});
复制代码

在访问该属性的时候,会触发 getter 钩子,这样依赖就能够收集到了,但咱们不可能每次访问属性都要收集吧?因此什么时候收集依赖呢?并且咱们应该创建怎样的映射关系?

思考一下比较容易得出,咱们应该创建 view 和 (命名空间/属性)之间的关系,这样更新了某个命名空间下的某个属性,咱们就知道须要去从新渲染哪些 view 了,映射关系以下图所示:

dependency

一个简单的收集器实现:

class Collector {
  public dependencyMap = {};
  private isCollecting = false;
  private tempComponentInstanceId = null;
  // 须要一个拦截器,拦截什么时候开始收集
  start(id) {
    this.isCollecting = true;
    this.tempComponentInstanceId = id;
  }

  collect(namespace, propertyName) {
    const uid = `${namespace}/${propertyName}`;
    if (this.isCollecting) {
      if (!this.dependencyMap[uid]) {
        this.dependencyMap[uid] = [];
      }
      if (this.dependencyMap[uid].indexOf(this.tempComponentInstanceId) > -1) {
        return;
      }
      this.dependencyMap[uid].push(this.tempComponentInstanceId);
    }
  }
  // 须要一个拦截器,拦截什么时候结束收集
  end() {
    this.isCollecting = false;
  }
}

export default new Collector();
复制代码

注意到上面收集器代码的拦截器了吗?这就解决了每次访问属性都要收集的问题,咱们能够本身来控制是否须要收集依赖。接下来咱们就须要在 view 端来采集 viewId,并真正开始收集依赖,咱们能够利用高阶组件/装饰器的做用来隐藏这些用户无需关心的基础性代码:

let countId = 0;

export function stick() {
  return (Target: React.ComponentClass): React.ComponentClass => {
    const displayName: string = Target.displayName || Target.name || 'TACKY_component';
    const target = Target.prototype || Target;
    const baseRender = target.render;

    target.render = function () {
      const id = this.props['@@TACKY__componentInstanceUid'];
      collector.start(id);
      const result = baseRender.call(this);
      collector.end();
      return result;
    }

    return class extends React.Component<Props, State> {
      unsubscribeHandler?: () => void;
      componentInstanceUid: string = `@@${displayName}__${++countId}`;

      constructor(props) {
        super(props);
      }

      refreshView() {
        this.forceUpdate();
      }

      componentDidMount() {
        this.unsubscribeHandler = store.subscribe(() => {
          this.refreshView();
        }, this.componentInstanceUid);
        this.refreshView();
      }

      componentWillUnmount() {
        if (this.unsubscribeHandler) {
          this.unsubscribeHandler();
        }
      }

      render() {
        const props = {
          ...this.props,
          '@@TACKY__componentInstanceUid': this.componentInstanceUid,
        };
        return (
          <ErrorBoundary> <Target {...props} /> </ErrorBoundary> ) } } } } 复制代码

解释一下上面这段代码的一些细节:

  • viewId 是如何生成的:componentInstanceUid 由计数器和组件的 displayName 组成,这样设计的用意是一方面在开发环境单纯一个 countId 不具备语义化,若是我想快速找到这个 view,displayName 更加友好。另外一方面单纯的 displayName 也没法保证每一个组件实例的惟一性,因此每一次渲染组件都会自增 countId 来确保惟一性
  • 依赖是什么时候收集的:上面代码能够看到我是将目标组件的 render 函数重写了,这样在组件初次渲染时,咱们就开始收集依赖了,这里咱们要感谢 react 把 componentWillMount 钩子给去掉了,若是用户在 componentWillMount 里面就已经作了更新操做,就先于依赖的收集时机了,并且 componentWillMount 我至今没有想出它的使用场景,几乎都有办法替代这种反模式,实际这个钩子暴露给用户是比较危险的,由于可能会致使流程没法正常结束
  • 订阅者发生了一些修改: store.subscribe(() => {}, viewId) 和以前的区别是多了一个 viewId 参数,这样创建在有依赖关系 Map 的状况下,每次修改状态咱们都能经过 O(1) 的时间复杂度精肯定位哪些 view 须要更新,再经过 O(1) 的时间复杂度去精确触发对应 view 的订阅者
  • didMount 里面多了一行 this.refreshView() 代码:这行代码是为了解决目标组件发布一次更新时,高阶组件中的订阅者还没来得及绑定,这样就会形成状态不一样步了。比较典型的场景是目标组件的 didMount 里面直接 dispatch 消息,可是高阶组件的 didMount 晚于目标组件 didMount 的执行,这个我想你们都了解,层级越深的组件越先完成渲染

综合来看,依赖收集的更新效率、diff 效率理论上虽然比 redux 更好一些,但整个框架的复杂度要比 redux 高了不少,依赖收集的前置性能消耗也很高,咱们要搞清楚本身项目的 overhead 在什么地方,选择本身合适的实现方式。

充分利用装饰器

mobx 中推荐你们使用装饰器,装饰器的用法确实很清真,好比咱们能够这样去设计一个领域模型:

class PrivilegeDomain extends Domain {
  @state() privilege = null;

  @mutation
  updateAwardStatus(id, level) {
    this.privilege[level].filter(r => r.obsId === id)[0].awardStatus = 1;
  }

  async fetchPrivilegeInfoFromRemote() {
    const privilege = await fetchPrivilege();
    this.$update({
      privilege,
    });
  }

  async getAward(id, level) {
    const code = await getAward(id);
    if (code === '0') {
      this.updateAwardStatus(id, level);
    }
  }
}
复制代码

那么与之对应,咱们须要去实现 @state()、@mutation() 装饰器,这里就不扩展怎么使用装饰器以及它的基本概念了,只是在框架中,须要注意一下 typescript 和 babel 对于装饰器的实现略有区别,框架须要去兼容,下面贴个简单的函数装饰器例子:

function createMutation(target, name, original) {
  return function (...payload: any[]) {
    store.dispatch(original);
  };
}

export function mutation(target, name, descriptor) {
  invariant(!!descriptor, 'The descriptor of the @mutation handler have to exist.');

  // babel/typescript: @mutation method() {}
  if (descriptor.value) {
    const original: Mutation = descriptor.value;
    descriptor.value = createMutation(target, name, original);
    return descriptor;
  }

  // babel only: @mutation method = () => {}
  const { initializer } = descriptor;
  descriptor.initializer = function () {
    return createMutation(target, name, initializer && initializer.call(this));
  };

  return descriptor;
}
复制代码

利用装饰器,咱们能够包裹原始函数,增强它的做用并对用户隐藏实现细节,这其实有点面向切面编程的意思,每一个被修饰的函数均可以轻松得加钩子了。上面的例子中,每一个被修饰的函数一旦被执行都会调用 disptach 发布一条消息,这样咱们就能够实现诸如 @mutation、@reducer 等框架中处理更新逻辑的抽象概念了。

不过对于属性装饰器,会有一些坑,咱们仍是先看代码再解释:

export function state() {
  return function (target, property, descriptor) {
    // typescript only: (exp: @state() name: string = 'someone';)
    if (!descriptor) {
      const raw = undefined;
      Object.defineProperty(target, property, {
        enumerable: true,
        configurable: true,
        get: function () {
        },
        set: function (newVal) {
        },
      });
      return;
    }
    // babel only: (exp: @state() name = 'someone';)
    invariant(
      descriptor.initializer,
      'Your current environment don\'t support \"descriptor.initializer\" class property decorator, please make sure your babel plugin version.'
    );
    const raw = descriptor.initializer.call(this);
    return {
      enumerable: true,
      configurable: true,
      get: function () {
      },
      set: function (newVal) {
      },
    };
  }
}
复制代码

babel / typescript 属性装饰器区别:在兼容 ts 的时候发现框架一直出问题,翻了 ts handbook 才发现 ts 属性装饰器的第三个参数 descriptor 并不存在,这跟 ts 的实现有关,而 babel 依赖于 plugin 的实现,在 class 构造函数初始化时会获取当前实例属性的 descriptor,大概是这样:

let descriptor = Object.getPropertyDescriptor(this, prop);
this[prop] = descriptor.initializer.call(this);
复制代码

因此 ts 中若是你不想改变 @state() API 的用法,你只能经过 Object.defineProperty 去自定义 descriptor。

但要注意,并无办法能够获取到 raw,也就是最初的默认值,由于 ts 是在构造函数中才初始化默认值的,而装饰器执行期间 class 尚未被实例化,因此这个值只能是 undefined,若是你想作到和 babel 同样的效果,可能只能在装饰器参数里面传默认值了,毕竟 ts 也没有 initializer 这个属性。

mutation 仍是 reducer

图片名称 图片名称

这块其实争议还挺大的,我说说我本身在业务中的感觉吧。其实 mutation 是 vuex 中的概念,在 mutation 中能够直接对原对象作更改,不像 reducer 老是一个纯函数去返回新的对象,但其实在业务开发中,这两种形式差异已经不算很大了,reducer 配合 immutable 也很方便,只是理解上不太同样,一种是“突变”,一种是“快照”,达到的目的是同样的(忽略 switch case 这种难阅读的写法,稍微改造下成函数就好了),以下代码:

// vuex
const store = new Vuex.Store({
  state: {
    count: 1
  },
  mutations: {
    increment (state) {
      // 变动状态
      state.count++
    }
  }
})
// redux
const list = (state = {}, action) => {
  switch (action.type) {
    case 'Get_List_Results':
      if (action.offset === 0) {
        return Immutable.fromJS(state)
          .updateIn(['dataList'], list => action.payload)
          .toJSON()
      } else {
        return Immutable.fromJS(state)
          .updateIn(['dataList'], list => list.concat(action.payload))
          .toJSON()
      }
    default:
      return state
  }
}
复制代码

其实我真正想对比的并非 vuex 中的 mutation,而是 mobx 中的 action,只不过 mobx 其实也是属于“突变”的作法,mobx 推荐的写法是这样的:

@action toggleAgree = (event) => {
    const { checked } = event.target;
    this.agreed = checked;
}
复制代码

全部的更新操做和各类业务逻辑、异步请求都混在一个 action 里面,这样作写起来确实是比 vuex、redux 要快不少了,很无脑,可是也更容易面向过程编程了,一旦业务复杂起来,同一套逻辑可能获得处改,彻底不考虑复用和拆分了,但 redux 被诟病最多的应该也是这个,即便只是改一个变量,也得一套流程写下来,像这种单个变量的赋值其实根本没有复用价值可言,因此针对这个痛点,我仍是把二者结合了一下,以下代码所示:

class PrivilegeDomain extends Domain {
  @state() privilege = null;
  @state() result = 0;

  @mutation
  updateAwardStatus(id, level) {
    this.privilege[level].filter(r => r.obsId === id)[0].awardStatus = 1;
  }
  
  @reducer
  updateResult(state, index) {
    return fromJS(state).setIn(['result'], index).toJSON();
  }

  async fetchPrivilegeInfoFromRemote() {
    const privilege = await fetchPrivilege();
    this.$update({
      privilege,
    });
  }

  async getAward(id, level) {
    const code = await getAward(id);
    if (code === '0') {
      this.updateAwardStatus(id, level);
    }
  }
}
复制代码

麻烦一些的更新操做,而且是属于一组的更新操做,能够放到一个 mutation 或者 reducer 里面,看本身喜爱用哪一种形式,我以为差很少,若是是简单的赋值操做,我提供了一个简易的语法糖 this.$update() 来达到一样的更新效果,这样代码其实也更容易阅读,什么地方作了更新操做一目了然,固然有的人可能以为没啥意义,见仁见智吧这块。

Observable 仍是 Time Travel

响应式以 mobx 为表明,直接操做原实例对象,函数式以 redux 为表明,每次拷贝新对象覆盖原对象,这也让 redux 支持时间旅行老是做为一项“优点”去被对比。因此有多少人在业务开发中深度使用时间旅行功能,对于大部分流程不超过 二、3 个函数的业务,我以为我根本不会用到时间旅行,并无带来颠覆级的效率提高,但也不能否认,在一些富交互的协同软件、工具软件中,时间旅行确实会解决一些痛点,因此有些东西你以为没用多是没遇到场景,存在即合理,对待开源工具和框架始终保持严谨客观的态度。

在框架中,其实同时支持 observable 和 time travel 也不是不能够,而是有没有必要这么作的问题,我在 tacky 框架中就同时支持了,实际上只要每次更新完成都去同步一份 snapshot 就能够了,实现也不复杂,但这么作的弊端是性能的损耗,你始终得去同步 snapshot 和实例对象上的值,因此框架必须提供一个开关,可让用户选择是否开启,这样算是作到告终合二者的特色。

domain store 挂载到 props 上仍是直接引用

我曾经好像看到过某个文档,说直接引用外部变量不走 props 是种反模式,但我本身一直没想明白这样作有什么很差,实际上我自研的框架中就采用了第二种方式,我认为若是该组件有从父组件传过来的 props,那就仍是走 props,但全部走框架状态管理相关的属性和方法,所有从外部生成好的实例引入,不污染组件自己的 props,以下代码所示:

import React from 'react';
import $column from '@domain/dwork/design-column/column';
import $tag from '@domain/dwork/design-column/tag';
import $list from '@processor/dwork/column-list/list';

@stick()
export default class List extends React.Component {
  componentDidMount() {
    $list.initLayoutState();
  }

  render() {
    const { fromParent } = this.props;
    return (
      <>
        <Radio.Group value={$tag.currentTagId} onChange={$list.changeTag}>
          <Radio value="">热门推荐</Radio>
          <For
            each="item"
            index="idx"
            of={$tag.tags}
          >
            <Radio key={idx} value={item.id}>{item.tagName}</Radio>
          </For>
        </Radio.Group>
        <Skeleton
          styleName="column-list"
          when={$column.columnList.length > 0}
          render={<Columns showTag={$tag.currentTagId === ''} data={$column.columnList} />}
        />
        <Pagination
          current={$column.current}
          defaultPageSize={$column.pageSize}
          totalPage={$column.totalPage}
          onChange={$list.changePage}
          hideOnSinglePage={true}
        />
      </>
    );
  }
}
复制代码

要实现这种效果,就得使用 react 提供的 forceUpdate() 方法了,毕竟这是一个外部数据,forceUpdate() 的机制咱们都了解,它会跳过 shouldComponentUpdate 强制渲染,因此数据 diff 须要框架本身去处理了,这点要注意。

在非 ts 项目中,mobx 将 store 挂载到组件的 props 上会让编辑器直接丧失提示和跳转,而 redux 的 mapDispatchToProps、mapStateToProps 就更麻烦了,不只没提示,一个一个 pick 出来映射的做用始终不让我以为满意。因此我更倾向于直接享受 vscode 对于 class 实例上的属性和函数的原生提示,不须要任何工具和辅助代码,即便是 js 项目也很是好维护,直接按住 alt 键作 navigation,这样咱们也不须要人工记忆映射关系。

但挂载在 props 上仍是有好处的,起码更符合 react 组件的规范,并且能够总览整个组件的 props 接口?(若是这算个好处的话)另外就是让这些业务型组件变得能够复用?

但以上几个问题我其实都思考过,总览组件的 props 我以为不算是个好处,由于咱们在维护一个项目的时候,做为前端第一时间基本都是去找那个对应“按钮、列表” UI 的 t(j)sx,而后从 t(j)sx 着手,沿着这条链路去修改逻辑,在那些业务的 class 里面一个个函数和属性都罗列的清清楚楚,这是维护方面的。

若是我要使用这个业务组件,一般只会传几个关键的 props 参数,其他的逻辑应该是足够内聚的,使用者并不想关心,即便有定制化的逻辑,也应该让组件经过 props 反向抛出来,真的会有人让使用者本身去把一个容器组件和它对应的 store 手工拼装映射起来用吗?我想你会被那个使用者按在地上摩擦的。除非真的有很高的复用要求。

更多的状况还得让业务进一步验证,目前暂时没发现问题。

中间件系统

这一节其实不想过多扩展,社区有一大堆研究过 redux 中间件机制的文章,中间件的应用在不少场景都有,咱们只要知道它的做用就能够了,框架里面也能够植入,不是很复杂。贴个 redux compose 函数吧:

export function compose(...funcs) {
  if (funcs.length === 0) {
    return (arg) => arg;
  }

  if (funcs.length === 1) {
    return funcs[0];
  }

  return funcs.reduce((a, b) => (...args) => a(b(...args)));
}
复制代码

更高层次的复用-多实例隔离以及命名空间

mobx 文档里面推荐一个 class 最好是单例的,若是是单例的,其实框架也会好写不少,但咱们业务的场景仍是会有同一个 domain class 须要多实例的状况,这实际上是为了更高层次的复用,咱们但愿在同一个应用中,能够作到同一 feature class 的状态隔离,好比某些场景,我就是想让两个相同业务逻辑的业务组件保持状态不一样步,独立维护状态。因此我以为不能简单的把状态挂载到原型上,而是得利用实例化自然的隔离特性。因此我在 @state() 修饰器中是这么作的:

export function state() {
  return function (target, property, descriptor) {
    // typescript only: (exp: @state() name: string = 'someone';)
    if (!descriptor) {
        // ...
    }
    // babel only: (exp: @state() name = 'someone';)
    invariant(
      descriptor.initializer,
      'Your current environment don\'t support \"descriptor.initializer\" class property decorator, please make sure your babel plugin version.'
    );
    const raw = descriptor.initializer.call(this);

    return {
      enumerable: true,
      configurable: true,
      get: function () {
        return observableStateFactory({
          currentInstance: this,
          target,
          property,
          raw: simpleClone(raw),
        }).get(true);
      },
      set: function (newVal) {
        setterBeforeHook({
          target,
        });
        if (isObject(newVal)) {
          observeObject({
            raw: newVal,
            target,
            currentInstance: this,
          });
        }
        return observableStateFactory({
          currentInstance: this,
          target,
          property,
          raw: simpleClone(raw),
        }).set(newVal);
      },
    };
  }
}
复制代码

先把默认值拿到,而后不在这个装饰器内部去维护 getter,setter 的变量,而是经过一个工厂去生产状态变量,每次经过当前的上下文 this、target 原型以及属性名和默认值的拷贝,来映射起来,这样就作到即便是一样的原型、属性名,也会由于 this 的不一样,而取到不一样的状态变量,达到了各自维护状态的功能。

而后再说说命名空间,我是不但愿让用户本身每次都要传一个字符串去维护,这样不只增长了使用成本,还得记忆当前应用中是否有冲突的命名空间,即便有报错也是后置性的。这种背景下,要干预原生 class,我能想到的除了继承就是装饰器了,考虑到每一个 class 确实有一些公共函数,好比 this.$update(),而且不想丧失 vscode navigation 的功能,最后选择了继承,我是这么实现的:

export class Domain {
  constructor() {
    const target = Object.getPrototypeOf(this);
    uid += 1;
    target[CURRENT_MATERIAL_TYPE] = MaterialType.Mutation;
    const domainName = target.constructor.name || 'TACKY_DOMAIN';
    const namespace = `${domainName}@@${uid}`;
    this[NAMESPACE] = namespace;
    StateTree.initInstanceStateTree(namespace, this);
  }

  $lazyLoad() {
    const target = Object.getPrototypeOf(this);
    target[CURRENT_MATERIAL_TYPE] = MaterialType.Noop;
    StateTree.initPlainObjectAndDefaultStateTreeFromInstance(this[NAMESPACE]);
  }

  $reset() {
    const atom = StateTree.globalStateTree[this[NAMESPACE]] as AtomStateTree;
    this.dispatch(atom.default);
  }

  $destroy() {
    StateTree.clearAll(this[NAMESPACE]);
  }

  $update(obj: object) {
    invariant(isObject(obj), 'resetState(...) param type error. Param should be a plain object.');
    this.dispatch(obj);
  }

  private dispatch(obj) {
    const target = Object.getPrototypeOf(this);
    const original = function () {
      for (const key in obj) {
        if (obj.hasOwnProperty(key)) {
          this[key] = obj[key];
        }
      }
    };
    target[CURRENT_MATERIAL_TYPE] = MaterialType.Mutation;
    // update state before render
    if (!store) {
      original.call(this);
      StateTree.syncPlainObjectStateTreeFromInstance(this[NAMESPACE]);
      target[CURRENT_MATERIAL_TYPE] = MaterialType.Noop;
      return;
    }
    // update state after render
    store.dispatch({
      payload: [],
      type: MaterialType.Mutation,
      namespace: this[NAMESPACE],
      original: bind(original, this) as Mutation
    });
    target[CURRENT_MATERIAL_TYPE] = MaterialType.Noop;
  }
}
复制代码

这样我就能够隐性的给每一个实例加上一个 namespace,还能作不少其余功能,不过这种方法仍是没法把操做植入到子类的 constructor 完成的那一刻,我想了一下除了改写子类的 constructor 貌似没有其余办法,好在我暂时尚未这样的需求。

复杂数据结构的处理

这里指的是 @state() 修饰一些诸如嵌套对象、数组等的状况,要想作到在原值上随意修改,就得像 mobx 那样处理了,但咱们业务对于 IE 没有兼容性要求,因此我采用了 Proxy 去实现,这样会省不少代码,也会简单不少,不过我只是用在了数组的处理上,以下所示:

class Observable {
  value: any = null;
  target: Object = {};
  currentInstance: Domain | null = null;

  constructor(raw, target, currentInstance) {
    this.target = target;
    this.currentInstance = currentInstance;
    this.value = Array.isArray(raw) ? this.arrayProxy(raw) : raw;
  }

  get(needCollect = false) {
    if (needCollect) {
      if (!this.currentInstance) {
        fail('Unexpected error. Observable current instance doesn\'t exists.');
        return;
      }
      collector.collect(this.currentInstance[NAMESPACE]);
    }

    return this.value;
  }

  setterHandler() {
    differ.collectDiff(true);
  }

  set(newVal) {
    const wpVal = Array.isArray(newVal) ? this.arrayProxy(newVal) : newVal;
    if (wpVal !== this.value) {
      this.setterHandler();
      this.value = wpVal;
    }
    setterAfterHook();
  }

  arrayProxy(array) {
    observeObject({ raw: array, target: this.target, currentInstance: this.currentInstance });

    return new Proxy(array, {
      set: (target, property, value, receiver) => {
        setterBeforeHook({
          target: this.target,
        });
        const previous = Reflect.get(target, property, receiver);
        let next = value;

        if (previous !== next) {
          this.setterHandler();
        }

        // set value is object
        if (isObject(next)) {
          observeObject({ raw: next, target: this.target, currentInstance: this.currentInstance });
        }
        // set value is array
        if (Array.isArray(next)) {
          next = this.arrayProxy(next);
        }

        const flag = Reflect.set(target, property, next);
        setterAfterHook();
        return flag;
      }
    });
  }
}
复制代码

还有种状况是嵌套对象,比较容易想到用递归去实现:

export function observeObjectProperty({ raw, target, currentInstance, property, }) {
  const subVal = raw[property];

  if (isObject(subVal)) {
    for (let prop in subVal) {
      if (subVal.hasOwnProperty(prop)) {
        observeObjectProperty({
          raw: subVal,
          target,
          currentInstance,
          property: prop,
        });
      }
    }
  } else {
    const observable = new Observable(subVal, target, currentInstance);

    Object.defineProperty(raw, property, {
      enumerable: true,
      configurable: true,
      get: function () {
        return observable.get();
      },
      set: function (newVal) {
        setterBeforeHook({
          target,
        });
        if (isObject(newVal)) {
          for (let prop in newVal) {
            if (newVal.hasOwnProperty(prop)) {
              observeObjectProperty({
                raw,
                target,
                currentInstance,
                property: prop,
              });
            }
          }
        }
        return observable.set(newVal);
      },
    });
  }
}

export function observeObject({ raw, target, currentInstance }) {
  for (let property in raw) {
    if (raw.hasOwnProperty(property)) {
      observeObjectProperty({
        raw,
        target,
        currentInstance,
        property,
      });
    }
  }
}
复制代码

钩子

咱们在上面的代码中应该能够发现诸如 setterBeforeHook, setterAfterHook 等函数,这其实就是 setter 处理器中的两个钩子,一个是修改值以前,一个是修改值以后,这个需求主要来源于我想禁止直接在非 mutation 函数中直接对 @state() 修饰过的状态赋值,也就是说你这么用会报错:

class TagDomain extends Domain {
  @state() currentTagId = '';

  @mutation
  updateCurrentTagId(tagId) {
    this.currentTagId = tagId; // correct
  }

  async test() {
    this.currentTagId = 'aaa'; // error
  }
}
复制代码

框架中只能经过 mutation 或者 this.$update() 来赋值更新,若是不限制,假如用户进行非法操做,会形成状态和视图不一样步的问题,因此仍是提示一个报错会比较友好。

通用错误处理及工具函数

把框架中经常使用的工具和错误处理函数抽出来,便于复用和统一修改,能够去一些优秀框架里面扒一些,好比:

export function isObject(value: any): boolean {
  if (value === null || typeof value !== 'object') return false
  const proto = Object.getPrototypeOf(value)
  return proto === Object.prototype || proto === null
}

export function isPrimitive(value) {
  return value === null || (typeof value !== 'object' && typeof value !== 'function');
}

// From: https://github.com/facebook/fbjs/blob/c69904a511b900266935168223063dd8772dfc40/packages/fbjs/src/core/shallowEqual.js
export function is(x, y) {
  if (x === y) {
    return x !== 0 || 1 / x === 1 / y
  } else {
    return x !== x && y !== y
  }
}

export const OBFUSCATED_ERROR =
  'An invariant failed, however the error is obfuscated because this is an production build.';

export function invariant(check: boolean, message?: string | boolean) {
  if (!check) throw new Error('[tacky]: ' + (message || OBFUSCATED_ERROR));
}

export function fail(message: string | boolean): never {
  invariant(false, message);
  throw 'X';
}
复制代码

总结

我相信总有能够知足需求的轮子,只要你认认真真的找,但也永远不存在一款完美的轮子,否则开源社区就像一滩死水,永远没有活跃度了,有时间那就本身去折腾去学习吧,重要的是你能从业务中发现痛点,有能力解决痛点,而且确实有收获,那就足够了,结果不必定很重要。要泼冷水实际上是很容易的,每家公司自研的轮子其实好用的很少,毕竟投入时间颇有限,但有时也不必定要被社区牵着鼻子走,本身动手多是每一个工程师工做中惟一的一点乐子了吧。

框架传送门:github.com/kujiale/tac…

目前还有挺多问题的,很简陋,主要靠做者空闲时间维护,欢迎你们来领 issue 一块儿共建,喜欢的话也能够来个 star

相关文章
相关标签/搜索