思维导图开发-技术选择-七种武器(3)之immutable.js

1,026 阅读7分钟

本系列旨在分享在开发思维导图库blink-mindblink-mind-desktop 时技术选择的一些经验。

状态管理的简单介绍

在使用react开发强交互app或者类库时,选择什么样的框架或者数据结构进行状态管理无疑是非常重要的。常见的状态管理框架有redux, mobx。状态管理库通常配合不可变(immutable)数据结构使用,因为可突变(mutable)对象会导致状态不可预测,难以调试。

不可变(immutable)数据结构有两个非常著名的库,immutable.jsimmer.js. immutable.js的实现原理是基于一种称之为trie的数据结构,关于实现机制的具体介绍可以参见YGYOO同学写的这篇文章。immer.js的实现原理是基于写时复制(copy on write),它的介绍和实现原理可以参考这篇文章

使用了immutable.js的知名开源项目有facebook 开发的富文本编辑框架draft.js,著名富文本编辑器框架slate, slate 在0.5 版本之前是使用的immutable.js , 0.5版本做了非常大的重构,使用自己写的一套数据结构来管理状态,没有再使用immutable.js。

使用了immer.js的知名开源项目有redux-toolkit, 这个是redux team官方推出的可以加速redux开发效率的工具库,使用它可以将原先啰嗦的redux状态管理代码精简。在它的createReducer这个工具函数中默认使用了immer.js,让大家可以使用正常的写可变操作的代码(normal mutative code)来创建简单的不可变状态更新(simpler immutable updates)。举个例子,之前的代码可能是

state = {
    ...state,
    user: {
        ...state.user
        name: 'xxx'
    }
}

现在可以使用下面的简单写法

state.user.name = 'xxx'

在强交互项目中进行状态管理的心得

无限次数的撤销重做

如果项目需要支持撤销回滚,支持录制操作流程,那么使用immutable.js 是一个非常好的选择。为什么呢,因为immutable.js 使用trie来进行数据共享。假设我们在app中进行了一次操作,并且对状态产生了修改,假设old-root是操作之前的状态,new-root是操作之后的状态,那么在immutable中的数据结构可能是如下图所示的这个样子:

可以看到,因为操作只是改变了一部分数据,所以old-root和new-root大部分的数据是相同的,在immutable中,相同的数据会被共享。 假设我们有需求保存很大的撤销栈,比方说支持1000次的撤销栈,那么如果使用immutable.js只需要将历史状态保存在stack当中即可,不需要担心数据量太多引起内存消耗过多的问题,因为这些历史状态的大部分数据都是共享的。

如果不使用immutable.js ,要实现支持无限次数的撤销回滚,同时内存消耗不大,实现起来就比较复杂啦。能立即想到的一个方法是,保存最开始的状态和最新的状态,以及中间操作的操作类型及参数,用f(0)表示初始状态,op[0]表示第一次操作的操作类型和参数,f(1)表示第一次操作之后的状态,那么 f(1) = f(0) * op[0], f(2) = f(1) * op[1],...,f(n)=f(n-1)*op[n-1],

假设当前状态是f(n), 那么如果需要进行撤销操作,返回状态f(n-1), 则需要进行n-1次运算, f(n-1) = f(0) * op[0] * op[1] * op[2] * ... * op[n-2] 这种方案越到后面,撤销操作的计算量越大。

考虑到用户的回滚一般情况下就是当前状态的前面几步,所以可以把上面的方案改成保存最新的状态,以及每次操作的逆操作,用数学函数的写法表示就是: f(n-1) = f(n) * inverse(op[n]), 其中inverse(op[n]) 表示第n次操作的逆操作。 这种方案即使在n很大的情况下进行撤销,初始撤销时的运算量都不大,但是如果遇到极端情况,用户进行了连续步数非常多的撤销,依然会导致计算量很大。

使用immutable.js,要实现无限次数的撤销重做,仅仅几行代码就可以搞定,将状态压入栈中或者从栈中弹出。

immutabe.js如何使用

对于刚接触immutable.js的同学来说,可能最头痛的地方就是immutable.js的api对对象的操作和pure js object很不一样。 还有就是如何写复杂业务状态的model。 定义。关于immutable.js的api可能就需要仔细的看下官方提供的文档。虽然immutable.js的提供的数据结构挺多的,常用的也就是List,Stack,Map,Record这几种。

假如我们使用immutable.js,如何编写复杂业务状态的model定义呢,我是通过参考draft.js 的代码来学习如何使用的,因为draft.js 和 immutable.js都是由facebook搞出来的,draft.js 里面的使用方法自然也是最佳实践。

在draft.js源代码的src/model/immutable目录下是项目中使用immutable进行model定义的代码所存放的地方。ContentState是draft.js中一个比较重要的数据定义,我们来看下ContentState.js的源码。

ContentState是继承自immutable里面的Record, immutable里面的Record和js里面的pure object类似,可以理解为不可变的pure object。

继承一个Record的套路是:

  1. 首先定义一个RecordType
