Location>code7788 >text

Designing a state management solution for low-code/rich text scenarios based on OT-JSON and Immer

Popularity:524 ℃/2025-04-23 10:15:24

In complex applications, such as low-code and rich text editor scenarios, the design of data structures becomes very important, and state management in this case is notreduxmobxetc., but instead, we need to customize the design for specific scenarios. So here we try to use it based onImmeras well asOT-JSONImplement atomized, collaborative, and highly scalable application-level state management solutions.

describe

WillImmerandOT-JSONThe idea of ​​combining comes fromslateLet's take a look firstslateThe basic data structure of the following example is the description of the highlight block. This data structure looks very much like a zero code/low code structure because it contains a lot ofchildren, and there is a decorative description of the node, i.e.boldborderbackgroundetc attribute values.

[
   {
     "highlight-block": {
       border: "var(--arcoblue-6)",
       background: "var(--arcoblue-3)",
     },
     children: [
       { children: [{ text: "🌰 " }, { text: "Give a chestnut", bold: true }] },
       { children: [{ text: "Support highlight blocks can be used to prompt important content in documents." }] },
     ],
   },
 ];

Then the design here is very interesting. We have talked about it in previous articles. In essence, low code and rich text are based onDSLDescription to operateDOMStructure, but rich text is mainly operated through keyboard inputDOM, while no code is used to operate by dragging and dropping.DOMOf course, there are some common design ideas here, and this conclusion actually comes fromslatestatus management.

Related to this articleDEMOAll are there/WindRunnerMax/webpack-simple-environment/tree/master/packages/immer-ot-jsonmiddle.

Basic Principles

We also mentioned the specific scenarios of data structures for customized design, which mainly refers toJSONThe structure is very flexible, like the description of a highlight block. We can design it as a separate object or flatten it toMapDescribe the decoration of the node in the form of the text, for example, the above text content specifies that the need to be usedtextAttribute description.

The design of atomization is very important. Here we divide atomization into two parts, atomization of structure and atomization of operations. The atomization of the structure means that we can freely combine nodes, while the atomization of operations means that we can operate the node state through description. The combination of these two can easily implement component rendering, state changes, historical operations, etc.

Free combination of nodes can be applied in many scenarios, such as in a form structure, any form item can be turned into a nested structure of other form items, and the combination mode can set some rules to limit it. The atomization of operations can more conveniently handle state changes. Also in a form, nested form items expand/collapse states need to be implemented through state changes.

Of course, it may not be so ideal when performing operations atomically.opsTo perform operations express similaractionParadigm operations are also very routine, and this part is requiredcomposeHow to deal with it. And state management may not all need to be persisted. In temporary state management,client-side-xxxProperty processing is easy to implement.AXY+ZValue processing will be more complicated.

The basis of collaborative algorithm is also atomized operations, similar toreduxParadigmactionIt is very convenient to operate, but it cannot handle collaborative conflicts, and it is also not easy to handle historical operations, etc. This limitation stems from its one-way, discrete operational model, eachactionOnly express independent intentions, but lack causality for global states (operation)AInfluence operationBexplicit maintenance of state).

OT-JSONThis can help us expand atomized operations into complex scenarios of collaborative editing, and introduce operation transformations.OT, to resolve conflicts. Of course, it is not enough to just introduce operational transformations at the front end, and it also needs to introduce a collaborative framework of the backend, for example,ShareDBwait. certainly,CRDTThe collaborative algorithm is also a feasible choice, which is a problem of application selection.

also,OT-JSONNaturally, it can support the maintenance of operation history. Each operation carries enough context information, so that the system can trace the complete chain of state changes, providing a foundation for advanced functions such as undo/redo, version traceback, etc. The causal relationship between operations is also explicitly recorded, allowing the system to perform operationsAMust be in operationBSuch constraints were applied before.

The design of this part of the scalability can be relatively rich, and the tree structure is naturally suitable for carrying nested data interactions. For example, various modules of Feishu Document areBlocksThe form of expansion is extended. It just so happens that Feishu's data structure is also usedOT-JSONTo achieve, text coordination is achieved byEasySyncAsOT-JSONsubtypes are implemented to provide higher scalability.

