浏览器输入模式的非受控DOM行为

先前我们在选区模块的基础上,通过浏览器的组合事件来实现半受控的输入模式,这是状态同步的重要实现之一。在这里我们要关注于处理浏览器复杂DOM结构默认行为,以及兼容IME输入法的各种输入场景,相当于我们来Case By Case地处理输入法和浏览器兼容的行为。

概述

在整个编辑器系列最开始的时候,我们就提到了ContentEditable的可控性以及浏览器兼容性问题,特别是结合了React作为视图层的模式下,状态管理以及DOM的行为将变得更不可控,这里回顾一下常见的浏览器的兼容性问题:

  • 在空contenteditable编辑器的情况下,直接按下回车键,在Chrome中的表现是会插入<div><br></div>,而在FireFox(<60)中的表现是会插入<br>IE中的表现是会插入<p><br></p>
  • 在有文本的编辑器中,如果在文本中间插入回车例如123|123,在Chrome中的表现内容是123<div>123</div>,而在FireFox中的表现则是会将内容格式化为<div>123</div><div>123</div>
  • 同样在有文本的编辑器中,如果在文本中间插入回车后再删除回车,例如123|123->123123,在Chrome中的表现内容会恢复原本的123123,而在FireFox中的表现则是会变为<div>123123</div>
  • 在同时存在两行文本的时候,如果同时选中两行内容再执行("formatBlock", false, "P")命令,在Chrome中的表现是会将两行内容包裹在同个<p>中,而在FireFox中的表现则是会将两行内容分别包裹<p>标签。
  • ...

由于我们的编辑器输入是依靠浏览器提供的组合事件,自然无法规避相关问题。编辑器设计的视图结构是需要严格控制的,这样我们才能根据一定的规则实现视图与选区模式的同步。依照整体MVC架构的设计,当前编辑器的视图结构设计如下:

<div data-block="true" >
  <div data-node="true">
    <span data-leaf="true"><span data-string="true">inline</span></span>
    <span data-leaf="true"><span data-string="true">inline2</span></span>
  </div>
</div>

那么如果在ContentEdiable输入时导致上述的结构被破坏,我们设计的编辑器同步模式便会出现问题。因此为了解决类似的问题,我们就需要实现脏DOM检查,若是出现破坏性的节点结构,就需要尝试修复DOM结构,甚至需要调度React来重新渲染严格的视图结构。

然而,如果每次输入或者选区变化等时机都进行DOM检查和修复,势必会影响编辑器整体性能或者输入流畅性,并且DOM检查和修复的范围也需要进行限制,否则同样影响性能。因此在这里我们需要对浏览器的输入模式进行归类,针对不同的类型进行不同的DOM检查和修复模式。

行内节点

DOM结构与Model结构的同步在非受控的React组件中变得复杂,这其实也就是部分编辑器选择自绘选区的原因之一,可以以此避免非受控问题。那么非受控的行为造成的主要问题可以比较容易地复现出来,假设此时存在两个节点,分别是inline类型和text类型的文本节点:

inline|text

此时我们的光标在inline后,假设schema中定义的inline规则是不会继承前个节点的格式,那么接下来如果我们输入内容例如1,此时文本就变成了inline|1text。这个操作是符合直觉的,然而当我们在上述的位置唤醒IME输入中文内容时,这里的文本就变成了错误的内容。

inline中文|中文text

这里的差异可以比较容易地看出来,如果是输入的英文或者数字,即不需要唤醒IME的受控输入模式,1这个字符是会添加到text文本节点前。而唤醒IME输入法的非受控输入模式,则会导致输入的内容不仅出现在text前,而且还会出现在inline节点的后面,这部分显然是有问题的。

这里究其原因还是在于非受控的IME问题,在输入英文时我们的输入在beforeinput事件中被阻止了默认行为,因此不会触发浏览器默认行为的DOM变更。然而当前在唤醒IME的情况下,DOM的变更行为是无法被阻止的,因此此时属于非受控的输入,这样就导致了问题。

