基于OT-JSON与Immer设计低代码/富文本场景的状态管理方案

在复杂应用中,例如低代码、富文本编辑器的场景下,数据结构的设计就显得非常重要,这种情况下的状态管理并非是reduxmobx等通用解决方案,而是需要针对具体场景进行定制化设计,那么在这里我们来尝试基于Immer以及OT-JSON实现原子化、可协同、高扩展的应用级状态管理方案。

描述

ImmerOT-JSON结合的想法来自于slate,我们首先来看一下slate的基本数据结构,下面的例子是高亮块的描述。这个数据结构看起来非常像零代码/低代码的结构,因为其含有很多children,而且存在对节点的装饰描述,即boldborderbackground等属性值。

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-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简化了不可变数据的操作,引入一种称为草稿状态的概念,以此允许开发者以直观的可变方式编写代码,同时底层自动生成全新的不可变对象。传统方式中,修改深层嵌套的数据需要小心翼翼地展开每一层结构,既容易出错又让代码显得复杂。

js 复制代码
const reducer = (state, action) => {
  return {
    ...state,
    first: {
      ...state.first,
      second: {
        ...state.first.second,
        value: action,
      },
    },
  };
};

Immer通过创建一个临时的草稿对象,让开发者像操作普通对象一样直接赋值、增删属性,甚至使用数组的pushpop等方法。完成所有修改后,便基于草稿状态的变更记录,生成变更后与原始数据结构共享未修改部分的新对象。这种机制既避免了深拷贝的性能损耗,又保证了数据的不可变性。

js 复制代码
const reducer = (state, action) => {
  state.first.second.value = action;
};

Immer中非常重要的一点是,在使用Proxy代理修改这个过程中,仅在访问到数据的时候才会创建Proxy对象,也就是说这是一种按需代理的懒代理机制,这样就不需要创建草稿时遍历创建所有代理。这种机制极大地减少了不必要的性能开销,尤其当处理大型复杂对象时。

例如修改了一个深层嵌套属性draft.a.b.c = 1Immer会沿着访问路径逐层生成代理,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转换的方便。同样可以看到,Immerdraft对象在变更之后,只有变更的部分是新的对象,其他部分是引用复用的。

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中实际上需要同时定义oiod,相当于两个原子操作的组合,具体的实现是先插入后删除。同样的,将两者的值都放置出来而不是仅处理索引,在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)的意思是,假设ab都是从相同的draft分支出来的,那么b'就是假设a已经应用了,此时b需要在a的基础上变换出b'才能直接应用,我们也可以理解为transform解决了a操作对b操作造成的影响,即维护因果关系。

在这里我们仍然测试最基本的insertdeleteretain的操作变换,其实我们可以看到,因果关系中位置的偏移是比较重要的,例如远程的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方法,主要是为了实现undoredo等功能。前边我们也提到了,进行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的批量操作,理论上会存在因果关系。

在下面这个例子中,我们假设现在有4op,并且存在重复的索引值处理。那么在下面的例子中,我们理论上期待的结果应该是将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]);

在这里我们可以考虑将其简单封装一下,然后直接调用函数就可以得到最终的结果,这样就不需要将逻辑全部混杂在整个应用的过程中。这里可以对比一下DeltaOT实现,单次Deltaops是以相对位置处理的数据,而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发生了变更。那么在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" });

低代码场景

在这里我们以简单的列表场景为示例,基于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 <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发生改变,则AC的引用需要变更,其他对象保持原始值,Immer恰好能够帮我们实现这个能力。

css 复制代码
   A
  / \
 B   C
    / \
   D   E

当然,在先前的例子中也可以发现,即使props的值不变,在最顶层的值变更之后还是会导致整个函数组件重新执行。在这种情况下是需要配合React.memo使用,以此来控制函数组件是否需要重新执行,将上面例子中的Child组件包装memo,就可以避免在count值变化时重新执行组件。

js 复制代码
const Child = React.memo(() => {
  console.log("render child");
  return <div>Child</div>;
})

路径查找

通常来说,在执行变更时我们需要得到要处理的目标path,特别是渲染后组件要操作本身时。在普通的变更中,我们可能更多的是依赖选区节点的表达,来得到要处理的目标节点。但是当我们想实现比较复杂的模块或者交互时,例如图片的异步上传等场景时,这可能并不足以让我们完成这些功能。

当我们使用React.memo来控制组件渲染后,其实会隐式地引入一个问题。例如此时我们有二级列表嵌套,以及内容节点[1,2],如果在[1]这个位置上插入新的节点,那么理论上原始的值应该变为[2,2],然而由于函数组件并未执行,其依然会保持原始的[1,2]

