本系列旨在分享在开发思维导图库blink-mind 和blink-mind-desktop 时技术选择的一些经验。
状态管理的简单介绍
在使用react开发强交互app或者类库时,选择什么样的框架或者数据结构进行状态管理无疑是非常重要的。常见的状态管理框架有redux, mobx。状态管理库通常配合不可变(immutable)数据结构使用,因为可突变(mutable)对象会导致状态不可预测,难以调试。
不可变(immutable)数据结构有两个非常著名的库,immutable.js 和 immer.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的套路是:
- 首先定义一个RecordType
type ContentStateRecordType = {
entityMap: ?any,
blockMap: ?BlockMap,
selectionBefore: ?SelectionState,
selectionAfter: ?SelectionState,
...
};
- 然后定义一个defaultRecord
const defaultRecord: ContentStateRecordType = {
entityMap: null,
blockMap: null,
selectionBefore: null,
selectionAfter: null,
};
- 最后定义一个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;
}