此时由于浏览器的默认行为,inline节点的内容会被输入法插入“中文”的文本,这部分是浏览器对于输入法的默认处理。而当我们输入完成后,数据结构Model层的内容是会将文本放置于text前,这部分则是编辑器来控制的行为,这跟我们输入非中文的表现是一致的,也是符合预期表现的。

那么由于我们的immutable设计,再加上性能优化策略的memo以及useMemo的执行,即使在最终的文本节点渲染加入了脏DOM检测也是不够的,因为此时完全不会执行rerender。这就导致React原地复用了当前的DOM节点,因此造成了IME输入的DOM变更和Model层的不一致。

const onRef = (dom: HTMLSpanElement | null) => {
  if (props.children === dom.textContent) return void 0;
  const children = dom.childNodes;
  // If the text content is inconsistent due to the modification of the input
  // it needs to be corrected
  for (let i = 1; i < children.length; ++i) {
    const node = children[i];
    node && node.remove();
  }
  // Guaranteed to have only one text child
  if (isDOMText(dom.firstChild)) {
    dom.firstChild.nodeValue = props.children;
  }
};

而如果我们直接将leafReact.memo以及useMemo移除,这个问题自然是会消失,然而这样就会导致编辑器的性能下降。因此我们就需要考虑尽可能检查到脏DOM的情况,实际上如果是在input事件或者MutationObserver中处理输入的纯非受控情况,也需要处理脏DOM的问题。

那么我们可以明显的想到,当行状态发生变更时,我们就直接检查当前行的所有leaf节点,然后对比文本内容,如果存在不一致的情况则直接进行修正。如果直接使用querySelector的话显然不够优雅,我们可以借助WeakMap来映射叶子状态到DOM结构,以此来快速定位到需要的节点。

然后在行节点的状态变更后,在处理副作用的时候检查脏DOM节点,并且由于我们的行状态也是immutable的,因此也不需要担心性能问题。此时检查的执行是O(N)的算法,而且检查的范围也会限制在发生rerender的行中,具体检查节点的方法自然也跟上述onRef一致。

const leaves = lineState.getLeaves();
for (const leaf of leaves) {
  const dom = LEAF_TO_TEXT.get(leaf);
  if (!dom) continue;
  const text = leaf.getText();
  // 避免 React 非受控与 IME 造成的 DOM 内容问题
  if (text === dom.textContent) continue;
  editor.logger.debug("Correct Text Node", dom);
  const nodes = dom.childNodes;
  for (let i = 1; i < nodes.length; ++i) {
    const node = nodes[i];
    node && node.remove();
  }
  if (isDOMText(dom.firstChild)) {
    dom.firstChild.nodeValue = text;
  }
}

这里需要注意的是,脏节点的状态检查是需要在useLayoutEffect时机执行的,因为我们需要保证执行的顺序是先校正DOM再更新选区。如果反过来的话就会导致一个问题,先更新的选区依然停留在脏节点上,此时再校正会由于DOM节点变化导致选区的丢失,表现是选区会在inline的最前方。

leaf rerender -> line rerender -> line layout effect -> block layout effect

此外,这里的实现在首次渲染并不需要检查,此时不会存在脏节点的情况,因此初始化渲染的时候我们可以直接跳过检查。以这种策略来处理脏DOM的问题,还可以避免部分其他可能存在的问题,零宽字符文本的内容暂时先不处理,如果再碰到类似的情况是需要额外的检查的。

其实换个角度想,这里的问题也可能是我们的选区策略是尽可能偏左侧的查找,如果在这种情况将其校正到右侧节点可能也可以解决问题。不过因为在空行的情况下我们的末尾\n节点并不会渲染,因此这样的策略目前并不能彻底解决问题,而且这个处理方式也会使得编辑器的选区策略变得更加复杂。

[inline|][text] => [inline][|text]

这里还需要关注下ReactHooks调用时机,在下面的例子中,从真实DOM中得到onRef执行顺序是最前的,因此在此时进行首次DOM检查是合理的。而后续的Child LayoutEffect就类似于行DOM检查,在修正过后在Parent LayoutEffect中更新选区是符合调度时机方案。