js 复制代码
[
  [0,   0,   0]
  [1,   1,   1(*)]   
]
// insert [1] [0,0,0] =>
[
  [0,   0,   0]
  [0,   0,   0]
  [1,   1,   1(*)]   
]

这里保持原始的[1,2]具体指的是,如果我们将path在渲染时传递给props,并且自定义memoequal函数并且传递path,那么低索引值的变更会导致大量节点的组件重新执行,性能会重新劣化。而如果不传递给props的话,在组件内部自然无法拿到节点渲染的path

在我们实现插件化的过程中,都是同一个插件来实现多个组件的渲染,这些组件都是同一种类型,却是渲染在不同path下的。因此通过插件来获取由该插件渲染出组件的path还是需要通过外层渲染状态来传递,上述的props传递方案自然不合适,因此这里我们通过WeakMap来实现path获取。

在这里我们通过两个WeakMap就可以实现findPath的功能,NODE_TO_INDEX用于存储节点与索引的映射关系,NODE_TO_PARENT用于存储节点与父节点的映射关系。通过这两个WeakMap就可以实现path的查找,每次更新节点时,较低索引的映射关系都可以更新。

js 复制代码
// 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,而不需要遍历整个状态树。

js 复制代码
// 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的实现,当然实际插件渲染的内容是可以懒加载的。

js 复制代码
/**
 * 视图更新需要触发视图绘制完成事件 无依赖数组
 * 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来写入选区状态即可。

js 复制代码
// 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的选区状态值。

js 复制代码
// 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

js 复制代码
// 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方法也是必不可少的,逆转操作是undoredo的基础。

首先需要关注在何时处理undo,明显我们仅需要在apply操作时才需要处理栈数据,而在apply的时候还需要注意仅有用户触发的内容才需要处理。当操作源是History模块本身,甚至是来源与远程协同的数据时,自然是不应该将新的数据推入栈中的。

不要忘记了选区的记录,当触发了撤销之后,我们的选区也应该要回归到前一个状态,因此我们实际处理的实际有两个,在will apply的时机记录当前选区的值,在实际apply的时候再将最新的变更changes推入栈中。

js 复制代码
// 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方法。

js 复制代码
// 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;
}

undoredo的两个方法通常是需要配合使用的,在不执行用户态的操作时,通过history模块本身相互应用的changes是需要进行变换然后入另一个栈。即undo执行的changes需要再invert之后入redo栈,反之亦然。

js 复制代码
// 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是基于未变更的值的,而此时出栈了就意味着已经执行了这些变更,因此需要变换来获取最新的选区。此外,恢复选区这里其实应该尽可能尝试恢复到变更附近的选区。

js 复制代码
// 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的实现。

js 复制代码
/**
 * 将 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);
    }
  }
}

总结

在这里我们基于ImmerOT-JSON设计了一套应用状态管理方案,通过Immer的草稿机制简化不可变数据操作,结合OT-JSON的原子化操作与协同算法,实现原子化、可协同、高扩展的应用级状态管理方案,以及按需渲染的视图性能优化方案。整体来说,这个方案比较适用于嵌套数据结构的动态组合与状态管理。

在实际应用中,我们还是需要根据场景来选择合适的状态管理方案。在应用级别的场景中,例如富文本、画板、低代码中,顶层的架构设计还是非常重要的,所有的状态变更、节点类型都应该由这层架构设计扩展出来。而在我们的业务层面上,则更注重的是业务逻辑的功能实现,这部分其实就显得相对更自由一些,绝大部分实现都是面向过程的逻辑,更关注的则是代码的组织形式了。

每日一题

参考

相关推荐
喜欢吃豆12 小时前
LangChain 架构深度解析:从中间件机制到人机协同 SQL 智能体实战报告
人工智能·中间件·架构·langchain·大模型
编程大师哥12 小时前
Java web
java·开发语言·前端
Mintopia12 小时前
如何结合 AI,为未来社交群体构建「信任桥梁」
人工智能·react native·架构
Murrays12 小时前
【React】01 初识 React
前端·javascript·react.js
大喜xi12 小时前
ReactNative 使用百分比宽度时,aspectRatio 在某些情况下无法正确推断出高度,导致图片高度为 0,从而无法显示
前端
helloCat12 小时前
你的前端代码应该怎么写
前端·javascript·架构
电商API_1800790524712 小时前
大麦网API实战指南:关键字搜索与详情数据获取全解析
java·大数据·前端·人工智能·spring·网络爬虫
康一夏12 小时前
CSS盒模型(Box Model) 原理
前端·css
web前端12312 小时前
React Hooks 介绍与实践要点
前端·react.js
我是小疯子6612 小时前
JavaScriptWebAPI核心操作全解析
前端