低代码场景的状态管理方案
在复杂应用中,例如低代码、富文本编辑器的场景下,数据结构的设计就显得非常重要,这种情况下的状态管理并非是redux
、mobx
等通用解决方案,而是需要针对具体场景进行定制化设计,那么在这里我们来尝试基于Immer
以及OT-JSON
实现原子化、可协同、高扩展的应用级状态管理方案。
描述
将Immer
与OT-JSON
结合的想法来自于slate
,我们首先来看一下slate
的基本数据结构,下面的例子是高亮块的描述。这个数据结构看起来非常像零代码/低代码的结构,因为其含有很多children
,而且存在对节点的装饰描述,即bold
、border
、background
等属性值。
[
{
"highlight-block" : {
border : "var(--arcoblue-6)" ,
background : "var(--arcoblue-3)" ,
} ,
children : [
{ children : [ { text : "🌰 " } , { text : "举个栗子" , bold : true } ] } ,
{ children : [ { text : "支持高亮块 可以用于提示文档中的重要内容。" } ] } ,
] ,
} ,
] ;
那么这里的设计就很有趣,之前的文章中我们就聊过,本质上低代码和富文本都是基于DSL
的描述来操作DOM
结构,只不过富文本主要是通过键盘输入来操作DOM
,而无代码则是通过拖拽等方式来操作DOM
,这里当然是有些共通的设计思路,这个结论其实就是来自于slate
的状态管理。
本文实现的相关DEMO
都在https://github.com/WindRunnerMax/webpack-simple-environment/tree/master/packages/immer-ot-json
中。
基本原则
前边我们也提到了数据结构的具体场景进行定制化设计,这部分主要指的是JSON
的结构非常灵活,像是高亮块的描述,我们可以将其设计为单独的对象,也可以将其拍平,以Map
的形式来描述节点的装饰,再例如上述文本内容则规定了需要用text
属性描述。
原子化的设计非常重要,在这里我们将原子化分为两部分,结构的原子化与操作的原子化。结构的原子化意味着我们可以将节点自由组合,而操作的原子化则意味着我们可以通过描述来操作节点状态,这两者的组合可以方便地实现组件渲染、状态变更、历史操作等等。
节点的自由组合可以应用在很多场景中,例如表单结构中,任何一个表单项都可以都可以变为其他表单项的嵌套结构,组合模式可以设定部分规则来限制。操作的原子化可以更方便地处理状态变更,同样是在表单中,嵌套的表单项展开/折叠状态就需要通过状态变更实现。
当然,原子化执行操作的时候可能并没有那么理想,组合ops
来执行操作表达类似action
范式的操作也是很常规的操作,这部分就是需要compose
的处理方式。并且状态管理可能并不是都需要持久化,在临时状态管理中,client-side-xxx
属性处理很容易实现,AXY+Z
值处理则会更加复杂。
协同算法的基础同样是原子化的操作,类似于redux
的范式action
操作非常方便,但是却无法处理协同冲突,同样也不容易处理历史操作等。这一局限性源于其单向、离散的操作模型,每个action
仅表达独立意图,而缺乏对全局状态因果关系(操作A
影响操作B
状态)的显式维护。
OT-JSON
则可以帮助我们将原子化的操作,扩展到协同编辑的复杂场景中,通过引入操作变换OT
,以此来解决冲突。当然仅仅是前端引入操作变换是不够的,还需要引入后端的协同框架,例如ShareDB
等。当然,CRDT
的协同算法也是可行的选择,这属于应用的选型问题了。
此外,OT-JSON
天然可以支持操作历史的维护,每个操作都携带了足够的上下文信息,使得系统能够追溯状态变化的完整链条,为撤销/重做、版本回溯等高级功能提供了基础。操作之间的因果关系也被显式地记录下来,使得系统能够做到操作A
必须在操作B
之前应用这样的约束条件。
扩展性这部分的设计可以是比较丰富的,,树形结构天然适合承载嵌套式数据交互。例如飞书文档的各种模块,都是以Blocks
的形式扩展出来的。恰好飞书的数据结构协同也是使用OT-JSON
来实现的,文本的协同则是借助了EasySync
作为OT-JSON
的子类型来实现的,以此来提供更高的扩展性。
当然,扩展性并不是说可以完全自由地接入插件,插件内的数据结构还是需要整体接受OT-JSON
的调度,并且文本这种特殊的子类型也要单独调度。以此系统框架能够将各种异构内容模块统一纳入协同体系,并且可以实现统一的状态管理、协同编辑、历史记录等功能。
Immer
Immer
简化了不可变数据的操作,引入一种称为草稿状态的概念,以此允许开发者以直观的可变方式编写代码,同时底层自动生成全新的不可变对象。传统方式中,修改深层嵌套的数据需要小心翼翼地展开每一层结构,既容易出错又让代码显得复杂。
const reducer = ( state , action ) => {
return {
... state ,
first : {
... state . first ,
second : {
... state . first . second ,
value : action ,
} ,
} ,
} ;
} ;
而Immer
通过创建一个临时的草稿对象,让开发者像操作普通对象一样直接赋值、增删属性,甚至使用数组的push
、pop
等方法。完成所有修改后,便基于草稿状态的变更记录,生成变更后与原始数据结构共享未修改部分的新对象。这种机制既避免了深拷贝的性能损耗,又保证了数据的不可变性。
const reducer = ( state , action ) => {
state . first . second . value = action ;
} ;
在Immer
中非常重要的一点是,在使用Proxy
代理修改这个过程中,仅在访问到数据的时候才会创建Proxy
对象,也就是说这是一种按需代理的懒代理机制,这样就不需要创建草稿时遍历创建所有代理。这种机制极大地减少了不必要的性能开销,尤其当处理大型复杂对象时。
例如修改了一个深层嵌套属性draft.a.b.c = 1
,Immer
会沿着访问路径逐层生成代理,Proxy(a)
、Proxy(a.b)
、Proxy(a.b.c)
。因此使用Immer
的时候还需要注意,在修改对象的时候尽可能保持仅读取需要修改的部分,其他的代理操作要在草稿,避免不必要的代理生成。
OT-JSON
在slate
中实现了9
种原子操作来描述变更,这其中包含了文本处理insert_text
、节点处理insert_node
、选区变换set_selection
的操作等。但是在slate
中虽然实现了操作变换与操作反转等,但是并未单独抽离独立的包,因此很多设计都是内部实现的,不具有通用性。
insert_node
: 插入节点。
insert_text
: 插入文本。
merge_node
: 合并节点。
move_node
: 移动节点。
remove_node
: 移除节点。
remove_text
: 移除文本。
set_node
: 设置节点。
set_selection
: 设置选区。
split_node
: 分割节点。
类似的,在OT-JSON
中实现了11
种操作,且json0
的结构设计已经过了广泛的生产环境验证,核心目标是通过结构化的数据表达,确保不同客户端之间的数据一致性。此外,富文本场景中SubType
仍然需要扩展,例如飞书的EasySync
类型扩展,那自然就需要更多的操作来描述变更。
{p:[path], na:x}
: 在指定的路径[path]
值上加x
数值。
{p:[path,idx], li:obj}
: 在列表[path]
的索引idx
前插入对象obj
。
{p:[path,idx], ld:obj}
: 从列表[path]
的索引idx
中删除对象obj
。
{p:[path,idx], ld:before, li:after}
: 用对象after
替换列表[path]
中索引idx
的对象before
。
{p:[path,idx1], lm:idx2}
: 将列表[path]
中索引idx1
的对象移动到索引idx2
处。
{p:[path,key], oi:obj}
: 向路径[path]
中的对象添加键key
和对象obj
。
{p:[path,key], od:obj}
: 从路径[path]
中的对象中删除键key
和值obj
。
{p:[path,key], od:before, oi:after}
: 用对象after
替换路径[path]
中键key
的对象before
。
{p:[path], t:subtype, o:subtypeOp}
: 对路径[path]
中的对象应用类型为t
的子操作o
,子类型操作。
{p:[path,offset], si:s}
: 在路径[path]
的字符串的偏移量offset
处插入字符串s
,内部使用子类型。
{p:[path,offset], sd:s}
: 从路径[path]
的字符串的偏移量offset
处删除字符串s
,内部使用子类型。
除了原子化的操作之外,最核心的就是操作变换的算法实现,这部分是协同的基础。JSON
的原子操作并非完全独立的,必须要通过操作变换来保证操作的执行顺序可以遵循其因果依赖。同时,对于操作反转的实现也是非常重要的,这部分意味着我们可以实现撤销、重做等功能。
数据结构
在低代码、富文本、画板/白板、表单引擎等等编辑器应用场景中,仅仅是使用JSON
数据结构来描述内容是不够的。类比在组件中,div
是描述视图的,状态是需要额外定义的,并且通过事件驱动来改变状态。而在编辑器场景中,JSON
既是视图描述也是要操作的状态。
那么基于JSON
来渲染视图这件事并不复杂,特别是在表格渲染中的场景会很常见。而通过操作来变更数据结构则并没有那么简单,那么基于OT-JSON
我们可以实现原子化的数据变更,与Immer
结合则可以配合视图的渲染刷新,在这里我们先以单元测试的方式测试数据结构的操作变换。
基本操作
针对数据的基本操作,无非就是增删改查,查这部分主要就是根据path
读数据即可,而我们关注的主要是增删改这部分与Immer
的结合。首先是insert
操作,p
表示路径,li
表示插入值,在变更之后就可以检查变更后的值是否正确,以及未修改对象的引用复用。
// packages/immer-ot-json/test/insert.test.ts
const baseState = {
a : {
b : [ 1 ] as number [ ] ,
} ,
d : { e : 2 } ,
} ;
const draft = createDraft ( baseState ) ;
const op : Op = {
p : [ "a" , "b" , 0 ] ,
li : 0 ,
} ;
json . type . apply ( draft , [ op ] ) ;
const nextState = finishDraft ( draft ) ;
expect ( nextState . a . b [ 0 ] ) . toBe ( 0 ) ;
expect ( nextState . a . b [ 1 ] ) . toBe ( 1 ) ;
expect ( nextState . a ) . not . toBe ( baseState . a ) ;
expect ( nextState . a . b ) . not . toBe ( baseState . a . b ) ;
expect ( nextState . d ) . toBe ( baseState . d ) ;
expect ( nextState . d . e ) . toBe ( baseState . d . e ) ;
删除操作也是类似的实现,ld
表示删除值,注意这里是删除的具体值而不是索引,这主要是为了invert
转换的方便。同样可以看到,Immer
的draft
对象在变更之后,只有变更的部分是新的对象,其他部分是引用复用的。
// packages/immer-ot-json/test/delete.test.ts
const baseState = {
a : {
b : [ 0 , 1 , 2 ] as number [ ] ,
} ,
d : { e : 2 } ,
} ;
const draft = createDraft ( baseState ) ;
const op : Op = {
p : [ "a" , "b" , 1 ] ,
ld : 1 ,
} ;
json . type . apply ( draft , [ op ] ) ;
const nextState = finishDraft ( draft ) ;
expect ( nextState . a . b [ 0 ] ) . toBe ( 0 ) ;
expect ( nextState . a . b [ 1 ] ) . toBe ( 2 ) ;
expect ( nextState . a ) . not . toBe ( baseState . a ) ;
expect ( nextState . a . b ) . not . toBe ( baseState . a . b ) ;
expect ( nextState . d ) . toBe ( baseState . d ) ;
expect ( nextState . d . e ) . toBe ( baseState . d . e ) ;
更新操作在OT-JSON
中实际上需要同时定义oi
和od
,相当于两个原子操作的组合,具体的实现是先插入后删除。同样的,将两者的值都放置出来而不是仅处理索引,在invert
时就不需要snapshot
来辅助得到原始值,并且Immer
的复用效果仍然没有问题。
// packages/immer-ot-json/test/update.test.ts
const baseState = {
a : {
b : { c : 1 } ,
} ,
d : { e : 2 } ,
} ;
const draft = createDraft ( baseState ) ;
const op : Op = {
p : [ "a" , "b" , "c" ] ,
// 应用时未校验, 但为了保证 invert 的正确性, 这里需要确定原始值
// https://github.com/ottypes/json0/blob/master/lib/json0.js#L237
od : 1 ,
oi : 3 ,
} ;
json . type . apply ( draft , [ op ] ) ;
const nextState = finishDraft ( draft ) ;
expect ( nextState . a . b . c ) . toBe ( 3 ) ;
expect ( nextState . a ) . not . toBe ( baseState . a ) ;
expect ( nextState . a . b ) . not . toBe ( baseState . a . b ) ;
expect ( nextState . d ) . toBe ( baseState . d ) ;
expect ( nextState . d . e ) . toBe ( baseState . d . e ) ;
操作变换
操作变换的应用场景主要是在协同编辑中,但是在非协同的情况下也有着大量应用。举个例子,在上传图片的时候,我们不应该将上传中的这个状态放置在undo
栈中,而无论是将其作为不可撤销的操作,还是合并先前undo
栈中已有的操作,都需要操作变换的实现。
我们可以理解b'=transform(a, b)
的意思是,假设a
和b
都是从相同的draft
分支出来的,那么b'
就是假设a
已经应用了,此时b
需要在a
的基础上变换出b'
才能直接应用,我们也可以理解为transform
解决了a
操作对b
操作造成的影响,即维护因果关系。
在这里我们仍然测试最基本的insert
、delete
、retain
的操作变换,其实我们可以看到,因果关系中位置的偏移是比较重要的,例如远程的b
操作与即将应用的a
操作都是删除操作,当b
操作执行时a
操作要删除的内容需要在b
的操作结果后重新计算索引。
// packages/immer-ot-json/test/transform.test.ts
// insert
const base : Op [ ] = [ { p : [ 1 ] } ] ;
const op : Op [ ] = [ { p : [ 0 ] , li : 1 } ] ;
const tf = type . transform ( base , op , "left" ) ;
expect ( tf ) . toEqual ( [ { p : [ 2 ] } ] ) ;
// delete
const base : Op [ ] = [ { p : [ 1 ] } ] ;
const op : Op [ ] = [ { p : [ 0 ] , ld : 1 } ] ;
const tf = type . transform ( base , op , "left" ) ;
expect ( tf ) . toEqual ( [ { p : [ 0 ] } ] ) ;
// retain
const base : Op [ ] = [ { p : [ 1 ] } ] ;
const op : Op [ ] = [ { p : [ 1 , "key" ] , oi : "value" } ] ;
const tf = type . transform ( base , op , "left" ) ;
expect ( tf ) . toEqual ( [ { p : [ 1 ] } ] ) ;
反转操作
反转操作即invert
方法,主要是为了实现undo
、redo
等功能。前边我们也提到了,进行apply
的时候很多操作是需要拿到原始值的,这些值在执行时并未实际校验,但是这样就可以直接在invert
时直接转换,不需要snapshot
来辅助计算值。
此外,invert
支持的是批量的操作反转,在下面的例子中也可以看出接收的参数是Op[]
。这里可以仔细思考一下,应用时数据操作正向的,而反转时的执行顺序是需要反转的,例如abc
的三个操作,在invert
后对应的应该是cba
的反转op
。
// packages/immer-ot-json/test/invert.test.ts
// insert
const op : Op [ ] = [ { p : [ 0 ] , li : 1 } ] ;
const inverted = type . invert ( op ) ;
expect ( inverted ) . toEqual ( [ { p : [ 0 ] , ld : 1 } ] ) ;
// delete
const op : Op [ ] = [ { p : [ 0 ] , ld : 1 } ] ;
const inverted = type . invert ( op ) ;
expect ( inverted ) . toEqual ( [ { p : [ 0 ] , li : 1 } ] ) ;
// retain
const op : Op [ ] = [ { p : [ 1 , "key" ] , oi : "value2" , od : "value1" } ] ;
const inverted = type . invert ( op ) ;
expect ( inverted ) . toEqual ( [ { p : [ 1 , "key" ] , od : "value2" , oi : "value1" } ] ) ;
批量应用
批量应用操作是个非常麻烦的问题,OT-JSON
是支持多个op
同时应用的,然而在apply
时数据是单个操作执行的。这个场景还是很常见的,例如在实现画板时,按住shift
并且单击图形节点可以多选,然后执行删除操作,那么这就是一个同时基于draft
的批量操作,理论上会存在因果关系。
在下面这个例子中,我们假设现在有4
个op
,并且存在重复的索引值处理。那么在下面的例子中,我们理论上期待的结果应该是将1/2/3
的值删除掉,即最终结果是[0, 4, 5, 6]
,然而最终得到的结果却是[0, 2, 4]
,这就是apply
是独立执行,且没有处理op
间的关联性引起的。
// packages/immer-ot-json/test/batch.test.ts
const baseState = {
a : {
b : [ 0 , 1 , 2 , 3 , 4 , 5 , 6 ] as number [ ] ,
} ,
} ;
const ops : Op [ ] = [
{ p : [ "a" , "b" , 1 ] , ld : 1 } ,
{ p : [ "a" , "b" , 2 ] , ld : 2 } ,
{ p : [ "a" , "b" , 3 ] , ld : 3 } ,
{ p : [ "a" , "b" , 3 ] , ld : 3 } ,
] ;
const nextState = type . apply ( baseState , ops ) ;
expect ( nextState . a . b ) . toEqual ( [ 0 , 2 , 4 ] ) ;
那么由于先前提到过了,transform
解决了a
操作对b
操作造成的影响,即维护因果关系。那么在这种情况下,就可以通过transform
来处理操作之间的关联性问题,那么我们就可以直接尝试调用transform
来处理这个问题。
然而transform
的函数签名是transform(op1, op2, side)
,这就意味着我们需要两组操作之间进行变换,然而我们现在的ops
是仅单组操作,因此我们需要考虑这部分应该如何结合。如果以空组变换ops
组的话,返回的结果是[]
是不正确的,因此我们需要尝试单op
来处理。
因此,最开始我准备考虑使用将已经应用过的ops
操作裁剪出来,然后将其直接影响的值通过transform
来移除,这里还需要考虑是否需要将应用过的操作顺序反转再变换,而且这里也能够看到删除的值没有问题,且重复的操作也能够正确处理。
// packages/immer-ot-json/test/batch.test.ts
const baseState = {
a : {
b : [ 0 , 1 , 2 , 3 , 4 , 5 , 6 ] as number [ ] ,
} ,
} ;
const ops : Op [ ] = [
{ p : [ "a" , "b" , 1 ] , ld : 1 } ,
{ p : [ "a" , "b" , 2 ] , ld : 2 } ,
{ p : [ "a" , "b" , 3 ] , ld : 3 } ,
{ p : [ "a" , "b" , 3 ] , ld : 3 } ,
] ;
const tfOps = ops . map ( ( op , index ) => {
const appliedOps = ops . slice ( 0 , index ) ;
appliedOps . reverse ( ) ;
const nextOps = type . transform ( [ op ] , appliedOps , "left" ) ;
return nextOps [ 0 ] ;
} ) ;
expect ( tfOps [ 0 ] ) . toEqual ( { p : [ "a" , "b" , 1 ] , ld : 1 } ) ;
expect ( tfOps [ 1 ] ) . toEqual ( { p : [ "a" , "b" , 1 ] , ld : 2 } ) ;
expect ( tfOps [ 2 ] ) . toEqual ( { p : [ "a" , "b" , 1 ] , ld : 3 } ) ;
expect ( tfOps [ 3 ] ) . toEqual ( undefined ) ;
const nextState = type . apply ( baseState , tfOps . filter ( Boolean ) ) ;
expect ( nextState . a . b ) . toEqual ( [ 0 , 4 , 5 , 6 ] ) ;
在这里我们可以考虑将其简单封装一下,然后直接调用函数就可以得到最终的结果,这样就不需要将逻辑全部混杂在整个应用的过程中。这里可以对比一下Delta
的OT
实现,单次Delta
的ops
是以相对位置处理的数据,而OT-JSON
是绝对位置,因此在批量处理时需要进行转换。
// packages/immer-ot-json/test/batch.test.ts
const ops : Op [ ] = [
{ p : [ "a" , "b" , 1 ] , ld : 1 } ,
{ p : [ "a" , "b" , 2 ] , ld : 2 } ,
{ p : [ "a" , "b" , 3 ] , ld : 3 } ,
{ p : [ "a" , "b" , 3 ] , ld : 3 } ,
] ;
const transformLocal = ( op1 : Op , base : Op [ ] , dir : "left" | "right" ) : Op => {
let transformedOp = op1 ;
const reversed = [ ... base ] . reverse ( ) ;
for ( const op of reversed ) {
const [ result ] = type . transformComponent ( [ ] , transformedOp , op , dir ) ;
if ( ! result ) return result ;
transformedOp = result ;
}
return transformedOp ;
} ;
ops . forEach ( ( op , index ) => {
const appliedOps = ops . slice ( 0 , index ) ;
const a1 = transformLocal ( op , appliedOps , "left" ) ;
appliedOps . reverse ( ) ;
const b1 = type . transform ( [ op ] , appliedOps , "left" ) ;
expect ( a1 ) . toEqual ( b1 [ 0 ] ) ;
} ) ;
然而看起来上述的例子表现是没问题的,然而考虑到实际的应用场景,我们可以测试一下执行顺序的问题。下面的例子中,我们虽然仅仅是调整了ops
的顺序,但最终却得到了错误的结果。
// packages/immer-ot-json/test/batch.test.ts
const ops : Op [ ] = [
{ p : [ "a" , "b" , 1 ] , ld : 1 } ,
{ p : [ "a" , "b" , 3 ] , ld : 3 } ,
{ p : [ "a" , "b" , 2 ] , ld : 2 } ,
{ p : [ "a" , "b" , 3 ] , ld : 3 } ,
] ;
const tfOps = ops . map ( ( op , index ) => {
const appliedOps = ops . slice ( 0 , index ) ;
appliedOps . reverse ( ) ;
const nextOps = type . transform ( [ op ] , appliedOps , "left" ) ;
return nextOps [ 0 ] ;
} ) ;
expect ( tfOps [ 0 ] ) . toEqual ( { p : [ "a" , "b" , 1 ] , ld : 1 } ) ;
expect ( tfOps [ 1 ] ) . toEqual ( { p : [ "a" , "b" , 2 ] , ld : 3 } ) ;
expect ( tfOps [ 2 ] ) . toEqual ( { p : [ "a" , "b" , 1 ] , ld : 2 } ) ;
// 这里是存在问题的 希望得到的结果是 undefined
expect ( tfOps [ 3 ] ) . toEqual ( { p : [ "a" , "b" , 1 ] , ld : 3 } ) ;
思考一下,我们究竟应该如何捋清楚这个因果关系问题,是不是可以考虑到这件事本身就应该是由a
应用后,b
发生了变更。那么在abcd
这种情况下,应该是以a
为基准,变换b/c/d
,然后以b
为基准,变换c/d
,以此类推。
// packages/immer-ot-json/test/batch.test.ts
const ops : Op [ ] = [
{ p : [ "a" , "b" , 1 ] , ld : 1 } ,
{ p : [ "a" , "b" , 3 ] , ld : 3 } ,
{ p : [ "a" , "b" , 2 ] , ld : 2 } ,
{ p : [ "a" , "b" , 3 ] , ld : 3 } ,
] ;
const copied : Op [ ] = [ ... ops ] ;
const len = copied . length ;
for ( let i = 0 ; i < len ; i ++ ) {
// 这里是 copied 而不是 ops, 是应用后的操作
// 否则会导致实际轮转的操作变换产生错误
// 例如 [1,2,3] 下会出现 [1,1,undefined] 的情况
const base = copied [ i ] ;
for ( let k = i + 1 ; k < len ; k ++ ) {
const op = copied [ k ] ;
if ( ! op ) continue ;
const nextOp = type . transformComponent ( [ ] , op , base , "left" ) ;
copied [ k ] = nextOp [ 0 ] ;
}
}
expect ( copied [ 0 ] ) . toEqual ( { p : [ "a" , "b" , 1 ] , ld : 1 } ) ;
expect ( copied [ 1 ] ) . toEqual ( { p : [ "a" , "b" , 2 ] , ld : 3 } ) ;
expect ( copied [ 2 ] ) . toEqual ( { p : [ "a" , "b" , 1 ] , ld : 2 } ) ;
expect ( copied [ 3 ] ) . toEqual ( undefined ) ;
这个问题的本质实际上是多个op
组合的时候,其每个操作都是独立的绝对位置,并非会将其实现为相对的位置,例如在Delta
中,compose
操作是会计算为相对位置的。那么我们自然也可以将其封装为composeWith
方法,这个方法在合并ops
时,例如历史操作的合并会非常有用。
// packages/immer-ot-json/test/batch.test.ts
const ops : Op [ ] = [
{ p : [ "a" , "b" , 1 ] , ld : 1 } ,
{ p : [ "a" , "b" , 3 ] , ld : 3 } ,
{ p : [ "a" , "b" , 2 ] , ld : 2 } ,
{ p : [ "a" , "b" , 3 ] , ld : 3 } ,
] ;
const composeWith = ( base : Op [ ] , ops : Op [ ] ) => {
const waiting : Op [ ] = [ ] ;
for ( const opa of ops ) {
let nextOp = opa ;
for ( const opb of base ) {
nextOp = type . transformComponent ( [ ] , nextOp , opb , "left" ) [ 0 ] ;
if ( ! nextOp ) break ;
}
nextOp && waiting . push ( nextOp ) ;
}
return base . concat ( waiting . filter ( Boolean ) ) ;
} ;
const copied = ops . reduce ( ( acc , op ) => composeWith ( acc , [ op ] ) , [ ] as Op [ ] ) ;
expect ( copied [ 0 ] ) . toEqual ( { p : [ "a" , "b" , 1 ] , ld : 1 } ) ;
expect ( copied [ 1 ] ) . toEqual ( { p : [ "a" , "b" , 2 ] , ld : 3 } ) ;
expect ( copied [ 2 ] ) . toEqual ( { p : [ "a" , "b" , 1 ] , ld : 2 } ) ;
expect ( copied [ 3 ] ) . toEqual ( undefined ) ;
最后,我们还可以考虑到一个路径持有的场景,类似于我们实现富文本编辑器的Ref
模块。举个例子,当上传图片时,loading
状态时可能会有用户操作改变了原始路径,这个情况下当上传结束后将实际地址写入节点时,需要拿到最新的path
。
// packages/immer-ot-json/test/batch.test.ts
const baseState = {
a : {
b : [ 0 , 1 , 2 , 3 , 4 , 5 , 6 ] as number [ ] ,
} ,
} ;
// 持有且变换后的操作 目的是变换 path
// 例如如果是 ld 的话 则应该先变换 [5,6] => [5,5]
const refOps : Op [ ] = [
{ p : [ "a" , "b" , 5 , "attrs" ] , od : "k" , oi : "v" } ,
{ p : [ "a" , "b" , 6 , "attrs" ] , od : "k1" , oi : "v1" } ,
] ;
const apply = ( snapshot : typeof baseState , ops : Op [ ] ) => {
for ( let i = 0 , n = ops . length ; i < n ; ++ i ) {
const tfOp = ops [ i ] ;
if ( ! tfOp ) continue ;
// 变换出可直接应用的 ops 后, ref module 可以持有按序变换
for ( let k = 0 , n = refOps . length ; k < n ; ++ k ) {
const refOp = refOps [ k ] ;
if ( ! refOp ) continue ;
const [ result ] = type . transformComponent ( [ ] , refOp , tfOp , "left" ) ;
refOps [ k ] = result ;
}
}
return type . apply ( snapshot , ops ) ;
} ;
const tfOps : Op [ ] = [
{ p : [ "a" , "b" , 1 ] , ld : 1 } ,
{ p : [ "a" , "b" , 2 ] , ld : 3 } ,
{ p : [ "a" , "b" , 1 ] , ld : 2 } ,
] ;
const nextState = apply ( baseState , tfOps ) ;
expect ( nextState . a . b ) . toEqual ( [ 0 , 4 , 5 , 6 ] ) ;
expect ( refOps [ 0 ] ) . toEqual ( { p : [ "a" , "b" , 2 , "attrs" ] , od : "k" , oi : "v" } ) ;
expect ( refOps [ 1 ] ) . toEqual ( { p : [ "a" , "b" , 3 , "attrs" ] , od : "k1" , oi : "v1" } ) ;
低代码场景
在这里我们以简单的列表场景为示例,基于Immer
以及OT-JSON
实现基本的状态管理。列表的场景会是比较通用的实现,在这里我们会实现列表的增删、选区处理、历史操作等功能,这其中很多设计是参考slate
的状态管理实现。
数据操作
OT-JSON
进行apply
的时候,实际上执行的方案是逐个执行op
。那么使用OT-JSON
来管理状态的时候,会很容易思考出一个问题,如果更改了比较内部的数据状态,provider
提供的value
在最顶层的对象引用并不会发生改变,可能不会引起render
。
为什么说可能不引起render
,如果我们在状态变更之后,直接引用的对象不发生改变,setState
不会引起渲染行为。但是如果组件状态较多,其他的状态变更仍然会引起整个组件的状态刷新,例如下面的Child
组件本身没有props
发生改变,但count
值的变化还是会导致函数组件执行。
// https://reactplayground.vercel.app/
import React , { useState , Fragment } from 'react' ;
const Child = ( ) => {
console . log ( "render child" ) ;
return < div > Child < / div > ;
}
const App = ( ) => {
const [ count , setCount ] = useState ( 0 )
const handleClick = ( ) => {
setCount ( c => c + 1 ) ;
}
return (
< Fragment >
< button onClick = { handleClick } > { count } < / button >
< Child > < / Child >
< / Fragment >
) ;
}
export default App ;
当然我们在不考虑其他状态变更的情况下,此时最顶层的对象引用不变,那么自然整个视图都不会刷新,因此我们必须要从变更的节点开始,以此向上的节点都需要变更引用值。下面的例子中,若C
发生改变,则A
、C
的引用需要变更,其他对象保持原始值,Immer
恰好能够帮我们实现这个能力。
当然,在先前的例子中也可以发现,即使props
的值不变,在最顶层的值变更之后还是会导致整个函数组件重新执行。在这种情况下是需要配合React.memo
使用,以此来控制函数组件是否需要重新执行,将上面例子中的Child
组件包装memo
,就可以避免在count
值变化时重新执行组件。
const Child = React . memo ( ( ) => {
console . log ( "render child" ) ;
return < div > Child < / div > ;
} )
路径查找
通常来说,在执行变更时我们需要得到要处理的目标path
,特别是渲染后组件要操作本身时。在普通的变更中,我们可能更多的是依赖选区节点的表达,来得到要处理的目标节点。但是当我们想实现比较复杂的模块或者交互时,例如图片的异步上传等场景时,这可能并不足以让我们完成这些功能。
当我们使用React.memo
来控制组件渲染后,其实会隐式地引入一个问题。例如此时我们有二级列表嵌套,以及内容节点[1,2]
,如果在[1]
这个位置上插入新的节点,那么理论上原始的值应该变为[2,2]
,然而由于函数组件并未执行,其依然会保持原始的[1,2]
。
[
[ 0 , 0 , 0 ]
[ 1 , 1 , 1 ( * ) ]
]
// insert [1] [0,0,0] =>
[
[ 0 , 0 , 0 ]
[ 0 , 0 , 0 ]
[ 1 , 1 , 1 ( * ) ]
]
这里保持原始的[1,2]
具体指的是,如果我们将path
在渲染时传递给props
,并且自定义memo
的equal
函数并且传递path
,那么低索引值的变更会导致大量节点的组件重新执行,性能会重新劣化。而如果不传递给props
的话,在组件内部自然无法拿到节点渲染的path
。
在我们实现插件化的过程中,都是同一个插件来实现多个组件的渲染,这些组件都是同一种类型,却是渲染在不同path
下的。因此通过插件来获取由该插件渲染出组件的path
还是需要通过外层渲染状态来传递,上述的props
传递方案自然不合适,因此这里我们通过WeakMap
来实现path
获取。
在这里我们通过两个WeakMap
就可以实现findPath
的功能,NODE_TO_INDEX
用于存储节点与索引的映射关系,NODE_TO_PARENT
用于存储节点与父节点的映射关系。通过这两个WeakMap
就可以实现path
的查找,每次更新节点时,较低索引的映射关系都可以更新。
// packages/immer-ot-json/src/components/list.tsx
const children = useMemo ( ( ) => {
const children : JSX . Element [ ] = [ ] ;
const path = findPath ( currentNode ) ;
for ( let i = 0 ; i < nodes . length ; ++ i ) {
const p = path . concat ( i ) ;
const n = nodes [ i ] ;
NODE_TO_INDEX . set ( n , i ) ;
NODE_TO_PARENT . set ( n , currentNode ) ;
children . push ( < NodeModel node = { n } > < / NodeModel > ) ;
}
return children ;
} , [ currentNode , nodes , selection ] ) ;
那么在实际查找path
的时候,就可以从目标节点通过NODE_TO_PARENT
开始不断查找父节点,直到找到根节点为止。而在这个查找过程中,就可以通过NODE_TO_INDEX
来获取path
,也就是说我们只需要通过层级级别的遍历就可以查找到path
,而不需要遍历整个状态树。
// packages/immer-ot-json/src/utils/path.ts
export const findPath = ( node : Node | Editor ) => {
const path : number [ ] = [ ] ;
let child = node ;
// eslint-disable-next-line no-constant-condition
while ( true ) {
if ( child instanceof Editor ) {
return path ;
}
const parent = NODE_TO_PARENT . get ( child ) ;
if ( isNil ( parent ) ) {
break ;
}
const i = NODE_TO_INDEX . get ( child ) ;
if ( isNil ( i ) ) {
break ;
}
path . unshift ( i ) ;
child = parent as Node ;
}
throw new Error ( "Unable To Find Path" ) ;
} ;
那么实际上我们也可以想到一个问题,我们更新path
值时是需要渲染过程中执行的,也就是说我们想要获取最新的path
,必须要在渲染完成后才可以。因此我们的整个调度过程时序必须要控制好,否则会导致获取不到最新的path
,因此通常我们还需要在useEffect
来分发渲染完成事件。
这里还需要关注的是,由于实际编辑器引擎是需要依赖useEffect
本身的生命周期的,也就是必须要所有的子组件渲染完成后才触发父组件的effect
副作用。因此在整个节点外层的Context
级别渲染节点不能是React.lazy
的实现,当然实际插件渲染的内容是可以懒加载的。
/**
* 视图更新需要触发视图绘制完成事件 无依赖数组
* state -> parent -> node -> child ->|
* effect <- parent <- node <- child <-|
*/
useEffect ( ( ) => {
editor . logger . debug ( "OnPaint" ) ;
editor . state . set ( EDITOR_STATE . PAINTING , false ) ;
Promise . resolve ( ) . then ( ( ) => {
editor . event . trigger ( EDITOR_EVENT . PAINT , { } ) ;
} ) ;
} ) ;
选区状态
选区状态selection
的模块同样依赖于React
的状态维护,主要是将其作为Provider
来使用。而选区表达本身的维护是依赖于path
的,因此在点击节点时可以直接使用上述的findPath
来写入选区状态即可。
// packages/immer-ot-json/src/components/node.tsx
const onMouseDown = ( ) => {
const path = findPath ( node ) ;
editor . selection . set ( path ) ;
} ;
与上述的路径查找类似,我们并不会将节点本身的path
作为props
传递到节点上,因此节点需要得知本身是否处于选中状态同样需要设计。这里的设计需要考虑两部分,首先是全局的选区状态,这里直接使用Context
提供value
,其次是节点本身的状态,每个节点都需要独立的Context
。
全局的选区状态管理本身也分为两部分,全局的hooks
是用于提供所有子组件的选区值,子组件中直接useContext
即可,应用入口还需要使用编辑器本身的事件来管理Context
的选区状态值。
// packages/immer-ot-json/src/hooks/use-selection.ts
export const SelectionContext = React . createContext < Range | null > ( null ) ;
export const useSelection = ( ) => {
return useContext ( SelectionContext ) ;
} ;
// packages/immer-ot-json/src/components/app.tsx
const onSelectionChange = useMemoFn ( ( e : SelectionChangeEvent ) => {
const { current } = e ;
setSelection ( current ) ;
} ) ;
useEffect ( ( ) => {
editor . event . on ( EVENTS . SELECTION_CHANGE , onSelectionChange ) ;
return ( ) => {
editor . event . off ( EVENTS . SELECTION_CHANGE , onSelectionChange ) ;
} ;
} , [ editor , onSelectionChange ] ) ;
单个组件的选中状态的设计比较有趣,首先考虑到选区状态只有两种,即选中/非选中状态,因此每个节点外层都应该放置一个Provider
来管理状态。那么如果是一个深层次嵌套的组件选中状态,我们是需要改变最深层次的Provider
值才可以改变选中状态。
那么这里就需要依赖最顶层selection
的状态变更来触发最顶层的Provider
变更,然后每一级的状态变更都需要重新执行函数组件,以此来按需地处理选中状态的变更以及render
。也就是说,当深层次节点处于选中状态时,其沿着所有path
低索引的节点都会处于选中状态。
这里其实仍然是需要配合React.memo
来使用的,由于selected
会作为props
传递给子组件,因此在selected
值变更时,子组件会重新执行。因此这里的变换是从顶层开始,每个选中状态由选中到非选中,或者是从非选中到选中状态,都会执行一次rerender
。
// packages/immer-ot-json/src/hooks/use-selected.ts
export const SelectedContext = createContext < boolean > ( false ) ;
export const useSelected = ( ) => {
return useContext ( SelectedContext ) ;
} ;
// packages/immer-ot-json/src/components/list.tsx
const children = useMemo ( ( ) => {
const children : JSX . Element [ ] = [ ] ;
const path = findPath ( editor ) ;
for ( let i = 0 ; i < nodes . length ; ++ i ) {
const p = path . concat ( i ) ;
const n = nodes [ i ] ;
const isSelected = selection && isEqual ( selection , p ) ;
children . push (
< SelectedContext . Provider key = { n . key } value = { ! ! isSelected } >
< NodeModel selected = { ! ! isSelected } node = { n } > < / NodeModel >
< / SelectedContext . Provider >
) ;
}
return children ;
} , [ editor , nodes , selection ] ) ;
// packages/immer-ot-json/src/components/node.tsx
const isSelected = useSelected ( ) ;
History
History
模块是与OT-JSON
对于数据操作的部分结合比较紧密的模块,会深度应用transform
进行操作变换,包括选区和数据的变换。此外invert
方法也是必不可少的,逆转操作是undo
、redo
的基础。
首先需要关注在何时处理undo
,明显我们仅需要在apply
操作时才需要处理栈数据,而在apply
的时候还需要注意仅有用户触发的内容才需要处理。当操作源是History
模块本身,甚至是来源与远程协同的数据时,自然是不应该将新的数据推入栈中的。
不要忘记了选区的记录,当触发了撤销之后,我们的选区也应该要回归到前一个状态,因此我们实际处理的实际有两个,在will apply
的时机记录当前选区的值,在实际apply
的时候再将最新的变更changes
推入栈中。
// packages/immer-ot-json/src/editor/history.ts
const { changes , source } = event ;
if ( ! changes . length || source === "history" ) {
return void 0 ;
}
this . redoStack = [ ] ;
let inverted = type . invert ( changes ) ;
let undoRange = this . currentRange ;
this . undoStack . push ( { ops : inverted , range : undoRange } ) ;
通常来说,我们不希望每次执行变更的时候都入栈,特别是一些高频操作,例如输入文本、拖拽节点。因此我们可以考虑在时间片之内的操作合并,将其规整为同一个undo ops
,那么在这里就需要考虑如何将栈顶的ops
与当前的changes
合并,这其实就用到了之前我们的composeWith
方法。
// packages/immer-ot-json/src/editor/history.ts
if (
// 如果触发时间在 delay 时间片内 需要合并上一个记录
this . lastRecord + this . DELAY > timestamp &&
this . undoStack . length > 0
) {
const item = this . undoStack . pop ( ) ;
if ( item ) {
for ( const base of item . ops ) {
for ( let k = 0 ; k < inverted . length ; k ++ ) {
const op = inverted [ k ] ;
if ( ! op ) continue ;
const nextOp = type . transformComponent ( [ ] , op , base , "left" ) ;
inverted [ k ] = nextOp [ 0 ] ;
}
}
inverted = type . compose ( item . ops , inverted ) ;
undoRange = item . range ;
}
} else {
this . lastRecord = timestamp ;
}
undo
与redo
的两个方法通常是需要配合使用的,在不执行用户态的操作时,通过history
模块本身相互应用的changes
是需要进行变换然后入另一个栈。即undo
执行的changes
需要再invert
之后入redo
栈,反之亦然。
// packages/immer-ot-json/src/editor/history.ts
public undo ( ) {
if ( ! this . undoStack . length ) return void 0 ;
const item = this . undoStack . pop ( ) ;
if ( ! item ) return void 0 ;
const inverted = type . invert ( item . ops ) ;
this . redoStack . push ( { ops : inverted , range : this . transformRange ( item . range , inverted ) } ) ;
this . lastRecord = 0 ;
this . editor . state . apply ( item . ops , "history" ) ;
this . restoreSelection ( item ) ;
}
public redo ( ) {
if ( ! this . redoStack . length ) return void 0 ;
const item = this . redoStack . pop ( ) ;
if ( ! item ) return void 0 ;
const inverted = type . invert ( item . ops ) ;
this . undoStack . push ( { ops : inverted , range : this . transformRange ( item . range , inverted ) } ) ;
this . lastRecord = 0 ;
this . editor . state . apply ( item . ops , "history" ) ;
this . restoreSelection ( item ) ;
}
针对于选区的变换同样也会依赖与transform
,这里仅需要依赖path
参数的改变即可。选区变换的原因是此前存储的range
是基于未变更的值的,而此时出栈了就意味着已经执行了这些变更,因此需要变换来获取最新的选区。此外,恢复选区这里其实应该尽可能尝试恢复到变更附近的选区。
// packages/immer-ot-json/src/editor/history.ts
protected transformRange ( range : Range | null , changes : Op [ ] ) {
if ( ! range ) return range ;
const nextSelOp = type . transform ( [ { p : range } ] , changes , "left" ) ;
return nextSelOp ? ( nextSelOp [ 0 ] . p as Range ) : null ;
}
protected restoreSelection ( stackItem : StackItem ) {
if ( stackItem . range ) {
this . editor . selection . set ( stackItem . range ) ;
}
}
实际上History
这部分用到的操作变换远不止这些,在协同场景中我们需要考虑如何应对remote
的操作,毕竟原则是我们仅能撤销自己的操作。还有诸如图片上传等场景是需要合并某undo
栈的操作的,这里也需要操作变换来应对ops
移动所带来的副作用,这部分我们放个基于Delta
的实现。
/**
* 将 mergeId 记录合并到 baseId 记录
* - 暂时仅支持合并 retain 操作, 需保证 baseId < mergeId
* - 其他操作暂时没有场景, 可查阅 NOTE 的 History Merge 一节
* @param baseId
* @param mergeId
*/
public mergeRecord ( baseId : string , mergeId : string ) : boolean {
const baseIndex = this . undoStack . findIndex ( item => item . id . has ( baseId ) ) ;
const mergeIndex = this . undoStack . findIndex ( item => item . id . has ( mergeId ) ) ;
if ( baseIndex === - 1 || mergeIndex === - 1 || baseIndex >= mergeIndex ) {
return false ;
}
const baseItem = this . undoStack [ baseIndex ] ;
const mergeItem = this . undoStack [ mergeIndex ] ;
let mergeDelta = mergeItem . delta ;
for ( let i = mergeIndex - 1 ; i > baseIndex ; i -- ) {
const item = this . undoStack [ i ] ;
mergeDelta = item . delta . transform ( mergeDelta ) ;
}
this . undoStack [ baseIndex ] = {
id : new Set ( [ ... baseItem . id , ... mergeItem . id ] ) ,
// 这里是 merge.compose(base) 而不是相反
// 因为 undo 后的执行顺序是 merge -> base
delta : mergeDelta . compose ( baseItem . delta ) ,
range : baseItem . range ,
} ;
this . undoStack . splice ( mergeIndex , 1 ) ;
return true ;
}
/**
* 变换远程堆栈
* @param stack
* @param delta
*/
protected transformStack ( stack : StackItem [ ] , delta : Delta ) {
let remoteDelta = delta ;
for ( let i = stack . length - 1 ; i >= 0 ; i -- ) {
const prevItem = stack [ i ] ;
stack [ i ] = {
id : prevItem . id ,
delta : remoteDelta . transform ( prevItem . delta , true ) ,
range : prevItem . range && this . transformRange ( prevItem . range , remoteDelta ) ,
} ;
remoteDelta = prevItem . delta . transform ( remoteDelta ) ;
if ( ! stack [ i ] . delta . ops . length ) {
stack . splice ( i , 1 ) ;
}
}
}
总结
在这里我们基于Immer
和OT-JSON
设计了一套应用状态管理方案,通过Immer
的草稿机制简化不可变数据操作,结合OT-JSON
的原子化操作与协同算法,实现原子化、可协同、高扩展的应用级状态管理方案,以及按需渲染的视图性能优化方案。整体来说,这个方案比较适用于嵌套数据结构的动态组合与状态管理。
在实际应用中,我们还是需要根据场景来选择合适的状态管理方案。在应用级别的场景中,例如富文本、画板、低代码中,顶层的架构设计还是非常重要的,所有的状态变更、节点类型都应该由这层架构设计扩展出来。而在我们的业务层面上,则更注重的是业务逻辑的功能实现,这部分其实就显得相对更自由一些,绝大部分实现都是面向过程的逻辑,更关注的则是代码的组织形式了。
每日一题
参考