Child onRef Child useLayoutEffect Parent useLayoutEffect Child useEffect Parent useEffect
// https://playcode.io/react
import React from 'react';
const Child = () => {
  const [,forceUpdate] = React.useState({});
  const onRef = () => console.log("Child onRef");
  React.useEffect(() => console.log("Child useEffect"));
  React.useLayoutEffect(() => console.log("Child useLayoutEffect"));
  return <button ref={onRef} onClick={() => forceUpdate({})}>Update</button>
}
export function App(props) {
  React.useEffect(() => console.log("Parent useEffect"));
  React.useLayoutEffect(() => console.log("Parent useLayoutEffect"));
  return <Child></Child>;
}

包装节点

关于包装节点的问题需要我们先聊一下这个模式的设计,现在实现的富文本编辑器是没有块结构的,因此实现任何具有嵌套的结构都是个复杂的问题。在这里我们原本就不会处理诸如表格类的嵌套结构,但是例如blockquote这种wrapper级结构我们是需要处理的。

类似的结构还有list,但是list我们可以完全自己绘制,但是blockquote这种结构是需要具体组合才可以的。然而如果仅仅是blockquote还好,在inline节点上使用wrapper是更常见的实现,例如a标签的包装在编辑器的实现模式中就是很常规的行为。

具体来说,在我们将文本分割为bolditalicinline节点时,会导致DOM节点被实际切割,此时如果嵌套<a>节点的话,就会导致hover后下划线等效果出现切割。因此如果能够将其wrapper在同一个<a>标签的话,就不会出现这种问题。

但是新的问题又来了,如果仅仅是单个key来实现渲染时嵌套并不是什么复杂问题,而同时存在多个需要wrapperkey则变成了令人费解的问题。如下面的例子中,如果将34单独合并b,外层再包裹a似乎是合理的,但是将34先包裹a后再合并5b也是合理的,甚至有没有办法将67一并合并,因为其都存在b标签。

1 2 3  4  5 6  7 8 9 0
a a ab ab b bc b c c c

思来想去,我最终想到了个简单的实现,对于需要wrapper的元素,如果其合并listkeyvalue全部相同的话,那么就作为同一个值来合并。那么这种情况下就变的简单了很多,我们将其认为是一个组合值,而不是单独的值,在大部分场景下是足够的。

1 2 3  4  5 6  7 8 9 0
a a ab ab b bc b c c c
12 34 5 6 7 890

不过话又说回来,这种wrapper结构是比较特殊的场景下才会需要的,在某些操作例如缩进这个行为中,是无法判断究竟是要缩进引用块还是缩进其中的文字。这个问题在很多开源编辑器中都存在,特别是扁平化的数据结构设计例如Quill编辑器。

其实也就是在没有块结构的情况下,对于类似的行为不好控制,而整体缩进这件事配合list在大型文档中也是很合理的行为,因此这部分实现还是要等我们的块结构编辑器实现才可以。当然,如果数据结构本身支持嵌套模式,例如Slate就可以实现。

后续在wrap node实现的a标签来实现输入时,又出现了上述类似inline-code的脏DOM问题。以下面的DOM结构来看,看似并不会有什么问题,然而当光标放置于超链接这三个字后唤醒IME输入中文时,会发现输入“测试输入”这几个字会被放置于直属div下,与a标签平级。

<div contenteditable>
  <a href="https://www.baidu.com"><span>超链接</span></a>
  <span>文本</span>
</div>
<div contenteditable>
  <a href="https://www.baidu.com"><span>超链接</span></a>
  测试输入
  <span>文本</span>
</div>

在这种情况下我们先前实现的脏DOM检测就失效了,因为检查脏DOM的实现是基于data-leaf实现的。此时浏览器的输入表现会导致我们无法正确检查到这部分内容,除非直接拿data-node行节点来直接判断,这样的实现自然不够好。

说到这里,先前我发现飞书文档的实现是a标签渲染的leaf,而wrap的包装实现是使用的span直接处理的,并且额外增加了样式来实现hover效果。直接使用span包裹就不会出现上述问题,而内部的a标签虽然会导致同样的问题,但是在leaf下可以触发脏DOM检查。

