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 notredux
、mobx
etc., but instead, we need to customize the design for specific scenarios. So here we try to use it based onImmer
as well asOT-JSON
Implement atomized, collaborative, and highly scalable application-level state management solutions.
describe
WillImmer
andOT-JSON
The idea of combining comes fromslate
Let's take a look firstslate
The 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.bold
、border
、background
etc 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 onDSL
Description to operateDOM
Structure, but rich text is mainly operated through keyboard inputDOM
, while no code is used to operate by dragging and dropping.DOM
Of course, there are some common design ideas here, and this conclusion actually comes fromslate
status management.
Related to this articleDEMO
All are there/WindRunnerMax/webpack-simple-environment/tree/master/packages/immer-ot-json
middle.
Basic Principles
We also mentioned the specific scenarios of data structures for customized design, which mainly refers toJSON
The structure is very flexible, like the description of a highlight block. We can design it as a separate object or flatten it toMap
Describe the decoration of the node in the form of the text, for example, the above text content specifies that the need to be usedtext
Attribute 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.ops
To perform operations express similaraction
Paradigm operations are also very routine, and this part is requiredcompose
How to deal with it. And state management may not all need to be persisted. In temporary state management,client-side-xxx
Property processing is easy to implement.AXY+Z
Value processing will be more complicated.
The basis of collaborative algorithm is also atomized operations, similar toredux
Paradigmaction
It 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, eachaction
Only express independent intentions, but lack causality for global states (operation)A
Influence operationB
explicit maintenance of state).
OT-JSON
This 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,ShareDB
wait. certainly,CRDT
The collaborative algorithm is also a feasible choice, which is a problem of application selection.
also,OT-JSON
Naturally, 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 operationsA
Must be in operationB
Such 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 areBlocks
The form of expansion is extended. It just so happens that Feishu's data structure is also usedOT-JSON
To achieve, text coordination is achieved byEasySync
AsOT-JSON
subtypes 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
Immer
Simplified 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,
},
},
};
};
andImmer
By creating a temporary draft object, developers can directly assign values, add and delete attributes, and even use arrays like operating ordinary objects.push
、pop
etc. 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;
};
existImmer
It is very important to useProxy
During this process of agent modification, it will only be created when data is accessed.Proxy
Object, 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 = 1
,Immer
Agents will be generated layer by layer along the access path.Proxy(a)
、Proxy()
、Proxy()
. Therefore useImmer
When 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
existslate
Implemented9
Atomic operations to describe changes, which include text processinginsert_text
, node processinginsert_node
, selection changeset_selection
operations, etc. But inslate
Although 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-JSON
Implemented11
andjson0
The 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 scenesSubType
Still need to be extended, such as Feishu'sEasySync
Type extension, then naturally more operations are needed to describe the changes.
-
{p:[path], na:x}
: In the specified path[path]
Add valuex
Value. -
{p:[path,idx], li:obj}
: On the list[path]
Index ofidx
Insert object beforeobj
。 -
{p:[path,idx], ld:obj}
: From the list[path]
Index ofidx
Delete the object inobj
。 -
{p:[path,idx], ld:before, li:after}
: Use objectsafter
Replacement list[path]
Indicesidx
Object ofbefore
。 -
{p:[path,idx1], lm:idx2}
: Add the list[path]
Indicesidx1
The object to the indexidx2
place. -
{p:[path,key], oi:obj}
: toward the path[path]
Add keys to the object inkey
and objectsobj
。 -
{p:[path,key], od:obj}
: From the path[path]
Delete key in object inkey
and valueobj
。 -
{p:[path,key], od:before, oi:after}
: Use objectsafter
Replace path[path]
Middle keykey
Object ofbefore
。 -
{p:[path], t:subtype, o:subtypeOp}
: For path[path]
The object application type int
Suboperation ofo
, subtype operation. -
{p:[path,offset], si:s}
: On the path[path]
Offset of stringoffset
Insert strings ats
, use subtypes internally. -
{p:[path,offset], sd:s}
: From the path[path]
Offset of stringoffset
Delete 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.JSON
The 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 useJSON
It is not enough to describe the content in a data structure. Analog in the component,div
It is a description view, the state needs to be defined additionally, and the state is changed through event-driven. And in the editor scene,JSON
It is both a view description and a state to be operated.
Then based onJSON
It 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-JSON
We can implement atomized data changes, andImmer
Combined, 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 onpath
Just read the data, and what we are focusing on is adding, deleting and modifying theImmer
The combination of . First of allinsert
operate,p
Indicates the path,li
It 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.ld
Indicates deletion value, note that the specific value of the delete is not the index, which is mainly forinvert
Convenient conversion. You can also see thatImmer
ofdraft
After 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-JSON
In fact, it needs to be defined at the same timeoi
andod
, 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,invert
No need tosnapshot
to help get the original value, andImmer
The 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 inundo
in the stack, whether it is to use it as an irrevocable operation or merge the previous oneundo
All operations already in the stack require the implementation of operation transformation.
We can understandb'=transform(a, b)
It means, assumptiona
andb
All from the samedraft
Branched out, thenb'
It's assumptiona
It has been applied, at this timeb
Need to be ina
Transformed fromb'
Only directly apply, we can also understand it astransform
Solveda
Operation is correctb
The impact caused by operations is to maintain causal relationships.
Here we still test the most basicinsert
、delete
、retain
In fact, we can see that the position offset in the causal relationship is more important, such as remoteb
Operation and upcoming applicationa
All operations are deleted whenb
When the operation is executeda
The content to be deleted needs to beb
Recalculate 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 isinvert
The method is mainly to achieveundo
、redo
and other functions. We also mentioned earlier,apply
Many operations require the original value to be obtained. These values are not actually verified when executed, but this can be directlyinvert
Direct conversion is not requiredsnapshot
to assist in calculating the value.
also,invert
Supports 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 exampleabc
The three operations ininvert
The corresponding one should becba
The 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-JSON
Support multipleop
It is applied simultaneously, howeverapply
When the data is performed by a single operation. This scenario is still very common, for example, when implementing the artboard, press and holdshift
And click the graph node to select multiple choices and then perform the delete operation, then this is a simultaneous basisdraft
In theory, there will be causal relationships in batch operations.
In the following example, we assume that there is now4
indivualop
, and there are duplicate index value processing. Then in the following example, the result we theoretically expect should be1/2/3
The value of[0, 4, 5, 6]
, but the final result is[0, 2, 4]
, this isapply
It is independently executed and has no processingop
induced 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,transform
Solveda
Operation is correctb
The impact caused by operations is to maintain causal relationships. Then in this case, it can be passedtransform
To deal with the correlation problem between operations, then we can directly try to calltransform
To deal with this issue.
Howevertransform
The function signature istransform(op1, op2, side)
, this means we need to transform between two sets of operations, but now weops
It is a single group operation, so we need to consider how this part should be combined. If transformed with empty groupops
If the group is[]
It's incorrect, so we need to try itop
To deal with it.
So at first I was going to consider using what would have been appliedops
Crop the operation and pass the value it directly affectstransform
To 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 compareDelta
ofOT
Implemented, singleDelta
ofops
is the data processed at a relative position, andOT-JSON
is 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 adjustedops
but 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 bea
After application,b
Changes have occurred. Then inabcd
In this case, it should bea
As the benchmark, transformb/c/d
, and thenb
As 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 multipleop
When combined, each operation is an independent absolute position, and it will not be implemented as a relative position, for example, inDelta
middle,compose
Operations are calculated as relative positions. Then we can naturally encapsulate it ascomposeWith
Method, 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 editorRef
Module. For example, when uploading pictures,loading
During 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 onImmer
as well asOT-JSON
Implement 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.slate
state management implementation.
Data operation
OT-JSON
conductapply
When the actual implementation plan is to execute one by oneop
. Then useOT-JSON
When managing state, it is easy to think about a problem. If the internal data state is changed,provider
Providedvalue
The 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,setState
No 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 followingChild
The component itself does not haveprops
Changes, butcount
Changes 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, ifC
If something changes,A
、C
The reference needs to be changed, and other objects retain the original value.Immer
It just happens to help us realize this ability.
A
/ \
B C
/ \
D E
Of course, it can also be found in previous examples, even ifprops
The 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.
Child
Component packagingmemo
, you can avoidcount
Re-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 wepath
Passed toprops
, and customizememo
ofequal
Function 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 toprops
If 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.path
Below. Therefore, through the plug-in, the component rendered by the plug-in is obtainedpath
It still needs to be passed through the outer rendering state, as aboveprops
The delivery plan is naturally not suitable, so here we passWeakMap
To achievepath
Get it.
Here we pass twoWeakMap
It can be achievedfindPath
Functions,NODE_TO_INDEX
Used to store the mapping relationship between nodes and indexes,NODE_TO_PARENT
Used to store the mapping relationship between the node and the parent node. Through these twoWeakMap
It can be achievedpath
The 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 searchpath
When you are in the target node, you can passNODE_TO_PARENT
Start searching for the parent node until the root node is found. In this search process, you can passNODE_TO_INDEX
Come 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 itpath
When 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 touseEffect
to distribute rendering completion events.
It is also important to note here that the actual editor engine needs to depend on ituseEffect
The life cycle of the parent component must be triggered after all child components are rendered.effect
side effect. Therefore, in the outer layer of the nodeContext
Level 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 statusselection
The module also relies onReact
Maintaining the status ofProvider
Come to use. The maintenance of the constituent expression itself depends onpath
, so you can use the above directly when clicking on the nodefindPath
Just 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.path
Asprops
Passing 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.Context
supplyvalue
, 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 globalhooks
is used to provide the selection value of all subcomponents, which is directly in the subcomponents.useContext
That's it, the application portal also needs to use the editor's own events to manageContext
selection 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.Provider
to manage the status. Then if it is a deeply nested component selected state, we need to change the deepest level.Provider
The value can change the selected state.
Then here you need to rely on the top levelselection
Changes to trigger the top levelProvider
Change, 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 allpath
Nodes with low indexes are selected.
It's still necessary to cooperate hereTo use it, because
selected
Will beprops
Pass to the child component, so inselected
When 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
History
The module is withOT-JSON
For modules that combine relatively tightly with data operations, they will be used in depthtransform
Perform operation transformations, including selection and data transformations. alsoinvert
Methods are also essential, reverse operation isundo
、redo
The basis of
First of all, you need to pay attention to when to deal with itundo
Obviously we only need toapply
The stack data needs to be processed only during operation, andapply
When you are also careful, you need to pay attention to only user-triggered content that needs to be processed. When the operation source isHistory
When 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 apply
The time to record the value of the current selection, in actualapply
Then the latest changeschanges
Push 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 stackops
With the currentchanges
Merge, this actually used our previouscomposeWith
method.
// 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;
}
undo
andredo
The two methods of the two need to be used in conjunction with each other. When the user-state operation is not performed, thehistory
The modules themselves apply to each otherchanges
It is necessary to perform a transformation and then enter another stack. Right nowundo
Executedchanges
Need to beinvert
Enter laterredo
Stack 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 itpath
Change the parameters. The reason for the selection transformation is that it was previously storedrange
It 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 factHistory
This part uses much more than these operational transformations. In the collaborative scenario, we need to consider how to deal with it.remote
After 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 casesundo
For stack operations, operation transformation is also needed here to deal with it.ops
We will put this part based on the side effects caused by movement.Delta
Implementation 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 onImmer
andOT-JSON
A set of application status management solutions was designed, throughImmer
The draft mechanism simplifies immutable data operations, combined withOT-JSON
The 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