Of course, scalability does not mean that you can access the plug-in completely freely. The data structures in the plug-in still need to be accepted as a wholeOT-JSON, and the special subtype of text, also need to be scheduled separately. This system framework can integrate various heterogeneous content modules into the collaborative system, and can realize unified state management, collaborative editing, history and other functions.

Immer

ImmerSimplified the operation of immutable data and introduced a concept called draft state, which allows developers to write code in an intuitive and mutable way, while automatically generating brand new immutable objects at the bottom. In traditional ways, modifying deeply nested data requires careful expansion of each layer of structure, which is both prone to errors and makes the code appear complex.

const reducer = (state, action) => {
  return {
    ...state,
    first: {
      ...,
      second: {
        ...,
        value: action,
      },
    },
  };
};

andImmerBy creating a temporary draft object, developers can directly assign values, add and delete attributes, and even use arrays like operating ordinary objects.pushpopetc. After all modifications are completed, a new object that shares the unmodified part with the original data structure is generated based on the change record of the draft status. This mechanism not only avoids the performance loss of deep copy, but also ensures data immutability.

const reducer = (state, action) => {
   = action;
};

existImmerIt is very important to useProxyDuring this process of agent modification, it will only be created when data is accessed.ProxyObject, that is, this is a lazy proxy mechanism for on-demand proxy, so that there is no need to traverse to create all proxy creation when creating drafts. This mechanism greatly reduces unnecessary performance overhead, especially when dealing with large and complex objects.

For example, a deep nested property has been modified = 1ImmerAgents will be generated layer by layer along the access path.Proxy(a)Proxy()Proxy(). Therefore useImmerWhen modifying the object, you should also pay attention to keep only the parts that need to be modified as much as possible. Other proxy operations should be drafted to avoid unnecessary proxy generation.

OT-JSON

existslateImplemented9Atomic operations to describe changes, which include text processinginsert_text, node processinginsert_node, selection changeset_selectionoperations, etc. But inslateAlthough operation transformation and operation inversion are implemented, independent packages are not withdrawn separately, so many designs are implemented internally and are not universal.

  • insert_node: Insert node.
  • insert_text: Insert text.
  • merge_node: Merge nodes.
  • move_node: Mobile node.
  • remove_node: Remove node.
  • remove_text: Remove text.
  • set_node: Set the node.
  • set_selection: Set selection.
  • split_node: Split nodes.

Similarly, inOT-JSONImplemented11andjson0The structural design has been verified in a wide range of production environments, and the core goal is to ensure data consistency between different clients through structured data expression. In addition, in rich text scenesSubTypeStill need to be extended, such as Feishu'sEasySyncType extension, then naturally more operations are needed to describe the changes.

  • {p:[path], na:x}: In the specified path[path]Add valuexValue.
  • {p:[path,idx], li:obj}: On the list[path]Index ofidxInsert object beforeobj
  • {p:[path,idx], ld:obj}: From the list[path]Index ofidxDelete the object inobj
  • {p:[path,idx], ld:before, li:after}: Use objectsafterReplacement list[path]IndicesidxObject ofbefore
  • {p:[path,idx1], lm:idx2}: Add the list[path]Indicesidx1The object to the indexidx2place.
  • {p:[path,key], oi:obj}: toward the path[path]Add keys to the object inkeyand objectsobj
  • {p:[path,key], od:obj}: From the path[path]Delete key in object inkeyand valueobj
  • {p:[path,key], od:before, oi:after}: Use objectsafterReplace path[path]Middle keykeyObject ofbefore
  • {p:[path], t:subtype, o:subtypeOp}: For path[path]The object application type intSuboperation ofo, subtype operation.
  • {p:[path,offset], si:s}: On the path[path]Offset of stringoffsetInsert strings ats, use subtypes internally.
  • {p:[path,offset], sd:s}: From the path[path]Offset of stringoffsetDelete stringss, use subtypes internally.

In addition to atomized operations, the most core is the implementation of operation transformation algorithms, which is the basis of collaboration.JSONThe atomic operations are not completely independent. The operation transformation must be used to ensure that the execution order of operations can follow its causal dependence. At the same time, it is also very important for the implementation of operation inversion, which means that we can implement functions such as undoing and redoing.