<div contenteditable>
  <span>
    <a href="https://www.baidu.com"><span>超链接</span></a>
    测试输入
  </span>
  <span>文本</span>
</div>

因此就可以在先前的脏DOM检查基础上解决了问题,而本质上类似的行为就是浏览器默认处理的结果,不同的浏览器处理结果可能都不一样。目前看起来是浏览器认为a标签的结构应该是属于inline的实现,也就是类似我们的inline-code实现,理论上倒却是并没有什么问题,由此我们需要自己来处理这些非受控的问题。

实际上Quill本身也会出现这个问题,同样也是脏DOM的处理。而slate并不会出现这个问题,这里处理方案则是通过DOM规避了问题,在a标签两端放置额外的&nbsp节点,以此来避免这个问题。当然还引入了额外的问题,引入了新的节点,目前看起来转移光标需要受控处理。

<!-- https://github.com/ianstormtaylor/slate/blob/main/site/examples/ts/inlines.tsx -->
<div contenteditable>
  <a href="https://www.baidu.com"
    ><span contenteditable="false" style="font-size: 0">&nbsp;</span
    ><span>超链接测试输入</span
    ><span contenteditable="false" style="font-size: 0">&nbsp;</span></a
  ><span>文本</span>
</div>

浏览器兼容性

在后续浏览器的测试中,重新出现了上述提到的a标签问题,此时并不是由于包装节点引起的,因此问题变得复杂了很多,主要是各个浏览器的兼容性的问题。类似于行内代码块,本质上还是浏览器IME非受控导致的DOM变更问题,但是在浏览器表现差异很大,下面是最小的DEMO结构。

<div contenteditable>
  <span data-leaf><a href="#"><span data-string>在[:]后输入:</span></a></span><span data-leaf>非链接文本</span>
</div>

在上述示例的a标签位置的最后的位置上输入内容,主流的浏览器的表现是有差异的,甚至在不同版本的浏览器上表现还不一致:

  • Chrome中会在a标签的同级位置插入文本类型的节点,效果类似于<a></a>"text"内容。
  • Firefox中会在a标签内插入span类型的节点,效果类似于<a></a><span data-string>text</span>内容。
  • Safari中会将a标签和span标签交换位置,然后在a标签上同级位置加入文本内容,类似<span><a></a>"text"</span>
<!-- Chrome -->
<span data-leaf="true">
  <a href="https://www.baidu.com"><span data-string="true">超链接</span></a>
  "文本"
</span>

<!-- Firefox -->
 <span data-leaf="true">
  <a href="https://www.baidu.com"><span data-string="true">超链接</span></a>
  <span data-string="true">文本</span>
</span>

<!-- Safari -->
 <span data-leaf="true">
  <span data-string="true">
    <a href="https://www.baidu.com">超链接</a>
    "文本"
    ""
  </span>
</span>

因此我们的脏DOM检查需要更细粒度地处理,仅仅对比文本内容显然是不足以处理的,我们还需要检查文本的内容节点结构是否准确。其实最开始我们是仅处理了Chrome下的情况,最简单的办法就是在leaf节点下仅允许存在单个节点,存在多个节点则说明是脏DOM

for (let i = 1; i < nodes.length; ++i) {
  const node = nodes[i];
  node && node.remove();
}

但是后来发现在编辑时会把Embed节点移除,这里也就是因为我们错误地把组合的div节点当成了脏DOM,因此这里就需要更细粒度地处理了。然后考虑检查节点的类型,如果是文本的节点类型再移除,那么就可以避免Embed节点被误删的问题。

for (let i = 1; i < nodes.length; ++i) {
  const node = nodes[i];
  isDOMText(node) && node.remove();
}

虽然看起来是解决了问题,然而在后续就发现了FirefoxSafari下的问题。先来看Firefox的情况,这个节点并非文本类型的节点,在脏DOM检查的时候就无法被移除掉,这依然无法处理Firefox下的脏DOM问题,因此我们需要进一步处理不同类型的节点。

