从零实现富文本编辑器#10-React视图层适配器的模式扩展
在编辑器最开始的架构设计上,我们就以MVC模式为基础,分别实现模型层、核心层、视图层的分层结构。在先前我们讨论的主要是模型层以及核心层的设计,即数据模型以及编辑器的核心交互逻辑,在这里我们以React为例,讨论其作为视图层的模式扩展设计。
- 开源地址: github.com/WindRunnerM…
- 在线编辑: windrunnermax.github.io/BlockKit/
- 项目笔记: github.com/WindRunnerM…
从零实现富文本编辑器系列文章
概述
多数编辑器实现了本身的视图层,而重新设计视图层需要面临渲染问题,诸如处理复杂的DOM更新、差量更新的成本,在业务上也无法复用组件生态,且存在新的学习成本。因此我们需要能够复用现有的视图层框架,例如React/Vue/Angular等,这就需要在最底层的架构上就实现可扩展视图层的核心模式。
复用现有的视图层框架也就意味着,在核心层设计上不感知任何类型的视图实现,针对诸如选区的实现也仅需要关注最基础的DOM操作。而这也就导致我们需要针对每种类型的框架都实现对应的视图层适配器,即使其本身并不会特别复杂,但也会是需要一定工作量的。
虽然独立设计视图层可以解决视图层适配成本问题,但相应的会增加维护成本以及包本身体积,因此在我们的编辑器设计上,我们还是选择复用现有的视图层框架。然而,即使复用视图层框架,适配富文本编辑器也并非是一件简单的事情,需要关注的点包括但不限于以下几部分:
- 视图层初始状态渲染: 生命周期同步、状态管理、渲染模式、
DOM映射状态等。 - 内容编辑的增量更新: 不可变对象、增量渲染、
Key值维护等。 - 渲染事件与节点检查: 脏
DOM检查、选区更新、渲染Hook等。 - 编辑节点的组件预设: 零宽字符、
Embed节点、Void节点等。 - 非编辑节点内容渲染: 占位节点、只读模式、插件模式、外部节点挂载等。
此外,基于React实现视图层适配器,相当于重新深入学习React的渲染机制。例如使用memo来避免不必要的重渲染、使用useLayoutEffect来控制DOM渲染更新时机、严格控制父子组件事件流以及副作用执行顺序等、处理脏DOM的受控更新等。
除了这些与React相关的实现,还有一些样式有关的问题需要注意。例如在HTML默认连续的空白字符,包括空格、制表符和换行符等,在渲染时会被合并为一个空格,这样就会导致输入的多个空格在渲染时只显示一个空格。
为了解决这个问题,有些时候我们可以直接使用HTML实体 来表示这些字符来避免渲染合并,然而我们其实可以用更简单的方式来处理,即使用CSS来控制空白符的渲染。下面的几个样式分别控制了不同的渲染行为:
-
whiteSpace: "pre-wrap": 表示在渲染时保留换行符以及连续的空白字符,但是会对长单词进行换行。 -
wordBreak: "break-word": 可以防止长单词或URL溢出容器,会在单词内部分行,对中英文混合内容特别有用。 -
overflowWrap: "break-word": 同样是处理溢出文本的换行,但自适应宽度时不考虑长单词折断,属于当前标准写法。
<div
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflowWrap: "break-word",
}}
></div>
这其中word-break是早期WebKit浏览器的实现,给word-break添加了一个非标准的属性值break-word。这里存在问题是,CSS在布局计算中,有一个环节叫Intrinsic Size即固有尺寸计算,此时如果给容器设置width: min-content时,容器就坍塌成了一个字母的宽度。
<style>
.wrapper { background: #eee; padding: 10px; width: min-content; border: 1px solid #999; }
.standard { overflow-wrap: break-word; }
.legacy { word-break: break-word; }
</style>
<h3>overflow-wrap: break-word</h3>
<div class="wrapper standard">
Supercalifragilistic
</div>
<h3>word-break: break-word</h3>
<div class="wrapper legacy">
Supercalifragilistic
</div>
生命周期同步
从最开始的编辑器设计中,我们就已经将核心层和视图层分离,并且为了更灵活地调度编辑器,我们将编辑器实例化的时机交予用户来控制。这种情况下若是暴露出Editor的ref/useImperativeHandle接口实现,就可以不必要设置null的状态,通常情况下的调度方式类似于下面的实现:
const Editor = () => {
const editor = useMemo(() => new Editor(), []);
return <Editable editor={editor} />;
}
此外,我们需要在SSR渲染或者生成SSG静态页面时,编辑器的渲染能够正常工作。那么通常来说,我们需要独立控制编辑器对于DOM操作的时机,实例化编辑器时不会进行任何DOM操作,而只有在DOM状态准备好后,才会进行DOM操作。那么最常见的时机,就是组件渲染的副作用Hook。
const Editable: React.FC = (props) => {
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const el = ref.current;
el && editor.mount(el);
}, [editor]);
return <div ref={ref}></div>;
}
然而在这种情况下,编辑器实例化的生命周期和Editable组件的生命周期必须要同步,这样才能保证编辑器的状态和视图层的状态一致。例如下面的例子中,Editable组件的生命周期与Editor实例的生命周期不一致,会导致视图所有的插件卸载。
const Editor = () => {
const editor = useMemo(() => new Editor(), []);
const [key, setKey] = useState(0);
return (
<Fragment>
<button onClick={() => setKey((k) => k + 1)}>切换</button>
{key % 2 ? <div><Editable editor={editor} /></div> : <Editable editor={editor} />}
</Fragment>
);
}
上面的例子中,若是Editable组件层级一致其实是没问题的,即使存在条件分支React也会直接复用,但层级不一致的话会导致Editable组件级别的卸载和重新挂载。当然本质上就是组件生命周期导致的问题,因此可以推断出若是同一层级的组件,设置为不同的key值也会触发同样的效果。
const Editor = () => {
const editor = useMemo(() => new Editor(), []);
const [key, setKey] = useState(0);
return (
<Fragment>
<button onClick={() => setKey((k) => k + 1)}>切换</button>
<Editable key={key} editor={editor} />
</Fragment>
);
}
这里的核心原因实际上是,Editable组件的useEffect钩子会在组件卸载时触发清理函数,而编辑器实例化的时候是在最外层执行的。那么编辑器实例化和卸载的时机是无法对齐的,卸载后所有的编辑器功能都会销毁,只剩下纯文本的内容DOM结构。
useLayoutEffect(() => {
const el = ref.current;
el && editor.mount(el);
return () => {
editor.destroy();
};
}, [editor]);
此外,实例化独立的编辑器后,同样也不能使用多个Editable组件来实现编辑器的功能,也就是说每个编辑器实例都必须要对应一个Editable组件,多个编辑器组件的调度会导致整个编辑器状态混乱。
const Editor = () => {
const editor = useMemo(() => new Editor(), []);
return (
<Fragment>
<Editable editor={editor} />
<Editable editor={editor} />
</Fragment>
);
}
因此为了避免类似的问题,我们是应该以最开始的实现方式一致,也就是说整个编辑器组件都应该是合并为独立组件的。那么类似于上述出现问题的组件应该有如下的形式,将实例化的编辑器和Editable组件合并为独立组件,这样就能够将生命周期完全对齐而非交给用户侧实现。
const Editor = () => {
const editor = useMemo(() => new Editor(), []);
return <Editable editor={editor} />;
};
const App = () => {
const [key, setKey] = useState(0);
return (
<Fragment>
<button onClick={() => setKey((k) => k + 1)}>切换</button>
{key % 2 ? <div><Editor /></div> : <Editor />}
</Fragment>
);
}
实际上我们完全可以将实例化编辑器这个行为封装到Editable组件中的,但是为了更灵活地调度编辑器,将实例化放置于外层更合适。此外,从上述的Effect中实际上可以看出,与onMount实例挂载对齐的生命周期应该是Unmount,因此这里更应该调度编辑器卸载DOM的事件。
然而若是将相关的事件粒度拆得更细,即在插件中同样需要定义为onMount和onUnmount的生命周期,这样就能更好地控制相关处理时机问题。然而这种情况下,对于用户来说就需要有更复杂的插件逻辑,与DOM相关的事件绑定、卸载等都需要用户明确在哪个生命周期调用。
即使分离了更细粒度的生命周期,编辑器的卸载时机仍然是需要主动调用的,不主动调用则会存在可能的内存泄漏问题。在插件的实现中用户是可能直接向DOM绑定事件的,这些事件在编辑器卸载时是需要主动解绑的,将相关的约定都学习一边还是存在一些心智负担。
实际上如果能实现更细粒度的生命周期,对于整个编辑器的实现是更高效的,毕竟若是避免实例化编辑器则可以减少不必要的状态变更和事件绑定。因此这里还是折中实现,若是用户需要避免编辑器的卸载事件,可以通过preventDestroy参数来实现,用户在编辑器实例化生命周期结束主动卸载。
export const Editable: React.FC<{
/**
* 避免编辑器主动销毁
* - 谨慎使用, 生命周期结束必须销毁编辑器
* - 注意保持值不可变, 否则会导致编辑器多次挂载
*/
preventDestroy?: boolean;
}> = props => {
const { preventDestroy } = props;
const { editor } = useEditorStatic();
useLayoutEffect(() => {
const el = ref.current;
el && editor.mount(el);
return () => {
editor.unmount();
!preventDestroy && editor.destroy();
};
}, [editor, preventDestroy]);
}
状态管理
对于状态管理我们需要从头开始,在这里我们先来实现全量更新模式,但是要在架构设计上留好增量的更新模式。那么思考核心层与视图层中的通信与状态管理,使用Context/Redux/Mobx是否可以避免自行维护各个状态对象,也可以达到局部刷新而不是刷新整个页面的效果。
深入思考一下似乎并不可行,以Context管理状态为例,即使有immer.js似乎也做不到局部刷新,因为整个delta的数据结构不能够达到非常完整的与props对应的效果。诚然我们可以根据op & attributes作为props再在组件内部做数据转换,但是这样似乎并不能避免维护一个状态对象。
由此最基本的是应该要维护一个LineState对象,每个op可能与前一个或者后一个有状态关联,以及行属性需要处理,这样一个基础的LineState对象是必不可少的。再加上是要做插件化的,那么给予react组件的props应该都实际上可以隐藏在插件里边处理。
如果我们使用immer的话,似乎只需要保证插件给予的参数是不变的即可,但是同样的每一个LineState都会重新调用一遍插件化的render方法。这样确实造成了一些浪费,即使能够保证数据不可变即不会再发生re-render,但是如果在插件中解构了这个对象或者做了一些处理,那么又会触发更新。
那么既然LineState对象不可避免,如果再在这上边抽象出一层BlockState来管理LineState。再通过ContentChange事件作为bridge,以及这种一层管理一层的方式,精确地更新每一行,减少性能损耗,甚至于精确的得知究竟是哪几个op更新了,做到完全精准更新也不是不可能。
由此回到最初实现State模块更新文档内容时,我们是直接重建了所有的LineState以及LeafState对象,然后在React视图层的BlockModel中监听了OnContentChange事件,以此来将BlockState的更新应用到视图层。
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差异进而全量更新文档视图,这样的性能开销通常是不可接受的。
不过,上述的监听OnContentChange事件来更新视图的方式,是完全没有问题的,这也是连接编辑器核心层和视图层的重要部分。React的视图更新需要setState来触发状态变更,那么从核心层的OnContentChange事件中触发更新,就可以将状态的更新应用到视图层。
const BlockView: FC = props => {
const [lines, setLines] = useState(() => state.getLines());
const onContentChange = useMemoFn(() => {
setLines(state.getLines());
});
}
这里其实还有个有趣的事情,假设我们在核心层中同步地多次触发了状态更新,则会导致多次视图层更新。这是个比较常见的事情,当一个事件作用到状态模型时,可能调用了若干指令,产生了多个Op,若每个Op的应用都进行一次视图同步,代价会十分高昂。
不过在React中,这件事的表现并没有那么糟糕,因为React本身会合并起来异步更新视图,但我们仍然可以避免多次无效的更新,可以减少React设置状态这部分的开销。具体的逻辑是,同步更新状态时,通过一个状态变量守卫住React状态更新,直到异步操作时才更新视图层。
在下面的这段代码中,可以举个例子同步等待刷新的队列为||||||||,每一个|都代表着一次状态更新。进入更新步骤即首个|后, 异步队列行为等待, 同步的队列由于!flushing全部被守卫。主线程执行完毕后, 异步队列开始执行, 此时拿到的是最新数据, 以此批量重新渲染。
/**
* 数据同步变更, 异步批量绘制变更
*/
const onContentChange = useMemoFn(() => {
if (flushing.current) return void 0;
flushing.current = true;
Promise.resolve().then(() => {
flushing.current = false;
setLines(state.getLines());
});
});
回到更新这件事本身,即使全部重建了LineState以及LeafState,也需要尽可能找到其原始的LineState以便于复用其key值,避免整个行的re-mount。当然即使复用了key值,因为重建了State实例,React也会继续后边的re-render流程。
这样的全量更新自然是存在性能浪费的,特别是我们的数据模型都是基于原子化Op的,不实现增量更新本身也并不合理。因此我们需要在核心层实现增量更新的逻辑,例如Immutable的状态序列对象、Key值的复用等,视图层也需要借助memo等来避免无效渲染。
渲染模式
我们希望实现的是视图层分离的通信架构,相当于所有的渲染都采用React,类似于Slate的架构设计。而Facebook在推出的Draft富文本引擎中,是使用纯React来渲染的,然后当前Draft已经不再维护,转而推出了Lexical。
后来我发现Lexical虽然是Facebook推出的,但是却没用React进行渲染,从DOM节点上就可以看出来是没有Fiber的,因此可以确定普通的节点并不是React渲染的。诚然使用React可能存在性能问题,而且由于非受控模式下可能会造成crash,但是能够直接复用视图层还是有价值的。
在Lexical的README中可以看到是可以支持React的,那么这里的支持实际上仅有DecoratorNode可以用React来渲染,例如在Playground中加入ExcaliDraw画板的组件的话,就可以发现svg外的DOM节点是React渲染的,可以发现React组件是额外挂载上去的。
在下面的例子中,可以看到p标签的属性中是lexical注入的相关属性,而子级的span标签则是lexical装饰器节点,用于渲染React组件,这部分都并非是React渲染的。而明显的,button标签则明显是React渲染的,因为其有Fiber相关属性,这也验证了分离渲染模式。
<!--
__lexicalKey_gqfof: "4"
__lexicalLineBreak: br
__lexicalTextContent: "\n\n\n\n"
-->
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<!--
__lexicalKey_gqfof: "5"
_reactListening2aunuiokal: true
-->
<span class="editor-image" data-lexical-decorator="true" contenteditable="false">
<!--
__reactFiber$zski0k5fvkf: { ... }
__reactProps$zski0k5fvkf: { ... }
-->
<button class="excalidraw-button "><!-- ... --></button>
</span>
</p>
也就是说,仅有Void/Embed类型的节点才会被React渲染,其他的内容都是普通的DOM结构。这怎么说呢,就有种文艺复兴的感觉,如果使用Quill的时候需要将React结合的话,通常就需要使用ReactDOM.render的方式来挂载React节点。
在Lexical还有一点是,需要协调的函数都需要用$符号开头,这也有点PHP的文艺复兴。Facebook实现的框架就比较喜欢规定一些约定性的内容,例如React的Hooks函数都需要以use开头,这本身也算是一种心智负担。
那么有趣的事,在Lexical中我是没有看到使用ReactDOM.render的方法,所以我就难以理解这里是如何将React节点渲染到DOM上的。于是在useDecorators中找到了Lexical实际上是以createPortal的方法来渲染的。
// https://react-lab.skyone.host/
const Context = React.createContext(1);
const Customer = () => <span>{React.useContext(Context)}</span>;
const App = () => {
const ref1 = React.useRef<HTMLDivElement>(null);
const ref2 = React.useRef<HTMLDivElement>(null);
const [decorated, setDecorated] = React.useState<React.ReactPortal | null>(null);
React.useEffect(() => {
const div1 = ref1.current!;
setDecorated(ReactDOM.createPortal(<Customer />, div1));
const div2 = ref2.current!;
ReactDOM.render(<Customer />, div2);
}, []);
return (
<Context.Provider value={2}>
{decorated}
<div ref={ref1}></div>
<div ref={ref2}></div>
<Customer></Customer>
</Context.Provider>
);
};
ReactDOM.render(<App />, document.getElementById("root"));
使用这种方式实际与ReactDOM.render效果基本一致,但是createPortal是可以自由使用Context的,且在React树渲染的位置是用户挂载的位置。实际上讨论这部分的主要原因是,我们在视图层的渲染并非需要严格使用框架来渲染,分离渲染模式也是能够兼容性能和生态的处理方式。
DOM 映射状态
在实现MVC架构时,理论上控制器层以及视图层都是独立的,控制器不会感知视图层的状态。但是在我们具体实现过程中,视图层的DOM是需要被控制器层处理的,例如事件绑定、选区控制、剪贴板操作等等,那么如何让控制器层能够操作相关的DOM就是个需要处理的问题。
理论上而言,我们在DOM上的设计是比较严格的,即data-block节点属块级节点,而data-node节点属行级节点,data-leaf节点则属行内节点。block节点下只能包含node节点,而node节点下只能包含leaf节点。
<div contenteditable style="outline: none" data-block>
<div data-node><span data-leaf><span>123</span></span></div>
<div data-node>
<span data-leaf><span contenteditable="false">321</span></span>
</div>
<div data-node><span data-leaf><span>123</span></span></div>
</div>
那么在这种情况下,我们是可以在控制器层通过遍历DOM节点来获取相关的状态的,或者诸如querySelectorAll、createTreeWalker等方法来获取相关的节点。但是这样明显是会存在诸多无效的遍历操作,因此我们需要考虑是否有更高效的方式来获取相关的节点。
在React中我们可以通过ref来获取相关的节点,那么如何将DOM节点对象映射到相关编辑器对象上。我们此时存在多个状态对象,因此可以将相关的对象完整一一映射到对应的主级DOM结构上,而且Js中我们可以使用WeakMap来维护弱引用关系。
export class Model {
/** DOM TO STATE */
protected DOM_MODEL: WeakMap<HTMLElement, BlockState | LineState | LeafState>;
/** STATE TO DOM */
protected MODEL_DOM: WeakMap<BlockState | LineState | LeafState, HTMLElement>;
/**
* 映射 DOM - LeafState
* @param node
* @param state
*/
public setLeafModel(node: HTMLSpanElement, state: LeafState) {
this.DOM_MODEL.set(node, state);
this.MODEL_DOM.set(state, node);
}
}
在React组件的ref回调函数中,我们需要通过setLeafModel方法来将DOM节点映射到LeafState上。在React中相关执行时机为ref -> layout effect -> effect,且需要保证引用不变, 否则会导致回调在re-render时被多次调用null/span状态。
export const Leaf: FC<LeafProps> = props => {
/**
* 处理 ref 回调
*/
const onRef = useMemoFn((dom: HTMLSpanElement | null) => {
dom && editor.model.setLeafModel(dom, lineState);
});
return (
<span ref={onRef} {...{ [LEAF_KEY]: true }}>
{props.children}
</span>
);
};
总结
在先前我们主要讨论了模型层以及核心层的设计,即数据模型以及编辑器的核心交互逻辑,也包括了部分DOM相关处理的基础实现。还重点讲述了选区的状态同步、输入的状态同步,并且处理了相关实现在浏览器中的兼容问题。
在当前部分,我们主要讨论了视图层的适配器设计,主要是全量的视图初始化渲染,包括生命周期同步、状态管理、渲染模式、DOM映射状态等。接下来我们需要处理变更的增量更新,这属于性能方面的优化,我们需要考虑如何最小化DOM以及Op操作,以及在React中实现增量渲染的方式。