Data structure

In low-code, rich text, artboard/whiteboard, form engine and other editor application scenarios, just useJSONIt is not enough to describe the content in a data structure. Analog in the component,divIt is a description view, the state needs to be defined additionally, and the state is changed through event-driven. And in the editor scene,JSONIt is both a view description and a state to be operated.

Then based onJSONIt is not complicated to render views, especially when it comes to scenes in table renderings. It is not that simple to change the data structure through operations, so based onOT-JSONWe can implement atomized data changes, andImmerCombined, it can be combined with the rendering and refresh of the view. Here we first test the operation transformation of the data structure in a unit test manner.

Basic Operation

The basic operations for data are nothing more than adding, deleting, modifying and checking. This part is mainly based onpathJust read the data, and what we are focusing on is adding, deleting and modifying theImmerThe combination of . First of allinsertoperate,pIndicates the path,liIt indicates the inserted value. After the change, you can check whether the changed value is correct and the reference multiplexing of the unmodified object.

// packages/immer-ot-json/test/
const baseState = {
  a: {
    b: [1] as number[],
  },
  d: { e: 2 },
};
const draft = createDraft(baseState);
const op: Op = {
  p: ["a", "b", 0],
  li: 0,
};
(draft, [op]);
const nextState = finishDraft(draft);
expect([0]).toBe(0);
expect([1]).toBe(1);
expect().();
expect().();
expect().toBe();
expect().toBe();

The deletion operation is also a similar implementation.ldIndicates deletion value, note that the specific value of the delete is not the index, which is mainly forinvertConvenient conversion. You can also see thatImmerofdraftAfter the object is changed, only the changed part is a new object, and the other parts are reference multiplexed.

// packages/immer-ot-json/test/
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,
};
(draft, [op]);
const nextState = finishDraft(draft);
expect([0]).toBe(0);
expect([1]).toBe(2);
expect().();
expect().();
expect().toBe();
expect().toBe();

Update operation inOT-JSONIn fact, it needs to be defined at the same timeoiandod, equivalent to a combination of two atomic operations, the specific implementation is to insert first and then delete. Similarly, place both values ​​out instead of just processing indexes,invertNo need tosnapshotto help get the original value, andImmerThe reuse effect is still fine.

// packages/immer-ot-json/test/
 const baseState = {
   a: {
     b: { c: 1 },
   },
   d: { e: 2 },
 };
 const draft = createDraft(baseState);
 const op: Op = {
   p: ["a", "b", "c"],
   // It was not verified during application, but in order to ensure the correctness of invert, the original value needs to be determined here
   // /ottypes/json0/blob/master/lib/#L237
   od: 1,
   oi: 3,
 };
 (draft, [op]);
 const nextState = finishDraft(draft);
 expect().toBe(3);
 expect().();
 expect().();
 expect().toBe();
 expect().toBe();

Operational transformation

The application scenarios of operation transformation are mainly in collaborative editing, but there are also a large number of applications in non-collaborative situations. For example, when uploading images, we should not place the uploaded state inundoin the stack, whether it is to use it as an irrevocable operation or merge the previous oneundoAll operations already in the stack require the implementation of operation transformation.

We can understandb'=transform(a, b)It means, assumptionaandbAll from the samedraftBranched out, thenb'It's assumptionaIt has been applied, at this timebNeed to be inaTransformed fromb'Only directly apply, we can also understand it astransformSolvedaOperation is correctbThe impact caused by operations is to maintain causal relationships.

Here we still test the most basicinsertdeleteretainIn fact, we can see that the position offset in the causal relationship is more important, such as remotebOperation and upcoming applicationaAll operations are deleted whenbWhen the operation is executedaThe content to be deleted needs to bebRecalculate the index after the operation result.

// packages/immer-ot-json/test/
// insert
const base: Op[] = [{ p: [1] }];
const op: Op[] = [{ p: [0], li: 1 }];
const tf = (base, op, "left");
expect(tf).toEqual([{ p: [2] }]);

// delete
const base: Op[] = [{ p: [1] }];
const op: Op[] = [{ p: [0], ld: 1 }];
const tf = (base, op, "left");
expect(tf).toEqual([{ p: [0] }]);