// data-leaf 节点内部仅应该存在非文本节点, 文本类型单节点, 嵌入类型双节点
for (let i = 1; i < nodes.length; ++i) {
  const node = nodes[i];
  // 双节点情况下, 即 Void/Embed 节点类型时需要忽略该节点
  if (isHTMLElement(node) && node.hasAttribute(VOID_KEY)) {
    continue;
  }
  node.remove();
}

Safari的情况下就更加复杂,因为其会将a标签和span标签交换位置,这样就导致了DOM结构性造成了破坏。这种情况下我们就必须要重新刷新DOM结构,这种情况下就需要更加复杂地处理,在这里我们加入forceUpdate以及TextNode节点的检查。

其实在飞书文档中也是采用了类似的做法,飞书文档的a标签在唤醒IME输入后,同样会触发脏DOM的检查,然后飞书文档会直接以行为基础ReMount当前行的所有leaf节点,这样就可以避免复杂的脏DOM检查。我们这里实现更精细的leaf处理,主要是避免不必要的挂载。

const LeafView: FC = () => {
  const { forceUpdate, index: renderKey } = useForceUpdate();
  LEAF_TO_REMOUNT.set(leafState, forceUpdate);
  return (<span key={renderKey}></span>);
}

if (isDOMText(dom.firstChild)) {
  // ...
} else {
  const func = LEAF_TO_REMOUNT.get(leaf);
  func && func();
}

这里需要注意的是,我们还需要处理零宽字符类型的情况。当Embed节点前没有任何节点,即位于行首时,输入中文后同样会导致IME的输入内容被滞留在Embed节点的零宽字符上,这点与上述的inline节点是类似的,因此这部分也需要处理。

const zeroNode = LEAF_TO_ZERO_TEXT.get(leaf);
const isZeroNode = !!zeroNode;
const textNode = isZeroNode ? zeroNode : LEAF_TO_TEXT.get(leaf);
const text = isZeroNode ? ZERO_SYMBOL : leaf.getText();
const nodes = textNode.childNodes;

到这里,我们的脏DOM检查已经能够处理大部分情况了,整体的模式都是React在行DOM结构计算完成后,浏览器渲染前进行处理。针对于文本节点以及a标签的检查,需要检查文本与状态的关系,以及严格的DOM结构破坏后的需要直接Remount组件。

// 文本节点内部仅应该存在一个文本节点, 需要移除额外节点
for (let i = 1; i < nodes.length; ++i) {
  const node = nodes[i];
  node && node.remove();
}
// 如果文本内容不合法, 通常是由于输入的脏 DOM, 需要纠正内容
if (isDOMText(textNode.firstChild)) {
  // Case1: [inline-code][caret][text] IME 会导致模型/文本差异
  // Case3: 在单行仅存在 Embed 节点时, 在节点最前输入会导致内容重复
  if (textNode.firstChild.nodeValue === text) return false;
  textNode.firstChild.nodeValue = text;
  } else {
  // Case2: Safari 下在 a 节点末尾输入时, 会导致节点内外层交换
  const func = LEAF_TO_REMOUNT.get(leaf);
  func && func();
  if (process.env.NODE_ENV === "development") {
    console.log("Force Render Text Node", textNode);
  }
}

而针对于额外的文本节点,即本章节中重点提到的浏览器兼容性问题,我们需要严格地控制leaf节点下的DOM结构。如果仅存在单个文本节点的情况下,是符合设计的结构,而如果是存在多个节点,除了Void/Embed节点的情况外,则说明DOM结构被破坏了,这里我们就需要移除掉多余的节点。

// data-leaf 节点内部仅应该存在非文本节点, 文本类型单节点, 嵌入类型双节点
for (let i = 1; i < nodes.length; ++i) {
  const node = nodes[i];
  // 双节点情况下, 即 Void/Embed 节点类型时需要忽略该节点
  if (isHTMLElement(node) && node.hasAttribute(VOID_KEY)) {
    continue;
  }
  // Case1: Chrome a 标签内的 IME 输入会导致同级的额外文本节点类型插入
  // Case2: Firefox a 标签内的 IME 输入会导致同级的额外 data-string 节点类型插入
  node.remove();
}

样式组合渲染

