状态管理框架开发不完全指南

导读

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

文章比较长,所以列个大纲,读者可以挑选自己感兴趣的章节以节省阅读时间。如果看完大纲你认为对你现在这个阶段应该没有什么帮助,那么也是一件好事,这一节就是为了帮助你节省下这个时间去做其他更有意义的事情。

前世今生

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

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

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

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

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

核心问题

状态管理框架的核心其实就是发布订阅模式,不管是 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 是怎么实现的,代码很少,这里就不扩展了,源码传送门:https://github.com/reduxjs/redux/blob/master/src/createStore.js

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 支持时间旅行总是作为一项“优势”去被对比。所以有多少人在业务开发中深度使用时间旅行功能,对于大部分流程不超过 2、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';
}

总结

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

框架传送门:https://github.com/kujiale/tacky

目前还有挺多问题的,很简陋,主要靠作者空闲时间维护,欢迎大家来领 issue 一起共建,喜欢的话也可以来个 star

知识共享许可协议
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。