// retain
const base: Op[] = [{ p: [1] }];
const op: Op[] = [{ p: [1, "key"], oi: "value" }];
const tf = (base, op, "left");
expect(tf).toEqual([{ p: [1] }]);

Reversal operation

The inversion operation isinvertThe method is mainly to achieveundoredoand other functions. We also mentioned earlier,applyMany operations require the original value to be obtained. These values ​​are not actually verified when executed, but this can be directlyinvertDirect conversion is not requiredsnapshotto assist in calculating the value.

also,invertSupports batch operation inversion. In the following example, it can also be seen that the received parameters areOp[]. Here you can think carefully that the data operation is positive when applying, and the execution order during inversion needs to be reversed, for exampleabcThe three operations ininvertThe corresponding one should becbaThe reversal ofop

// packages/immer-ot-json/test/
// insert
const op: Op[] = [{ p: [0], li: 1 }];
const inverted = (op);
expect(inverted).toEqual([{ p: [0], ld: 1 }]);

// delete
const op: Op[] = [{ p: [0], ld: 1 }];
const inverted = (op);
expect(inverted).toEqual([{ p: [0], li: 1 }]);

// retain
const op: Op[] = [{ p: [1, "key"], oi: "value2", od: "value1" }];
const inverted = (op);
expect(inverted).toEqual([{ p: [1, "key"], od: "value2", oi: "value1" }]);

Bulk application

Batch application operation is a very troublesome issue.OT-JSONSupport multipleopIt is applied simultaneously, howeverapplyWhen the data is performed by a single operation. This scenario is still very common, for example, when implementing the artboard, press and holdshiftAnd click the graph node to select multiple choices and then perform the delete operation, then this is a simultaneous basisdraftIn theory, there will be causal relationships in batch operations.

In the following example, we assume that there is now4indivualop, and there are duplicate index value processing. Then in the following example, the result we theoretically expect should be1/2/3The value of[0, 4, 5, 6], but the final result is[0, 2, 4], this isapplyIt is independently executed and has no processingopinduced by correlation between the two.

// packages/immer-ot-json/test/
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 = (baseState, ops);
expect().toEqual([0, 2, 4]);

So since it has been mentioned before,transformSolvedaOperation is correctbThe impact caused by operations is to maintain causal relationships. Then in this case, it can be passedtransformTo deal with the correlation problem between operations, then we can directly try to calltransformTo deal with this issue.

HowevertransformThe function signature istransform(op1, op2, side), this means we need to transform between two sets of operations, but now weopsIt is a single group operation, so we need to consider how this part should be combined. If transformed with empty groupopsIf the group is[]It's incorrect, so we need to try itopTo deal with it.

So at first I was going to consider using what would have been appliedopsCrop the operation and pass the value it directly affectstransformTo remove it, we also need to consider whether the applied operation sequence needs to be reversed and then transformed. Moreover, we can see that the deleted value is not a problem, and repeated operations can be handled correctly.