由于我们的编辑器是以immutable提高渲染性能,因此在文本节点变更时若是需要存在连续的格式处理,例如inline-code的样式实现,就会出现组件不重新渲染问题。具体表现是若是存在多个连续的code节点,最后一个节点长度为1,删除最后这个节点时会导致前一个节点无法刷新样式。

[inline][c]|

这个问题的原因是我们的className是在渲染leaf节点时动态计算的,具体的逻辑如下所示。如果前一个节点不存在或者前一个节点不是inline-code,则添加inline-code-start类属性,类似的需要在最后一个节点加入inline-code-end类属性。

if (!prev || !prev.op.attributes || !prev.op.attributes[INLINE_CODE_KEY]) {
  context.classList.push(INLINE_CODE_START_CLASS);
}
context.classList.push("block-kit-inline-code");
if (!next || !next.op.attributes || !next.op.attributes[INLINE_CODE_KEY]) {
  context.classList.push(INLINE_CODE_END_CLASS);
}

这个情况同样类似于Dirty DOM的问题,由于删除的节点长度为1,因此前一个节点的LeafState并没有变更,因此不会触发React的重新渲染。这里我们就需要在行节点渲染时进行纠正,这里的执行倒是不需要像上述检查那样同步执行,以异步的effect执行即可。

/**
 * 编辑器行结构布局计算后异步调用
 */
public didPaintLineState(lineState: LineState): void {
  for (let i = 0; i < leaves.length; i++) {
    if (!prev || !prev.op.attributes || !prev.op.attributes[INLINE_CODE_KEY]) {
      node && node.classList.add(INLINE_CODE_START_CLASS);
    }
    if (!next || !next.op.attributes || !next.op.attributes[INLINE_CODE_KEY]) {
      node && node.classList.add(INLINE_CODE_END_CLASS);
    }
  }
}

虽然看起来已经解决了问题,然而在React中还是存在一些问题,主要的原因此时的DOM处理是非受控的。类似于下面的例子,由于React在处理style属性时,只会更新发生变化的样式属性,即使整体是新对象,但具体值与上次渲染时相同,因此React不会重新设置这个样式属性。

// https://playcode.io/react
import React from "react";
export function App() {
  const el = React.useRef();
  const [, setState] = React.useState(1);
  const onClick = () => {
    el.current && (el.current.style.color = "blue");
  }
  console.log("Render App")
  return (
    <div>
      <div style={{ color:"red" }} ref={el}>Hello React.</div>
      <button onClick={onClick}>Color Button</button>
      <button onClick={() => setState(c => ++c)}>Rerender Button</button>
    </div>
  );
}

因此,在上述的didPaintLineState中我们主要是classList添加类属性值,即使是LeafState发生了变更,React也不会重新设置类属性值,因此这里我们还需要在didPaintLineState变更时删除非必要的类属性值。

public didPaintLineState(lineState: LineState): void {
  for (let i = 0; i < leaves.length; i++) {
    if (!prev || !prev.op.attributes || !prev.op.attributes[INLINE_CODE_KEY]) {
      node && node.classList.add(INLINE_CODE_START_CLASS);
    } else {
      node && node.classList.remove(INLINE_CODE_START_CLASS);
    }
    if (!next || !next.op.attributes || !next.op.attributes[INLINE_CODE_KEY]) {
      node && node.classList.add(INLINE_CODE_END_CLASS);
    } else {
      node && node.classList.remove(INLINE_CODE_END_CLASS);
    }
  }
}

总结

在先前我们实现了半受控的输入模式,这个输入模式同样是目前大多数富文本编辑器的主流实现方式。在这里我们关注于浏览器ContentEdiable模式输入的默认行为造成的DOM结构问题,并且通过脏DOM检查的方式来修正这些问题,以此来保持编辑器的严格DOM结构。

当前我们主要关注的是编辑器文本的输入问题,即如何将键盘输入的内容写入到编辑器数据模型中。而接下来我们需要关注于输入模式结构化变更的受控处理,即回车、删除、拖拽等操作的处理,这些操作同样也是基于输入相关事件实现的,而且通常会涉及到文本的结构变更,属于输入模式的补充。

每日一题

参考