type ContentStateRecordType = {
  entityMap: ?any,
  blockMap: ?BlockMap,
  selectionBefore: ?SelectionState,
  selectionAfter: ?SelectionState,
  ...
};
  1. 然后定义一个defaultRecord
const defaultRecord: ContentStateRecordType = {
  entityMap: null,
  blockMap: null,
  selectionBefore: null,
  selectionAfter: null,
};
  1. 最后定义一个class继承Record(defaultRecord)
const ContentStateRecord = (Record(defaultRecord): any);
class ContentState extends ContentStateRecord

因为draft.js 使用的是flow, 对flow 不熟悉的同学可能看起来不是很理解。flow 和 typescript 在类型定义这块比较类似,看得懂typescript 就可以看的懂flow. 如果是既不使用typescript也不使用flow,只是使用JavaScript的话,那么忽略第一步就可以了。类型定义可以让编译器在编译阶段就能识别出一些错误出来,同时也可以让代码的可读性更高,IDE的代码自动补全也会支持的更好。

一般来说,我的心得是业务里面最顶层的model一般继承自immutable.Record。

如何进行状态转移

同样也是参考draft.js里面的实践,将每个状态转移的操作都封装成一个函数,状态转移函数接受一个状态的model, 以及一些其他操作参数,返回一个状态model,

draft.js中状态转移函数在src/model/modifier/DraftModifier.js中定义了一些,

比方说插入文本这个操作对应的状态转移函数的声明如下

  insertText: function(
    contentState: ContentState,
    targetRange: SelectionState,
    text: string,
    inlineStyle?: DraftInlineStyle,
    entityKey?: ?string,
  ): ContentState

ContentState上文说过是业务状态的顶层model,这个函数接受一个顶层model和一些和操作相关的参数,放回一个经过状态转移之后的新的顶层model。

业务上来理解就是当在富文本编辑器中插入文本时,假设插入之前的状态是contentState, targetRange,text,inlineStyle,entityKey是和插入文本这个业务逻辑相关的参数,插入之后的状态newContentState=insertText(contentState,targetRange,text,inlineStyle,entityKey)。 总结一下,业务里面的状态转移过程是:

newModel = transformFunction(oldModel,args);

在blink-mind中状态转移函数集中写在sheet-model-modifier.ts中,举个例子, 拖拽移动topic的状态转移函数如下

function dragAndDrop({ model, srcKey, dstKey, dropDir }) {
  const srcTopic = model.getTopic(srcKey);
  const dstTopic = model.getTopic(dstKey);

  const srcParentKey = srcTopic.parentKey;
  const srcParentTopic = model.getTopic(srcParentKey);
  let srcParentSubKeys = srcParentTopic.subKeys;
  const srcIndex = srcParentSubKeys.indexOf(srcKey);

  srcParentSubKeys = srcParentSubKeys.delete(srcIndex);

  if (dropDir === 'in') {
    let dstSubKeys = dstTopic.subKeys;
    dstSubKeys = dstSubKeys.push(srcKey);
    model = model.withMutations(m => {
      m.setIn(['topics', srcParentKey, 'subKeys'], srcParentSubKeys)
        .setIn(['topics', srcKey, 'parentKey'], dstKey)
        .setIn(['topics', dstKey, 'subKeys'], dstSubKeys)
        .setIn(['topics', dstKey, 'collapse'], false);
    });
  } else {
    const dstParentKey = dstTopic.parentKey;
    const dstParentItem = model.getTopic(dstParentKey);
    let dstParentSubKeys = dstParentItem.subKeys;
    const dstIndex = dstParentSubKeys.indexOf(dstKey);
    //src 和 dst 的父亲相同,这种情况要做特殊处理
    if (srcParentKey === dstParentKey) {
      let newDstParentSubKeys = List();
      dstParentSubKeys.forEach(key => {
        if (key !== srcKey) {
          if (key === dstKey) {
            if (dropDir === 'prev') {
              newDstParentSubKeys = newDstParentSubKeys.push(srcKey).push(key);
            } else {
              newDstParentSubKeys = newDstParentSubKeys.push(key).push(srcKey);
            }
          } else {
            newDstParentSubKeys = newDstParentSubKeys.push(key);
          }
        }
      });
      model = model.withMutations(m => {
        m.setIn(['topics', dstParentKey, 'subKeys'], newDstParentSubKeys);
      });
    } else {
      if (dropDir === 'prev') {
        dstParentSubKeys = dstParentSubKeys.insert(dstIndex, srcKey);
      } else if (dropDir === 'next') {
        dstParentSubKeys = dstParentSubKeys.insert(dstIndex + 1, srcKey);
      }
      model = model.withMutations(m => {
        m.setIn(['topics', srcParentKey, 'subKeys'], srcParentSubKeys)
          .setIn(['topics', srcKey, 'parentKey'], dstParentKey)
          .setIn(['topics', dstParentKey, 'subKeys'], dstParentSubKeys)
          .setIn(['topics', dstParentKey, 'collapse'], false);
      });
    }
  }
  return model;
}