嘶临辈沧从零实现富文本编辑器系列文章概述#在整个编辑器系列最开始的时候我们就提到了ContentEditable的可控性以及浏览器兼容性问题特别是结合了React作为视图层的模式下状态管理以及DOM的行为将变得更不可控这里回顾一下常见的浏览器的兼容性问题:在空contenteditable编辑器的情况下直接按下回车键在Chrome中的表现是会插入而在FireFox(60)中的表现是会插入IE中的表现是会插入。在有文本的编辑器中如果在文本中间插入回车例如123|123在Chrome中的表现内容是123123而在FireFox中的表现则是会将内容格式化为123123。同样在有文本的编辑器中如果在文本中间插入回车后再删除回车例如123|123-123123在Chrome中的表现内容会恢复原本的123123而在FireFox中的表现则是会变为123123。在同时存在两行文本的时候如果同时选中两行内容再执行(formatBlock, false, P)命令在Chrome中的表现是会将两行内容包裹在同个中而在FireFox中的表现则是会将两行内容分别包裹标签。...由于我们的编辑器输入是依靠浏览器提供的组合事件自然无法规避相关问题。编辑器设计的视图结构是需要严格控制的这样我们才能根据一定的规则实现视图与选区模式的同步。依照整体MVC架构的设计当前编辑器的视图结构设计如下:Copyinlineinline2那么如果在ContentEdiable输入时导致上述的结构被破坏我们设计的编辑器同步模式便会出现问题。因此为了解决类似的问题我们就需要实现脏DOM检查若是出现破坏性的节点结构就需要尝试修复DOM结构甚至需要调度React来重新渲染严格的视图结构。然而如果每次输入或者选区变化等时机都进行DOM检查和修复势必会影响编辑器整体性能或者输入流畅性并且DOM检查和修复的范围也需要进行限制否则同样影响性能。因此在这里我们需要对浏览器的输入模式进行归类针对不同的类型进行不同的DOM检查和修复模式。行内节点#DOM结构与Model结构的同步在非受控的React组件中变得复杂这其实也就是部分编辑器选择自绘选区的原因之一可以以此避免非受控问题。那么非受控的行为造成的主要问题可以比较容易地复现出来假设此时存在两个节点分别是inline类型和text类型的文本节点:Copyinline|text此时我们的光标在inline后假设schema中定义的inline规则是不会继承前个节点的格式那么接下来如果我们输入内容例如1此时文本就变成了inline|1text。这个操作是符合直觉的然而当我们在上述的位置唤醒IME输入中文内容时这里的文本就变成了错误的内容。Copyinline中文|中文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层的不一致。Copyconst 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 correctedfor (let i 1; i children.length; i) {const node children[i];node node.remove();}// Guaranteed to have only one text childif (isDOMText(dom.firstChild)) {dom.firstChild.nodeValue props.children;}};而如果我们直接将leaf的React.memo以及useMemo移除这个问题自然是会消失然而这样就会导致编辑器的性能下降。因此我们就需要考虑尽可能检查到脏DOM的情况实际上如果是在input事件或者MutationObserver中处理输入的纯非受控情况也需要处理脏DOM的问题。那么我们可以明显的想到当行状态发生变更时我们就直接检查当前行的所有leaf节点然后对比文本内容如果存在不一致的情况则直接进行修正。如果直接使用querySelector的话显然不够优雅我们可以借助WeakMap来映射叶子状态到DOM结构以此来快速定位到需要的节点。然后在行节点的状态变更后在处理副作用的时候检查脏DOM节点并且由于我们的行状态也是immutable的因此也不需要担心性能问题。此时检查的执行是O(N)的算法而且检查的范围也会限制在发生rerender的行中具体检查节点的方法自然也跟上述onRef一致。Copyconst 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的最前方。Copyleaf rerender - line rerender - line layout effect - block layout effect此外这里的实现在首次渲染并不需要检查此时不会存在脏节点的情况因此初始化渲染的时候我们可以直接跳过检查。以这种策略来处理脏DOM的问题还可以避免部分其他可能存在的问题零宽字符文本的内容暂时先不处理如果再碰到类似的情况是需要额外的检查的。其实换个角度想这里的问题也可能是我们的选区策略是尽可能偏左侧的查找如果在这种情况将其校正到右侧节点可能也可以解决问题。不过因为在空行的情况下我们的末尾\n节点并不会渲染因此这样的策略目前并不能彻底解决问题而且这个处理方式也会使得编辑器的选区策略变得更加复杂。Copy[inline|][text] [inline][|text]这里还需要关注下React的Hooks调用时机在下面的例子中从真实DOM中得到onRef执行顺序是最前的因此在此时进行首次DOM检查是合理的。而后续的Child LayoutEffect就类似于行DOM检查在修正过后在Parent LayoutEffect中更新选区是符合调度时机方案。CopyChild onRefChild useLayoutEffectParent useLayoutEffectChild useEffectParent useEffectCopy// https://playcode.io/reactimport 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));returnforceUpdate({})}Update}export function App(props) {React.useEffect(() console.log(Parent useEffect));React.useLayoutEffect(() console.log(Parent useLayoutEffect));return ;}包装节点#关于包装节点的问题需要我们先聊一下这个模式的设计现在实现的富文本编辑器是没有块结构的因此实现任何具有嵌套的结构都是个复杂的问题。在这里我们原本就不会处理诸如表格类的嵌套结构但是例如blockquote这种wrapper级结构我们是需要处理的。类似的结构还有list但是list我们可以完全自己绘制但是blockquote这种结构是需要具体组合才可以的。然而如果仅仅是blockquote还好在inline节点上使用wrapper是更常见的实现例如a标签的包装在编辑器的实现模式中就是很常规的行为。具体来说在我们将文本分割为bold、italic等inline节点时会导致DOM节点被实际切割此时如果嵌套节点的话就会导致hover后下划线等效果出现切割。因此如果能够将其wrapper在同一个标签的话就不会出现这种问题。但是新的问题又来了如果仅仅是单个key来实现渲染时嵌套并不是什么复杂问题而同时存在多个需要wrapper的key则变成了令人费解的问题。如下面的例子中如果将34单独合并b外层再包裹a似乎是合理的但是将34先包裹a后再合并5的b也是合理的甚至有没有办法将67一并合并因为其都存在b标签。Copy1 2 3 4 5 6 7 8 9 0a a ab ab b bc b c c c思来想去我最终想到了个简单的实现对于需要wrapper的元素如果其合并list的key和value全部相同的话那么就作为同一个值来合并。那么这种情况下就变的简单了很多我们将其认为是一个组合值而不是单独的值在大部分场景下是足够的。Copy1 2 3 4 5 6 7 8 9 0a a ab ab b bc b c c c12 34 5 6 7 890不过话又说回来这种wrapper结构是比较特殊的场景下才会需要的在某些操作例如缩进这个行为中是无法判断究竟是要缩进引用块还是缩进其中的文字。这个问题在很多开源编辑器中都存在特别是扁平化的数据结构设计例如Quill编辑器。其实也就是在没有块结构的情况下对于类似的行为不好控制而整体缩进这件事配合list在大型文档中也是很合理的行为因此这部分实现还是要等我们的块结构编辑器实现才可以。当然如果数据结构本身支持嵌套模式例如Slate就可以实现。后续在wrap node实现的a标签来实现输入时又出现了上述类似inline-code的脏DOM问题。以下面的DOM结构来看看似并不会有什么问题然而当光标放置于超链接这三个字后唤醒IME输入中文时会发现输入“测试输入”这几个字会被放置于直属div下与a标签平级。Copy超链接文本Copy超链接测试输入文本在这种情况下我们先前实现的脏DOM检测就失效了因为检查脏DOM的实现是基于data-leaf实现的。此时浏览器的输入表现会导致我们无法正确检查到这部分内容除非直接拿data-node行节点来直接判断这样的实现自然不够好。说到这里先前我发现飞书文档的实现是a标签渲染的leaf而wrap的包装实现是使用的span直接处理的并且额外增加了样式来实现hover效果。直接使用span包裹就不会出现上述问题而内部的a标签虽然会导致同样的问题但是在leaf下可以触发脏DOM检查。Copy超链接测试输入文本因此就可以在先前的脏DOM检查基础上解决了问题而本质上类似的行为就是浏览器默认处理的结果不同的浏览器处理结果可能都不一样。目前看起来是浏览器认为a标签的结构应该是属于inline的实现也就是类似我们的inline-code实现理论上倒却是并没有什么问题由此我们需要自己来处理这些非受控的问题。实际上Quill本身也会出现这个问题同样也是脏DOM的处理。而slate并不会出现这个问题这里处理方案则是通过DOM规避了问题在a标签两端放置额外的nbsp节点以此来避免这个问题。当然还引入了额外的问题引入了新的节点目前看起来转移光标需要受控处理。Copy超链接测试输入文本浏览器兼容性#在后续浏览器的测试中重新出现了上述提到的a标签问题此时并不是由于包装节点引起的因此问题变得复杂了很多主要是各个浏览器的兼容性的问题。类似于行内代码块本质上还是浏览器IME非受控导致的DOM变更问题但是在浏览器表现差异很大下面是最小的DEMO结构。Copy在[:]后输入:非链接文本在上述示例的a标签位置的最后的位置上输入内容主流的浏览器的表现是有差异的甚至在不同版本的浏览器上表现还不一致:在Chrome中会在a标签的同级位置插入文本类型的节点效果类似于text内容。在Firefox中会在a标签内插入span类型的节点效果类似于text内容。在Safari中会将a标签和span标签交换位置然后在a标签上同级位置加入文本内容类似text。Copy超链接文本超链接文本超链接文本因此我们的脏DOM检查需要更细粒度地处理仅仅对比文本内容显然是不足以处理的我们还需要检查文本的内容节点结构是否准确。其实最开始我们是仅处理了Chrome下的情况最简单的办法就是在leaf节点下仅允许存在单个节点存在多个节点则说明是脏DOM。Copyfor (let i 1; i nodes.length; i) {const node nodes[i];node node.remove();}但是后来发现在编辑时会把Embed节点移除这里也就是因为我们错误地把组合的div节点当成了脏DOM因此这里就需要更细粒度地处理了。然后考虑检查节点的类型如果是文本的节点类型再移除那么就可以避免Embed节点被误删的问题。Copyfor (let i 1; i nodes.length; i) {const node nodes[i];isDOMText(node) node.remove();}虽然看起来是解决了问题然而在后续就发现了Firefox和Safari下的问题。先来看Firefox的情况这个节点并非文本类型的节点在脏DOM检查的时候就无法被移除掉这依然无法处理Firefox下的脏DOM问题因此我们需要进一步处理不同类型的节点。Copy//>Hello React.Color ButtonsetState(c c)}Rerender Button);}因此在上述的didPaintLineState中我们主要是classList添加类属性值即使是LeafState发生了变更React也不会重新设置类属性值因此这里我们还需要在didPaintLineState变更时删除非必要的类属性值。Copypublic 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);}}}