// packages/immer-ot-json/test/
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 = ((op, index) => {
  const appliedOps = (0, index);
  ();
  const nextOps = ([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 = (baseState, (Boolean));
expect().toEqual([0, 4, 5, 6]);

Here we can consider simply encapsulating it, and then calling the function directly to get the final result, so that the logic does not need to be mixed in the entire application process. Here you can compareDeltaofOTImplemented, singleDeltaofopsis the data processed at a relative position, andOT-JSONis the absolute position, so conversion is required during batch processing.

// packages/immer-ot-json/test/
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] = ([], transformedOp, op, dir);
    if (!result) return result;
    transformedOp = result;
  }
  return transformedOp;
};
((op, index) => {
  const appliedOps = (0, index);
  const a1 = transformLocal(op, appliedOps, "left");
  ();
  const b1 = ([op], appliedOps, "left");
  expect(a1).toEqual(b1[0]);
});

However, it seems that the above examples are fine, but considering the actual application scenario, we can test the execution order problem. In the following example, we only adjustedopsbut ended up with the wrong result.

// packages/immer-ot-json/test/
 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 = ((op, index) => {
   const appliedOps = (0, index);
   ();
   const nextOps = ([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 });
 // There is a problem here. I hope the result is undefined
 expect(tfOps[3]).toEqual({ p: ["a", "b", 1], ld: 3 });

Think about how we should figure out this causal relationship issue, and whether we can consider that this matter itself should beaAfter application,bChanges have occurred. Then inabcdIn this case, it should beaAs the benchmark, transformb/c/d, and thenbAs the benchmark, transformc/d, and so on.

// packages/immer-ot-json/test/
 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 ​​= ;
 for (let i = 0; i < len; i++) {
   // Here is copied instead of ops, it is the operation after application
   // Otherwise, it will cause errors in the actual rotation operation transformation
   // For example, under [1,2,3], [1,1,undefined] will occur
   const base = copied[i];
   for (let k = i + 1; k < len; k++) {
     const op = copied[k];
     if (!op) continue;
     const nextOp = ([], 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);

The essence of this problem is actually multipleopWhen combined, each operation is an independent absolute position, and it will not be implemented as a relative position, for example, inDeltamiddle,composeOperations are calculated as relative positions. Then we can naturally encapsulate it ascomposeWithMethod, this method is mergedops, such as merges of historical operations can be very useful.

// packages/immer-ot-json/test/
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 = ([], nextOp, opb, "left")[0];
      if (!nextOp) break;
    }
    nextOp && (nextOp);
  }
  return ((Boolean));
};
const copied = ((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);

Finally, we can also consider a path holding scenario, similar to what we implement a rich text editorRefModule. For example, when uploading pictures,loadingDuring the status, user operations may change the original path. In this case, when the actual address is written to the node after the upload is completed, the latest one needs to be obtained.path

// packages/immer-ot-json/test/
 const baseState = {
   a: {
     b: [0, 1, 2, 3, 4, 5, 6] as number[],
   },
 };
 // The purpose of the operation after holding and transforming is to transform path
 // For example, if it is ld, you should first transform [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 = ; i < n; ++i) {
     const tfOp = ops[i];
     if (!tfOp) continue;
     // After transforming out the directly applicable ops, the ref module can hold the orderly transformation
     for (let k = 0, n = ; k < n; ++k) {
       const refOp = refOps[k];
       if (!refOp) continue;
       const [result] = ([], refOp, tfOp, "left");
       refOps[k] = result;
     }
   }
   return (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().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" });

Low code scenario

Here we take a simple list scenario as an example, based onImmeras well asOT-JSONImplement basic state management. The list scenario will be a more general implementation. Here we will implement the functions of list addition and deletion, selection processing, historical operations, etc., many of which are referenced.slatestate management implementation.

Data operation

OT-JSONconductapplyWhen the actual implementation plan is to execute one by oneop. Then useOT-JSONWhen managing state, it is easy to think about a problem. If the internal data state is changed,providerProvidedvalueThe object reference at the top level does not change, and may not causerender

Why may not causerender, if the object we reference directly does not change after the state changes,setStateNo rendering behavior is caused. However, if the component states are large, other state changes will still cause the entire component to refresh the state, such as the followingChildThe component itself does not havepropsChanges, butcountChanges in value will still cause the function component to execute.

// /
import React, { useState, Fragment } from 'react';

const Child = () => {
  ("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;

Of course, without considering other state changes, the top-level object reference remains unchanged, so naturally the entire view will not be refreshed. Therefore, we must start from the changed node, so the upward nodes need to change the reference value. In the following example, ifCIf something changes,ACThe reference needs to be changed, and other objects retain the original value.ImmerIt just happens to help us realize this ability.

   A
  / \
 B   C
    / \
   D   E

Of course, it can also be found in previous examples, even ifpropsThe value of the same remains unchanged, and after the top-level value changes, the entire function component will still be re-execute. In this case, cooperation is requiredUse this to control whether the function component needs to be re-executeed.ChildComponent packagingmemo, you can avoidcountRe-execute the component when the value changes.

const Child = (() => {
  ("render child");
  return <div>Child</div>;
})

Path search

Generally speaking, when performing changes, we need to get the target we want to deal withpath, especially when the component needs to operate itself after rendering. In ordinary changes, we may rely more on the expression of the selected node to get the target node to be processed. However, when we want to implement more complex modules or interactions, such as asynchronous upload of images, this may not be enough for us to complete these functions.

When we useAfter controlling component rendering, an issue will be implicitly introduced. For example, at this time we have secondary list nesting and content nodes[1,2], if in[1]Insert a new node at this position, then theoretically the original value should become[2,2], however, since the function component is not executed, it remains original[1,2]

[
  [0,   0,   0]
  [1,   1,   1(*)]   
]
// insert [1] [0,0,0] =>
[
  [0,   0,   0]
  [0,   0,   0]
  [1,   1,   1(*)]   
]

Keep it original here[1,2]Specifically, if wepathPassed toprops, and customizememoofequalFunction and passpath, then the changes in the low index value will cause the components of a large number of nodes to be re-execute and the performance will be degraded again. If not passed topropsIf it is natural that the node rendering cannot be obtained inside the componentpath

In the process of implementing plug-in, the same plug-in implements the rendering of multiple components. These components are of the same type, but are rendered in different ways.pathBelow. Therefore, through the plug-in, the component rendered by the plug-in is obtainedpathIt still needs to be passed through the outer rendering state, as abovepropsThe delivery plan is naturally not suitable, so here we passWeakMapTo achievepathGet it.

Here we pass twoWeakMapIt can be achievedfindPathFunctions,NODE_TO_INDEXUsed to store the mapping relationship between nodes and indexes,NODE_TO_PARENTUsed to store the mapping relationship between the node and the parent node. Through these twoWeakMapIt can be achievedpathThe mapping relationship of lower index can be updated every time the node is updated.

// packages/immer-ot-json/src/components/
const children = useMemo(() => {
  const children: [] = [];
  const path = findPath(currentNode);
  for (let i = 0; i < ; ++i) {
    const p = (i);
    const n = nodes[i];
    NODE_TO_INDEX.set(n, i);
    NODE_TO_PARENT.set(n, currentNode);
    (<NodeModel node={n}></NodeModel>);
  }
  return children;
}, [currentNode, nodes, selection]);

Then in actual searchpathWhen you are in the target node, you can passNODE_TO_PARENTStart searching for the parent node until the root node is found. In this search process, you can passNODE_TO_INDEXCome and get itpath, that is, we only need to search through hierarchical traversalpath, without traversing the entire state tree.

// packages/immer-ot-json/src/utils/
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;
    }
    (i);
    child = parent as Node;
  }
  throw new Error("Unable To Find Path");
};

Then we can actually think of a question, we update itpathWhen values ​​are executed during rendering, that is, we want to get the latest onepath, it must be after rendering is completed. Therefore, the timing of our entire scheduling process must be controlled well, otherwise it will lead to the failure to obtain the latestpath, so we usually need touseEffectto distribute rendering completion events.

It is also important to note here that the actual editor engine needs to depend on ituseEffectThe life cycle of the parent component must be triggered after all child components are rendered.effectside effect. Therefore, in the outer layer of the nodeContextLevel rendering node cannot beThe implementation of the plug-in rendering content can be lazy to load.

/**
  * View update requires triggering view drawing completion event. No dependency array
  * state -> parent -> node -> child ->|
  * effect <- parent <- node <- child <-|
  */
 useEffect(() => {
   ("OnPaint");
   (EDITOR_STATE.PAINTING, false);
   ().then(() => {
     (EDITOR_EVENT.PAINT, {});
   });
 });

Selection status

Selection statusselectionThe module also relies onReactMaintaining the status ofProviderCome to use. The maintenance of the constituent expression itself depends onpath, so you can use the above directly when clicking on the nodefindPathJust write to the selection status.

// packages/immer-ot-json/src/components/
const onMouseDown = () => {
  const path = findPath(node);
  (path);
};

Similar to the path search mentioned above, we will not use the node itself.pathAspropsPassing to the node, therefore the node needs to know whether it is in the selected state, and also needs design. The design here needs to consider two parts. First, the global selection state, which is used directly here.Contextsupplyvalue, followed by the state of the node itself, each node needs to be independentContext

The global selection state management itself is also divided into two parts, the globalhooksis used to provide the selection value of all subcomponents, which is directly in the subcomponents.useContextThat's it, the application portal also needs to use the editor's own events to manageContextselection status value.

// packages/immer-ot-json/src/hooks/
export const SelectionContext = <Range | null>(null);
export const useSelection = () => {
  return useContext(SelectionContext);
};
// packages/immer-ot-json/src/components/
const onSelectionChange = useMemoFn((e: SelectionChangeEvent) => {
  const { current } = e;
  setSelection(current);
});
useEffect(() => {
  (EVENTS.SELECTION_CHANGE, onSelectionChange);
  return () => {
    (EVENTS.SELECTION_CHANGE, onSelectionChange);
  };
}, [editor, onSelectionChange]);

The design of the selected state of a single component is more interesting. First of all, considering that there are only two types of selected states, namely, the selected/unselected state, so one should be placed on the outer layer of each node.Providerto manage the status. Then if it is a deeply nested component selected state, we need to change the deepest level.ProviderThe value can change the selected state.

Then here you need to rely on the top levelselectionChanges to trigger the top levelProviderChange, and then each level of state changes requires the function component to be re-executed to handle the selected state changes as needed andrender. That is, when the deep node is selected, it follows allpathNodes with low indexes are selected.

It's still necessary to cooperate hereTo use it, becauseselectedWill bepropsPass to the child component, so inselectedWhen the value changes, the child component will be executed again. Therefore, the transformation here starts from the top layer, and each selected state will be executed once from selected to non-selected, or from non-selected to selected state.rerender

// packages/immer-ot-json/src/hooks/
export const SelectedContext = createContext<boolean>(false);
export const useSelected = () => {
  return useContext(SelectedContext);
};
// packages/immer-ot-json/src/components/
const children = useMemo(() => {
  const children: [] = [];
  const path = findPath(editor);
  for (let i = 0; i < ; ++i) {
    const p = (i);
    const n = nodes[i];
    const isSelected = selection && isEqual(selection, p);
    (
      < key={} value={!!isSelected}>
        <NodeModel selected={!!isSelected} node={n}></NodeModel>
      </>
    );
  }
  return children;
}, [editor, nodes, selection]);
// packages/immer-ot-json/src/components/
const isSelected = useSelected();

History

HistoryThe module is withOT-JSONFor modules that combine relatively tightly with data operations, they will be used in depthtransformPerform operation transformations, including selection and data transformations. alsoinvertMethods are also essential, reverse operation isundoredoThe basis of

First of all, you need to pay attention to when to deal with itundoObviously we only need toapplyThe stack data needs to be processed only during operation, andapplyWhen you are also careful, you need to pay attention to only user-triggered content that needs to be processed. When the operation source isHistoryWhen the module itself, even data from the source and remote collaboration, it is naturally not necessary to push new data into the stack.

Don't forget the selection record. When the undo is triggered, our selection should also return to the previous state. Therefore, there are actually two things we actually processed, inwill applyThe time to record the value of the current selection, in actualapplyThen the latest changeschangesPush into the stack.

// packages/immer-ot-json/src/editor/
const { changes, source } = event;
if (! || source === "history") {
  return void 0;
}
 = [];
let inverted = (changes);
let undoRange = ;
({ ops: inverted, range: undoRange });

Generally speaking, we do not want to put the stack every time we perform a change, especially some high-frequency operations, such as inputting text and dragging nodes. Therefore, we can consider combining operations within the time slice and regularize them into the sameundo ops, then here we need to consider how to put the top of the stackopsWith the currentchangesMerge, this actually used our previouscomposeWithmethod.

// packages/immer-ot-json/src/editor/
 if (
   // If the trigger time is within the delay time slice, the previous record needs to be merged.
    + > timestamp &&
    > 0
 ) {
   const item = ();
   if (item) {
     for (const base of ) {
       for (let k = 0; k < ; k++) {
         const op = inverted[k];
         if (!op) continue;
         const nextOp = ([], op, base, "left");
         inverted[k] = nextOp[0];
       }
     }
     inverted = (, inverted);
     undoRange = ;
   }
 } else {
    = timestamp;
 }

undoandredoThe two methods of the two need to be used in conjunction with each other. When the user-state operation is not performed, thehistoryThe modules themselves apply to each otherchangesIt is necessary to perform a transformation and then enter another stack. Right nowundoExecutedchangesNeed to beinvertEnter laterredoStack and vice versa.

// packages/immer-ot-json/src/editor/
public undo() {
  if (!) return void 0;
  const item = ();
  if (!item) return void 0;
  const inverted = ();
  ({ ops: inverted, range: (, inverted) });
   = 0;
  (, "history");
  (item);
}

public redo() {
  if (!) return void 0;
  const item = ();
  if (!item) return void 0;
  const inverted = ();
  ({ ops: inverted, range: (, inverted) });
   = 0;
  (, "history");
  (item);
}

The transformation of the selection will also depend on thetransform, here only needs to depend on itpathChange the parameters. The reason for the selection transformation is that it was previously storedrangeIt is based on unchanged values, and when it is released at this time, it means that these changes have been performed, so a transformation is needed to obtain the latest selection. In addition, the restoration selection should actually try to restore to the nearby selection area where the change is as much as possible.

// packages/immer-ot-json/src/editor/
protected transformRange(range: Range | null, changes: Op[]) {
  if (!range) return range;
  const nextSelOp = ([{ p: range }], changes, "left");
  return nextSelOp ? (nextSelOp[0].p as Range) : null;
}

protected restoreSelection(stackItem: StackItem) {
  if () {
    ();
  }
}

In factHistoryThis part uses much more than these operational transformations. In the collaborative scenario, we need to consider how to deal with it.remoteAfter all, the principle is that we can only cancel our own operations. There are also scenarios such as uploading pictures that require merging of certain casesundoFor stack operations, operation transformation is also needed here to deal with it.opsWe will put this part based on the side effects caused by movement.DeltaImplementation of .

/**
  * MergeId records into baseId records
  * - Only merge retain operation is supported for the time being, and baseId < mergeId must be guaranteed
  * - There are no scenes for other operations yet, you can check the History Merge section of NOTE
  * @param baseId
  * @param mergeId
  */
 public mergeRecord(baseId: string, mergeId: string): boolean {
   const baseIndex = (item => (baseId));
   const mergeIndex = (item => (mergeId));
   if (baseIndex === -1 || mergeIndex === -1 || baseIndex >= mergeIndex) {
     return false;
   }
   const baseItem = [baseIndex];
   const mergeItem = [mergeIndex];
   let mergeDelta = ;
   for (let i = mergeIndex - 1; i > baseIndex; i--) {
     const item = [i];
     mergeDelta = (mergeDelta);
   }
   [baseIndex] = {
     id: new Set([..., ...]),
     // Here is (base) instead of the other way around
     // Because the execution order after undo is merge -> base
     delta: (),
     range: ,
   };
   (mergeIndex, 1);
   return true;
 }

 /**
  * Transform remote stack
  * @param stack
  * @param delta
  */
 protected transformStack(stack: StackItem[], delta: Delta) {
   let remoteDelta = delta;
   for (let i = - 1; i >= 0; i--) {
     const prevItem = stack[i];
     stack[i] = {
       id: ,
       delta: (, true),
       range: && (, remoteDelta),
     };
     remoteDelta = (remoteDelta);
     if (!stack[i].) {
       (i, 1);
     }
   }
 }

Summarize

Here we are based onImmerandOT-JSONA set of application status management solutions was designed, throughImmerThe draft mechanism simplifies immutable data operations, combined withOT-JSONThe atomized operation and collaboration algorithm realizes atomized, collaborative, and highly scalable application-level state management solutions, as well as view performance optimization solutions for on-demand rendering. Overall, this solution is more suitable for dynamic combination and state management of nested data structures.

In practical applications, we still need to choose the appropriate state management plan based on the scenario. In application-level scenarios, such as rich text, artboards, and low code, the top-level architecture design is still very important. All state changes and node types should be extended by this layer of architecture design. At the business level, we pay more attention to the functional implementation of business logic. This part actually seems relatively freer. Most implementations are process-oriented logic, and more focus on the organizational form of code.

One question every day

  • /WindRunnerMax/EveryDay

refer to

  • /ottypes/docs
  • /immerjs/immer
  • /ottypes/json0
  • /p/602961293
  • /ianstormtaylor/slate/
  • /questions/34385243/why-is-immutability-so-important-or-needed-in-javascript