# 低代码场景的状态管理方案 在复杂应用中,例如低代码、富文本编辑器的场景下,数据结构的设计就显得非常重要,这种情况下的状态管理并非是`redux`、`mobx`等通用解决方案,而是需要针对具体场景进行定制化设计,那么在这里我们来尝试基于`Immer`以及`OT-JSON`实现原子化、可协同、高扩展的应用级状态管理方案。 ## 概述 将`Immer`与`OT-JSON`结合的想法来自于`slate`,我们首先来看一下`slate`的基本数据结构,下面的例子是高亮块的描述。这个数据结构看起来非常像零代码/低代码的结构,因为其含有很多`children`,而且存在对节点的装饰描述,即`bold`、`border`、`background`等属性值。 ```js [ { "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-env/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`简化了不可变数据的操作,引入一种称为草稿状态的概念,以此允许开发者以直观的可变方式编写代码,同时底层自动生成全新的不可变对象。传统方式中,修改深层嵌套的数据需要小心翼翼地展开每一层结构,既容易出错又让代码显得复杂。 ```js const reducer = (state, action) => { return { ...state, first: { ...state.first, second: { ...state.first.second, value: action, }, }, }; }; ``` 而`Immer`通过创建一个临时的草稿对象,让开发者像操作普通对象一样直接赋值、增删属性,甚至使用数组的`push`、`pop`等方法。完成所有修改后,便基于草稿状态的变更记录,生成变更后与原始数据结构共享未修改部分的新对象。这种机制既避免了深拷贝的性能损耗,又保证了数据的不可变性。 ```js 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`表示插入值,在变更之后就可以检查变更后的值是否正确,以及未修改对象的引用复用。 ```js // 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`对象在变更之后,只有变更的部分是新的对象,其他部分是引用复用的。 ```js // 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`的复用效果仍然没有问题。 ```js // 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`的操作结果后重新计算索引。 ```js // 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`。 ```js // 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`间的关联性引起的。 ```js // 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`来移除,这里还需要考虑是否需要将应用过的操作顺序反转再变换,而且这里也能够看到删除的值没有问题,且重复的操作也能够正确处理。 ```js // 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`是绝对位置,因此在批量处理时需要进行转换。 ```js // 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`的顺序,但最终却得到了错误的结果。 ```js // 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`发生了变更。那么我们就可以假设`a`变更的影响来变换`b`操作,由此在`abcd`这种情况下,应该是以`a`为基准,变换`b/c/d`,然后以`b`为基准,变换`c/d`,以此类推。 ```js // 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`时,例如历史操作的合并会非常有用。 ```js // 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`。 ```js // 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" }); ``` 通过`ref`来维护批量执行的`ops`或许是比较合适的方案,当执行一次批量操作时,此时的操作都是基于当前的`draft`状态来处理的。而在针对每个`op`进行`apply`前,需要将本次执行的操作全部先放置于`refs`中,例如在`slate`的`remove-nodes`实现中,通过`ref`维护的影响关系。 ```js // packages/slate/src/transforms-node/remove-nodes.ts const depths = Editor.nodes(editor, { at, match, mode, voids }) const pathRefs = Array.from(depths, ([, p]) => Editor.pathRef(editor, p)) for (const pathRef of pathRefs) { const path = pathRef.unref()! if (path) { const [node] = Editor.node(editor, path) editor.apply({ type: 'remove_node', path, node }) } } ``` 与之类似的,飞书文档的操作变换实现中采用的是非`ref`持有的方式,而是类似于先前的`composeWith`实现,直接在执行`apply`前进行操作变换,处理索引的偏移问题。下面的例子中,就是批量执行两行删除的`op`时,针对单个`block`执行的`children`变更。 ```js doxcnTYXXXX: { id: "doxcnTYXXXX", version: 1147, payload: { ops: [ { p: ["children", 2], action: { ld: "doxcnHXXXXXX" } }, { p: ["children", 2], action: { ld: "doxcnP222222" } }, ], }, } ``` 此外,在这里还需要设想一个问题,类似这种需要多次同步`apply`的实现方式,是不适合同步立即执行编辑器的变更的,这样的渲染代价十分高昂。因此,等待所有的同步指令全部完成之后,再批量到视图层应用变更才是更合适的方案,也就是我们常见的同步变更数据,异步渲染视图。 ```js // https://github.com/ianstormtaylor/slate/blob/06b882/packages/slate/src/core/apply.ts#L44 if (!FLUSHING.get(editor)) { FLUSHING.set(editor, true) Promise.resolve().then(() => { FLUSHING.set(editor, false) editor.onChange({ operation: op }) editor.operations = [] }) } ``` ## 低代码场景 在这里我们以简单的列表场景为示例,基于`Immer`以及`OT-JSON`实现基本的状态管理。列表的场景会是比较通用的实现,在这里我们会实现列表的增删、选区处理、历史操作等功能,这其中很多设计是参考`slate`的状态管理实现。 ### 数据操作 `OT-JSON`进行`apply`的时候,实际上执行的方案是逐个执行`op`。那么使用`OT-JSON`来管理状态的时候,会很容易思考出一个问题,如果更改了比较内部的数据状态,`provider`提供的`value`在最顶层的对象引用并不会发生改变,可能不会引起`render`。 为什么说可能不引起`render`,如果我们在状态变更之后,直接引用的对象不发生改变,`setState`不会引起渲染行为。但是如果组件状态较多,其他的状态变更仍然会引起整个组件的状态刷新,例如下面的`Child`组件本身没有`props`发生改变,但`count`值的变化还是会导致函数组件执行。 ```js // https://reactplayground.vercel.app/ import React, { useState, Fragment } from 'react'; const Child = () => { console.log("render child"); return