24小时热门版块排行榜    

Znn3bq.jpeg
查看: 138  |  回复: 0

壁游先泳鹿

新虫 (小有名气)

[交流] 从零实现富文本编辑器#11-Immutable状态维护与增量渲染

在这里我们先不引入视图层的渲染问题,而是仅在Model层面上实现精细化的处理,具体来说就是实现不可变的状态对象,仅更新的节点才会被重新创建,其他节点则直接复用。由此想来此模块的实现颇为复杂,也并未引入immer等框架,而是直接处理的状态对象,因此先从简单的更新模式开始考虑。

回到最开始实现的State模块更新文档内容,我们是直接重建了所有的LineState以及LeafState对象,然后在React视图层的BlockModel中监听了OnContentChange事件,以此来将BlockState的更新应用到视图层。

Copy
delta.eachLine((line, attributes, index) => {
  const lineState = new LineState(line, attributes, this);
  lineState.index = index;
  lineState.start = offset;
  lineState.key = Key.getId(lineState);
  offset = offset + lineState.length;
  this.lines[index] = lineState;
});
这种方式简单直接,全量更新状态能够保证在React的状态更新,然而这种方式的问题在于性能。当文档内容非常大的时候,全量计算将会导致大量的状态重建,并且其本身的改变也会导致React的diff差异进而全量更新文档视图,这样的性能开销通常是不可接受的。

那么通常来说我们就需要基于变更来确定状态的更新,首先我们需要确定更新的粒度,例如以行为基准则未变更的时候就直接取原有的LineState。相当于尽可能复用Origin List然后生成Target List,这样的方式自然可以避免部分状态的重建,尽可能复用原本的对象。

整体思路大概是先执行变成生成最新的列表,然后分别设置旧列表和新列表的row和col两个指针值,然后更新时记录起始row,删除和新增自然是正常处理,对于更新则认为是先删后增。对于内容的处理则需要分别讨论单行和跨行的问题,中间部分的内容就作为重建的操作。

最后可以将这部分增删LineState数据放置于Changes中,就可以得到实际增删的Ops了,这样我们就可以优化部分的性能,因为仅原列表和目标列表的中间部分才会重建,其他部分的行状态直接复用。此外这部分数据在apply的delta中是不存在的,同样可以认为是数据的补充。

Copy
  Origin List (Old)                          Target List (New)
+-------------------+                      +-------------------+
| [0] LineState A   | <---- Retain ------> | [0] LineState A   | (Reused)
+-------------------+                      +-------------------+
| [1] LineState B   |          |           | [1] LineState B2  | (Update)
+-------------------+       Changes        |     (Modified)    | (Del C)
| [2] LineState C   |          |           +-------------------+
+-------------------+          V           | [2] NewState X    | (Inserted)
| [3] LineState D   | ---------------\     +-------------------+
+-------------------+                 \--> | [3] LineState D   | (Reused)
| [4] LineState E   | <---- Retain ------> | [4] LineState E   | (Reused)
+-------------------+                      +-------------------+
那么这里实际上是存在非常需要关注的点,我们现在维护的是状态模型,也就是说所有的更新就不再是直接的compose,而是操作我们实现的状态对象。本质上我们是需要实现行级别的compose方法,这里的实现非常重要,假如我们对于数据的处理存在偏差的话,那么就会导致状态出现问题。

此外在这种方式中,我们判断LineState是否需要新建则是根据整个行内的所有LeafState来重建的。也就是说这种时候我们是需要再次将所有的op遍历一遍,当然实际上由于最后还需要将compose后的Delta切割为行级别的内容,所以其实即使在应用变更后也最少需要再遍历两次。

那么此时我们需要思考优化方向,首先是首个retain,在这里我们应该直接完整复用原本的LineState,包括处理后的剩余节点也是如此。而对于中间的节点,我们就需要为其独立设计更新策略,这部分理论上来说是需要完全独立处理为新的状态对象的,这样可以减少部分Leaf Op的遍历。

Copy
new Delta().retain(5).insert("xx"
insert("123", insert("\n" // skip
insert("456", insert("\n" // new line state
其中,如果是新建的节点,我们直接构建新的LineState即可,删除的节点则不从原本的LineState中放置于新的列表。而对于更新的节点,我们需要更新原本的LineState对象,因为实际上行是存在更新的,而重点是我们需要将原本的LineState的key值复用。

这里我们先简单实现实现描述一下复用的问题,比较方便的实现则是直接以\n的标识为目标的State,这就意味着我们要独立\n为独立的状态。即如果在123|456\n的|位置插入\n的话,那么我们就是123是新的LineState,456是原本的LineState,以此来实现key的复用。

Copy
[
  insert("123", insert("\n",
  insert("456", insert("\n"
]
// ===>
[
  LineState(LeafState("123", LeafState("\n"),
  LineState(LeafState("456", LeafState("\n")
]
其实这里有个非常值得关注的点是,LineState在Delta中是没有具体对应的Op的,而相对应的LeafState则是有具体的Op的。这就意味着我们在处理LineState的更新时,是不能直接根据变更控制的,因此必须要找到能够映射的状态,因此最简单的方案即根据\n节点映射。

Copy
LeafState("\n", key="1" <=> LineState(key="L1"
实际上我们可以总结一下,最开始我们考虑先更新再diff,后来考虑的是边更新边记录。边更新边记录的优点在于,可以避免再次遍历一边所有Leaf节点的消耗,同时也可以避免diff的复杂性。但是这里也存在个问题,如果内部进行了多次retain操作,则无法直接复用LineState。

不过通常来说,最高频的操作是输入内容,这种情况下首操作一般都是retain,尾操作为空会收集剩余文档内容,因此这部分优化是会被高频触发的。而如果是多次的内容部分变更操作,这部分虽然可以通过判断行内的叶子结点是否变更,来判断是否复用行对象,但是也存在一定复杂性。

关于这部分的具体实现,在编辑器的状态模块里存在独立的Mutate模块,这部分实现在后边实现各个模块时会独立介绍。到这里我们就可以实现一个简单的Immutable状态维护,如果Leaf节点发生变化之后,其父节点Line会触发更新,而其他节点则可以直接复用。

Key 值维护#
至此我们实现了一套简单的Immutable Delta+Iterator来处理更新,这种时候我们就可以借助不可变的方式来实现React视图的更新,那么在React的渲染模式中,key值的管理也是个值的探讨的问题。

在这里我们就可以根据状态不可变来生成key值,借助WeakMap映射关系获取对应的字符串id值,此时就可以借助key的管理以及React.memo来实现视图的复用。其实在这里初步看起来key值应该是需要主动控制强制刷新的时候,以及完全是新节点才会用得到的。

但是这种方式也是有问题的,因为此时我们即使输入简单的内容,也会导致整个行的key发生改变,而此时我们是不必要更新此时的key的。因此key值是需要单独维护的,不能直接使用不可变的对象来索引key值,那么如果是直接使用index作为key值的话,就会存在潜在的原地复用问题。

key值原地复用会导致组件的状态被错误保留,例如此时有个非受控管理的input组件列表,在某个输入框内已经输入了内容,当其发生顺序变化时,原始输入内容会跟随着原地复用的策略留在原始的位置,而不是跟随到新的位置,因为其整体列表顺序key未发生变化导致React直接复用节点。

在LineState节点的key值维护中,如果是初始值则是根据state引用自增的值,在变更的时候则是尽可能地复用原始行的key,这样可以避免过多的行节点重建并且可以控制整行的强制刷新。

而对于LeafState节点的key值最开始是直接使用index值,这样实际上会存在隐性的问题,而如果直接根据Immutable来生成key值的话,任何文本内容的更改都会导致key值改变进而导致DOM节点的频繁重建。

Copy
export const NODE_TO_KEY = new WeakMap<Object.Any, Key>();
export class Key {
  /** 当前节点 id */
  public id: string;
  /** 自动递增标识符 */
  public static n = 0;

  constructor() {
    this.id = `${Key.n++}`;
  }

  /**
   * 根据节点获取 id
   * @param node
   */
  public static getId(node: Object.Any): string {
    let key = NODE_TO_KEY.get(node);
    if (!key) {
      key = new Key();
      NODE_TO_KEY.set(node, key);
    }
    return key.id;
  }
}
通常使用index作为key是可行的,然而在一些非受控场景下则会由于原地复用造成渲染问题,diff算法导致的性能问题我们暂时先不考虑。在下面的例子中我们可以看出,每次我们都是从数组顶部删除元素,而实际的input值效果表现出来则是删除了尾部的元素,这就是原地复用的问题。在非受控场景下比较明显,而我们的ContentEditable组件就是一个非受控场景,因此这里的key值需要再考虑一下。

Copy
const { useState, Fragment, useRef, useEffect } = React;
function App() {
  const ref = useRef<HTMLParagraphElement>(null);
  const [nodes, setNodes] = useState(() => Array.from({ length: 10 }, (_, i) => i));

  const onClick = () => {
    const [_, ...rest] = nodes;
    console.log(rest);
    setNodes(rest);
  };

  useEffect(() => {
    const el = ref.current;
    el && Array.from(el.children).forEach((it, i) => ((it as HTMLInputElement).value = i + "");
  }, []);

  return (
    <Fragment>
      <p ref={ref}>
        {nodes.map((_, i) => (<input key={i}></input>)}
      </p>
      <button onClick={onClick}>slice</button>
    </Fragment>
  );
}
考虑到先前提到的我们不希望任何文本内容的更改都导致key值改变引发重建,因此就不能直接使用计算的immutable对象引用来处理key值,而描述单个op的方法除了insert就只剩下attributes了。

但是如果基于attributes来获得就需要精准控制合并insert的时候取需要取旧的对象引用,且没有属性的op就不好处理了,因此这里可能只能将其转为字符串处理,但是这样同样不能保持key的完全稳定,因此前值的索引改变就会导致后续的值出现变更。

Copy
const prefix = new WeakMap<LineState, Record<string, number>>();
const suffix = new WeakMap<LineState, Record<string, number>>();
const mapToString = (map: Record<string, string>: string => {
  return Object.keys(map)
    .map(key => `${key}:${map[key]}`)
    .join(",";
};
const toKey = (state: LineState, op: Op): string => {
  const key = op.attributes ? mapToString(op.attributes) : "";
  const prefixMap = prefix.get(state) || {};
  prefix.set(state, prefixMap);
  const suffixMap = suffix.get(state) || {};
  suffix.set(state, suffixMap);
  const prefixKey = prefixMap[key] ? prefixMap[key] + 1 : 0;
  const suffixKey = suffixMap[key] ? suffixMap[key] + 1 : 0;
  prefixMap[key] = prefixKey;
  suffixMap[key] = suffixKey;
  return `${prefixKey}-${suffixKey}`;
};
在slate中我先前认为生成的key跟节点是完全一一对应的关系,例如当A节点变化时,其代表的层级key必然会发生变化。然而在关注这个问题之后,我发现其在更新生成新的Node之后,会同步更新Path以及PathRef对应的Node节点所对应的key值。

Copy
for (const [pathRef, key] of pathRefMatches) {
  if (pathRef.current) {
    const [node] = Editor.node(e, pathRef.current)
    NODE_TO_KEY.set(node, key)
  }
  pathRef.unref()
}
在后续观察Lexical实现的选区模型时,发现其是用key值唯一地标识每个叶子结点的,选区也是基于key值来描述的。整体表达上比较类似于Slate的选区结构,或者说是DOM树的结构。这里仅仅是值得Range选区,Lexical实际上还有其他三种选区类型。

Copy
{
  anchor: { key: "51", offset: 2, type: "text" },
  focus: { key: "51", offset: 3, type: "text" }
}
在这里比较重要的是key值变更时的状态保持,因为编辑器的内容实际上是需要编辑的。然而如果做到immutable话,很明显直接根据状态对象的引用来映射key会导致整个编辑器DOM无效的重建。例如调整标题的等级,就由于整个行key的变化导致整行重建。

那么如何尽可能地复用key值就成了需要研究的问题,我们的编辑器行级别的key是被特殊维护的,即实现了immutable以及key值复用。而目前叶子状态的key依赖了index值,因此如果调研Lexical的实现,同样可以将其应用到我们的key值维护中。

通过在playground中调试可以发现,即使我们不能得知其是否为immutable的实现,依然可以发现Lexical的key是以一种偏左的方式维护。因此在我们的编辑器实现中,也可以借助同样的方式,合并直接以左值为准复用,拆分时若以0起始直接复用,起始非0则创建新key。

[123456(key1)][789(bold-key2)]文本,将789的加粗取消,整段文本的key值保持为key1。
[123456789(key1)]]文本,将789这段文本加粗,左侧123456文本的key值保持为key1,789则是新的key。
[123456789(key1)]]文本,将123这段文本加粗,左侧123文本的key值保持为key1,456789则是新的key。
[123456789(key1)]]文本,将456这段文本加粗,左侧123文本的key值保持为key1,456和789分别是新的key。
因此,此时在编辑器中我们也是用类似偏左的方式维护key,由于我们需要保持immutable,所以这里的表达实际上是尽可能复用先前的key状态。这里与LineState的key值维护方式类似,都是先创建状态然后更新其key值,当然还有很多细节的地方需要处理。

Copy
// 起始与裁剪位置等同 NextOp => Immutable 原地复用 State
https://vk.com/topic-237947067_60116855
https://vk.com/topic-237947067_60116986
https://vk.com/topic-237947067_60117032
https://vk.com/topic-237947067_60117094
https://vk.com/topic-237947067_60117130
https://vk.com/topic-237947067_60117167
https://vk.com/topic-237947067_60117215
https://vk.com/topic-237947067_60117268
https://vk.com/topic-237947067_60117325
https://vk.com/topic-237947067_60117376
https://vk.com/topic-237947067_60117427
https://vk.com/topic-237947067_60117475
https://vk.com/topic-237947067_60117526
https://vk.com/topic-237947067_60117591
https://vk.com/topic-237947067_60117636
https://vk.com/topic-237947067_60117713
https://vk.com/topic-237947067_60117762
https://vk.com/topic-237947067_60117807
https://vk.com/topic-237947067_60117847
https://vk.com/topic-237947067_60117890
https://vk.com/topic-237947067_60117933
https://vk.com/topic-237947067_60118039
https://vk.com/topic-237947067_60118092
https://vk.com/topic-237947067_60118149
https://vk.com/topic-237947067_60118212
https://vk.com/topic-237947067_60118276
https://vk.com/topic-237947067_60118336
https://vk.com/topic-237947067_60118391
https://vk.com/topic-237947067_60118440
https://vk.com/topic-237947067_60118482
https://vk.com/topic-237947067_60118536
https://vk.com/topic-237947067_60118589
https://vk.com/topic-237947067_60118634
https://vk.com/topic-237947067_60118682
https://vk.com/topic-237947067_60118744
https://vk.com/topic-237947067_60118808
https://vk.com/topic-237947067_60118861
https://vk.com/topic-237947067_60118913
https://vk.com/topic-237947067_60118965
https://vk.com/topic-237947067_60119013
https://vk.com/topic-237947067_60119062
https://vk.com/topic-237947067_60119105
https://vk.com/topic-237947067_60119148
https://vk.com/topic-237947067_60119202
https://vk.com/topic-237947067_60119244
https://vk.com/topic-237947067_60119286
https://vk.com/topic-237947067_60119327
https://vk.com/topic-237947067_60119375
https://vk.com/topic-237947067_60119425
https://vk.com/topic-237947067_60119494
https://vk.com/topic-237947067_60119661
https://vk.com/topic-237947067_60119707
https://vk.com/topic-237947067_60119755
https://vk.com/topic-237947067_60119805
https://vk.com/topic-237947067_60119848
https://vk.com/topic-237947067_60119889
https://vk.com/topic-237947067_60119924
https://vk.com/topic-237947067_60120067
https://vk.com/topic-237947067_60120115
https://vk.com/topic-237947067_60120160
https://vk.com/topic-237947067_60120209
https://vk.com/topic-237947067_60120243
https://vk.com/topic-237947067_60120318
https://vk.com/topic-237947067_60120368
https://vk.com/topic-237947067_60120404
https://vk.com/topic-237947067_60120443
https://vk.com/topic-237947067_60120494
https://vk.com/topic-237947067_60120531
https://vk.com/topic-237947067_60120567
https://vk.com/topic-237947067_60120613
https://vk.com/topic-237947067_60120655
https://vk.com/topic-237947067_60120699
https://vk.com/topic-237947067_60120740
https://vk.com/topic-237947067_60120782
https://vk.com/topic-237947067_60120822
https://vk.com/topic-237947067_60120863
https://vk.com/topic-237947067_60120915
https://vk.com/topic-237947067_60120975
https://vk.com/topic-237947067_60121016
https://vk.com/topic-237947067_60121060
https://vk.com/topic-237947067_60121105
https://vk.com/topic-237947067_60121159
https://vk.com/topic-237947067_60121189
https://vk.com/topic-237947067_60121231
https://vk.com/topic-237947067_60121274
https://vk.com/topic-237947067_60121330
https://vk.com/topic-237947067_60121388
https://vk.com/topic-237947067_60121457
https://vk.com/topic-237947067_60121543
https://vk.com/topic-237947067_60121621
https://vk.com/topic-237947067_60121693
https://vk.com/topic-237947067_60121732
https://vk.com/topic-237947067_60121777
https://vk.com/topic-237947067_60121839
https://vk.com/topic-237947067_60121870
https://vk.com/topic-237947067_60121900
https://vk.com/topic-237947067_60121947
https://vk.com/topic-237947067_60121987
https://vk.com/topic-237947067_60122022
https://vk.com/topic-237947067_60122055
https://vk.com/topic-237947067_60122099
https://vk.com/topic-237947067_60122143
https://vk.com/topic-237947067_60122182
https://vk.com/topic-237947067_60122215
https://vk.com/topic-237947067_60122249
https://vk.com/topic-237947067_60122279
https://vk.com/topic-237947067_60122315
https://vk.com/topic-237947067_60122354
https://vk.com/topic-237947067_60122388
https://vk.com/topic-237947067_60122408
https://vk.com/topic-237947067_60122431
https://vk.com/topic-237947067_60122462
https://vk.com/topic-237947067_60122495
https://vk.com/topic-237947067_60122529
https://vk.com/topic-237947067_60122562
https://vk.com/topic-237947067_60122602
https://vk.com/topic-237947067_60122645
https://vk.com/topic-237947067_60122713
https://vk.com/topic-237947067_60122768
https://vk.com/topic-237947067_60122815
https://vk.com/topic-237947067_60122839
https://vk.com/topic-237947067_60122863
https://vk.com/topic-237947067_60122892
https://vk.com/topic-237947067_60122918
https://vk.com/topic-237947067_60122956
https://vk.com/topic-237947067_60123010
https://vk.com/topic-237947067_60123070
https://vk.com/topic-237947067_60123110
https://vk.com/topic-237947067_60123199
https://vk.com/topic-237947067_60123232
https://vk.com/topic-237947067_60123267
https://vk.com/topic-237947067_60123302
https://vk.com/topic-237947067_60123341
https://vk.com/topic-237947067_60123379
https://vk.com/topic-237947067_60123413
https://vk.com/topic-237947067_60123469
回复此楼

» 猜你喜欢

已阅   回复此楼   关注TA 给TA发消息 送TA红花 TA的回帖
相关版块跳转 我要订阅楼主 壁游先泳鹿 的主题更新
普通表情 高级回复 (可上传附件)
信息提示
请填处理意见