跳到主要内容

设计富文本编辑器

需求分析

核心需求

  1. 基础编辑能力:文本输入、删除、选中、复制粘贴、撤销重做
  2. 富文本格式:加粗、斜体、下划线、标题、列表、引用、代码块
  3. 复杂 Block:图片、视频、表格、分割线等非文本内容
  4. 工具栏交互:固定工具栏(Toolbar)、浮动菜单(Bubble Menu)、斜杠命令(Slash Command)
  5. 序列化与反序列化:支持 HTML、JSON、Markdown 等格式的导入导出
  6. 插件化架构:功能可拆分、可扩展,第三方可开发自定义插件
  7. 协同编辑:多人同时编辑同一篇文档,实时同步

非功能需求

  • 性能:大文档(万级 Block)流畅编辑,输入延迟 < 16ms
  • 兼容性:主流浏览器支持,输入法(IME)兼容
  • 可扩展性:新增 Block 类型、Mark 类型零入侵
  • 可测试性:数据模型与视图分离,核心逻辑可单元测试

富文本编辑器发展史

富文本编辑器按技术架构可分为三个阶段(L0/L1/L2),理解这个演进有助于面试时展示技术深度:

等级代表框架核心思路优点缺点
L0早期编辑器直接使用 contentEditable + document.execCommand实现简单、浏览器原生支持行为不一致、不可控、已废弃
L1Quill、Draft.js、Slate.js自建数据模型,拦截用户输入,自己控制 DOM 更新行为可预测、跨浏览器一致仍依赖 contentEditable 做输入
L2Google Docs、腾讯文档完全自绘(Canvas / 自定义排版引擎),不依赖 contentEditable完全可控、支持复杂排版开发成本极高、需自行实现光标/选区/输入法
面试要点

大多数开源编辑器处于 L1 阶段。面试中被问到"如何设计富文本编辑器",通常指的是 L1 架构。L2 是大厂在线文档团队的技术方向,了解即可。

ContentEditable 原理与问题

浏览器提供了 contentEditable 属性,将任意 HTML 元素变为可编辑区域。配合已废弃的 document.execCommand API 可以实现基本的富文本操作:

L0 编辑器示例
// execCommand 已被废弃,不推荐使用
document.execCommand('bold'); // 加粗
document.execCommand('italic'); // 斜体
document.execCommand('insertHTML', false, '&lt;hr&gt;'); // 插入 HTML

ContentEditable 的核心问题

  1. 行为不一致:不同浏览器按 Enter 生成的标签不同(Chrome 生成 &lt;div&gt;,Firefox 生成 &lt;br&gt;,Safari 生成 &lt;div&gt;
  2. DOM 不可预测:粘贴、拖拽、输入法等操作会产生非预期的 DOM 结构
  3. 无数据模型:DOM 即数据,无法做序列化、协同编辑、撤销重做栈
  4. execCommand 已废弃:浏览器不再维护,无法扩展新命令
注意

L1 编辑器仍然使用 contentEditable 作为输入层(接收键盘、输入法事件),但不再信任浏览器生成的 DOM。编辑器会拦截事件,在自己的数据模型上操作,然后按自己的规则重新渲染 DOM。


整体架构

整体遵循 单向数据流

  1. 用户在 ContentEditable 区域输入
  2. 事件处理层拦截浏览器事件
  3. 转化为编辑器命令(Command)
  4. 命令操作数据模型(Document Model)
  5. 模型变更触发视图重新渲染
  6. 插件系统在各环节提供扩展点

核心模块设计

1. 数据模型(Document Model)

数据模型是编辑器的核心,定义了文档的结构化表示。主流编辑器的数据模型可以抽象为四层结构:

TypeScript 类型定义

types/model.ts
// ===== 基础类型 =====

/** Mark:行内装饰(不改变文档结构,只改变样式) */
interface Mark {
type: string; // 'bold' | 'italic' | 'code' | 'link' ...
attrs?: Record<string, unknown>;
}

/** Text Node:最小文本单元 */
interface TextNode {
text: string;
marks?: Mark[];
}

/** Element Node:包含子节点的结构节点 */
interface ElementNode {
type: string; // 'paragraph' | 'heading' | 'blockquote' | 'image' ...
attrs?: Record<string, unknown>;
children: EditorNode[];
}

/** 节点联合类型 */
type EditorNode = ElementNode | TextNode;

/** 文档根节点 */
interface EditorDocument {
type: 'document';
children: ElementNode[];
version: number; // 用于协同编辑的版本号
}

主流框架的数据模型对比

Slate.js 采用嵌套 JSON 树,灵活度高:

Slate.js 数据模型
const slateDocument = {
children: [
{
type: 'paragraph',
children: [
{ text: '这是一段' },
{ text: '加粗', bold: true }, // Mark 扁平化到 Text 上
{ text: '的文本' },
],
},
{
type: 'heading',
level: 2,
children: [{ text: '二级标题' }],
},
],
};

2. 选区与光标(Selection & Range)

编辑器需要精确控制用户的选区(Selection)和光标(Cursor)。浏览器提供了 Selection APIRange API,编辑器在此基础上做抽象。

types/selection.ts
/** 路径:从根节点到目标节点的索引数组 */
type Path = number[];

/** 位置点:路径 + 偏移量 */
interface Point {
path: Path; // 例如 [0, 1] 表示第 1 个 Block 的第 2 个子节点
offset: number; // 文本内的字符偏移
}

/** 编辑器选区 */
interface EditorSelection {
anchor: Point; // 选区起点(用户按下鼠标的位置)
focus: Point; // 选区终点(用户松开鼠标的位置)
}

/** 判断选区是否折叠(光标模式) */
function isCollapsed(selection: EditorSelection): boolean {
return (
selection.anchor.offset === selection.focus.offset &&
selection.anchor.path.every((v, i) => v === selection.focus.path[i])
);
}

编辑器选区与浏览器选区的映射

selection/bridge.ts
/** 将编辑器选区同步到浏览器 DOM */
function syncSelectionToDOM(
editor: Editor,
selection: EditorSelection
): void {
const domSelection = window.getSelection();
if (!domSelection) return;

// 将编辑器 Path + Offset 映射为 DOM Node + Offset
const anchorDOM = findDOMPoint(editor, selection.anchor);
const focusDOM = findDOMPoint(editor, selection.focus);

const range = document.createRange();
range.setStart(anchorDOM.node, anchorDOM.offset);
range.setEnd(focusDOM.node, focusDOM.offset);

domSelection.removeAllRanges();
domSelection.addRange(range);
}

/** 将浏览器 DOM 选区映射回编辑器选区 */
function syncSelectionFromDOM(editor: Editor): EditorSelection | null {
const domSelection = window.getSelection();
if (!domSelection || domSelection.rangeCount === 0) return null;

const { anchorNode, anchorOffset, focusNode, focusOffset } = domSelection;
if (!anchorNode || !focusNode) return null;

// 将 DOM Node + Offset 映射回编辑器 Path + Offset
const anchor = findEditorPoint(editor, anchorNode, anchorOffset);
const focus = findEditorPoint(editor, focusNode, focusOffset);

if (!anchor || !focus) return null;
return { anchor, focus };
}

3. 命令系统(Command System)

命令系统是编辑器操作的唯一入口,所有对数据模型的修改都必须通过命令完成。这是典型的 命令模式,便于实现撤销重做和插件拦截。

commands/command.ts
/** 命令类型 */
interface Command<T = unknown> {
type: string;
payload?: T;
}

/** 命令处理器 */
type CommandHandler<T = unknown> = (
editor: Editor,
command: Command<T>
) => boolean; // 返回 true 表示命令已处理

/** 命令注册表 */
class CommandRegistry {
private handlers = new Map<string, CommandHandler[]>();

/** 注册命令处理器 */
register<T>(type: string, handler: CommandHandler<T>): () => void {
const list = this.handlers.get(type) ?? [];
list.push(handler as CommandHandler);
this.handlers.set(type, list);

// 返回取消注册函数
return () => {
const idx = list.indexOf(handler as CommandHandler);
if (idx >= 0) list.splice(idx, 1);
};
}

/** 分发命令(后注册的优先处理) */
dispatch<T>(editor: Editor, command: Command<T>): boolean {
const list = this.handlers.get(command.type) ?? [];
for (let i = list.length - 1; i >= 0; i--) {
if (list[i](editor, command)) return true;
}
return false;
}
}

撤销重做(Undo/Redo)

撤销重做基于操作记录栈,每次命令执行时记录逆操作(inverse operation):

history/history.ts
interface Operation {
type: string;
path: Path;
[key: string]: unknown;
}

class HistoryManager {
private undoStack: Operation[][] = [];
private redoStack: Operation[][] = [];
private batch: Operation[] = []; // 当前批次(一次用户操作可能包含多个 Operation)

/** 记录操作 */
record(op: Operation): void {
this.batch.push(op);
}

/** 提交当前批次 */
commit(): void {
if (this.batch.length === 0) return;
this.undoStack.push(this.batch);
this.batch = [];
this.redoStack = []; // 新操作清空 redo 栈
}

/** 撤销 */
undo(editor: Editor): void {
const ops = this.undoStack.pop();
if (!ops) return;

// 逆序应用逆操作
const inverseOps = ops.map(op => invertOperation(op)).reverse();
inverseOps.forEach(op => applyOperation(editor, op));
this.redoStack.push(ops);
}

/** 重做 */
redo(editor: Editor): void {
const ops = this.redoStack.pop();
if (!ops) return;

ops.forEach(op => applyOperation(editor, op));
this.undoStack.push(ops);
}
}

/** 生成逆操作 */
function invertOperation(op: Operation): Operation {
switch (op.type) {
case 'insert_text':
return { ...op, type: 'remove_text' };
case 'remove_text':
return { ...op, type: 'insert_text' };
case 'insert_node':
return { ...op, type: 'remove_node' };
case 'remove_node':
return { ...op, type: 'insert_node' };
case 'set_node':
return { ...op, type: 'set_node', properties: op.newProperties, newProperties: op.properties };
default:
return op;
}
}

4. Schema 约束

Schema 定义了文档的合法结构,类似于数据库的表结构定义,是 ProseMirror 的核心设计理念之一:

schema/schema.ts
interface NodeSpec {
/** 节点内容约束(类似正则) */
content?: string; // 例如 'paragraph+' 表示至少一个段落
/** 允许的 Mark 类型 */
marks?: string; // 例如 'bold italic' 或 '_' 表示全部允许
/** 是否为行内节点 */
inline?: boolean;
/** 是否为原子节点(不可编辑内部) */
atom?: boolean;
/** 节点属性定义 */
attrs?: Record<string, { default?: unknown }>;
/** 从 DOM 解析 */
parseDOM?: Array<{
tag?: string;
style?: string;
getAttrs?: (dom: HTMLElement) => Record<string, unknown> | false;
}>;
/** 渲染为 DOM */
toDOM?: (node: ElementNode) => unknown[];
}

interface MarkSpec {
/** 是否跨节点 */
spanning?: boolean;
attrs?: Record<string, { default?: unknown }>;
parseDOM?: Array<{ tag?: string; style?: string }>;
toDOM?: (mark: Mark) => unknown[];
}

/** Schema 定义 */
interface SchemaSpec {
nodes: Record<string, NodeSpec>;
marks: Record<string, MarkSpec>;
}

// 示例:定义一个基础 Schema
const basicSchema: SchemaSpec = {
nodes: {
doc: { content: 'block+' },
paragraph: { content: 'inline*', marks: '_', group: 'block' } as NodeSpec & { group: string },
heading: {
content: 'inline*',
marks: '_',
attrs: { level: { default: 1 } },
},
image: {
inline: true,
atom: true, // 原子节点:光标不能进入内部
attrs: {
src: { default: '' },
alt: { default: '' },
width: { default: null },
},
},
text: { inline: true },
},
marks: {
bold: {
parseDOM: [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }],
toDOM: () => ['strong', 0],
},
italic: {
parseDOM: [{ tag: 'em' }, { tag: 'i' }],
toDOM: () => ['em', 0],
},
link: {
attrs: { href: { default: '' }, target: { default: '_blank' } },
parseDOM: [{ tag: 'a[href]' }],
toDOM: (mark: Mark) => ['a', mark.attrs, 0],
},
},
};
补充

Schema 的核心价值在于约束。例如,你可以定义 heading 不允许包含 imagecode_block 内不允许任何 Mark。这样在操作数据模型时,Schema 会自动校验并修复不合法的文档结构,这被称为 Normalization


关键技术实现

1. 插件架构

插件化是编辑器可扩展性的基础。一个好的插件系统应该允许插件在多个维度扩展编辑器:

plugins/plugin.ts
/** 插件接口 */
interface EditorPlugin {
/** 插件名称 */
name: string;

/** 扩展 Schema(增加新的节点/Mark 类型) */
schema?: Partial<SchemaSpec>;

/** 注册命令 */
commands?: (editor: Editor) => Record<string, CommandHandler>;

/** 注册快捷键 */
keyBindings?: Record<string, (editor: Editor) => boolean>;

/** 自定义节点渲染组件 */
renderNode?: (
node: ElementNode,
children: React.ReactNode,
attrs: Record<string, unknown>
) => React.ReactElement | null;

/** 输入规则(自动转换,如 ## 自动变标题) */
inputRules?: InputRule[];

/** 粘贴处理 */
onPaste?: (editor: Editor, event: ClipboardEvent) => boolean;

/** 编辑器创建时调用 */
onCreate?: (editor: Editor) => void;

/** 编辑器销毁时调用 */
onDestroy?: () => void;
}

/** 输入规则:基于正则匹配自动转换内容 */
interface InputRule {
/** 匹配正则(匹配输入文本末尾) */
pattern: RegExp;
/** 处理函数 */
handler: (
editor: Editor,
match: RegExpMatchArray,
start: number,
end: number
) => boolean;
}

插件示例:Markdown 快捷输入

plugins/markdown-shortcuts.ts
const markdownShortcutsPlugin: EditorPlugin = {
name: 'markdown-shortcuts',

inputRules: [
// ## + 空格 → 二级标题
{
pattern: /^##\s$/,
handler: (editor, _match, start, end) => {
editor.commands.dispatch(editor, {
type: 'SET_BLOCK_TYPE',
payload: { type: 'heading', attrs: { level: 2 } },
});
// 删除已输入的 ## 和空格
editor.commands.dispatch(editor, {
type: 'DELETE_RANGE',
payload: { start, end },
});
return true;
},
},
// - + 空格 → 无序列表
{
pattern: /^[-*]\s$/,
handler: (editor, _match, start, end) => {
editor.commands.dispatch(editor, {
type: 'SET_BLOCK_TYPE',
payload: { type: 'bullet_list' },
});
editor.commands.dispatch(editor, {
type: 'DELETE_RANGE',
payload: { start, end },
});
return true;
},
},
// > + 空格 → 引用
{
pattern: /^>\s$/,
handler: (editor, _match, start, end) => {
editor.commands.dispatch(editor, {
type: 'WRAP_IN',
payload: { type: 'blockquote' },
});
editor.commands.dispatch(editor, {
type: 'DELETE_RANGE',
payload: { start, end },
});
return true;
},
},
],
};

2. 工具栏设计

编辑器通常有三种工具栏形态:

类型说明典型场景
固定工具栏(Toolbar)固定在编辑器顶部或页面顶部传统编辑器(Word 风格)
浮动菜单(Bubble Menu)选中文本后在选区附近弹出Notion、Medium
斜杠命令(Slash Command)输入 / 弹出命令面板Notion、飞书文档
toolbar/bubble-menu.tsx
import { useEffect, useState, useRef } from 'react';

interface BubbleMenuProps {
editor: Editor;
}

function BubbleMenu({ editor }: BubbleMenuProps) {
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const handleSelectionChange = () => {
const { selection } = editor;
if (!selection || isCollapsed(selection)) {
setPosition(null);
return;
}

// 获取浏览器选区的位置,计算菜单坐标
const domSelection = window.getSelection();
if (!domSelection || domSelection.rangeCount === 0) return;
const range = domSelection.getRangeAt(0);
const rect = range.getBoundingClientRect();

setPosition({
top: rect.top - 48 + window.scrollY,
left: rect.left + rect.width / 2,
});
};

editor.on('selectionChange', handleSelectionChange);
return () => editor.off('selectionChange', handleSelectionChange);
}, [editor]);

if (!position) return null;

return (
<div
ref={menuRef}
style={{
position: 'absolute',
top: position.top,
left: position.left,
transform: 'translateX(-50%)',
}}
>
<button onClick={() => editor.commands.toggleBold()}>B</button>
<button onClick={() => editor.commands.toggleItalic()}>I</button>
<button onClick={() => editor.commands.toggleLink()}>Link</button>
</div>
);
}

Slash Command 实现思路

toolbar/slash-command.tsx
interface SlashMenuItem {
title: string;
description: string;
icon: string;
command: (editor: Editor) => void;
}

const SLASH_MENU_ITEMS: SlashMenuItem[] = [
{
title: '标题 1',
description: '大标题',
icon: 'H1',
command: (editor) => editor.commands.setHeading({ level: 1 }),
},
{
title: '标题 2',
description: '中标题',
icon: 'H2',
command: (editor) => editor.commands.setHeading({ level: 2 }),
},
{
title: '图片',
description: '插入图片',
icon: 'IMG',
command: (editor) => editor.commands.insertImage(),
},
{
title: '代码块',
description: '插入代码块',
icon: 'CODE',
command: (editor) => editor.commands.setCodeBlock(),
},
];

/** 监听 / 输入,弹出命令面板 */
function useSlashCommand(editor: Editor) {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);

useEffect(() => {
const handleInput = (text: string) => {
if (text === '/') {
setIsOpen(true);
setQuery('');
} else if (isOpen) {
setQuery(prev => prev + text);
}
};

editor.on('textInput', handleInput);
return () => editor.off('textInput', handleInput);
}, [editor, isOpen]);

// 根据 query 过滤菜单项
const filteredItems = SLASH_MENU_ITEMS.filter(item =>
item.title.toLowerCase().includes(query.toLowerCase())
);

return { isOpen, filteredItems, setIsOpen };
}

3. 序列化与反序列化

编辑器需要将内部数据模型转换为多种外部格式,也需要解析外部格式导入。这是一个经典的 适配器模式 应用。

serializers/html.ts
/** 将编辑器节点序列化为 HTML */
function serializeToHTML(nodes: EditorNode[]): string {
return nodes.map(node => {
if ('text' in node) {
let html = escapeHTML(node.text);
// 按 Mark 包裹标签
(node.marks ?? []).forEach(mark => {
switch (mark.type) {
case 'bold':
html = `<strong>${html}</strong>`;
break;
case 'italic':
html = `<em>${html}</em>`;
break;
case 'code':
html = `<code>${html}</code>`;
break;
case 'link':
html = `<a href="${mark.attrs?.href}">${html}</a>`;
break;
}
});
return html;
}

const element = node as ElementNode;
const childrenHTML = serializeToHTML(element.children);

switch (element.type) {
case 'paragraph':
return `<p>${childrenHTML}</p>`;
case 'heading':
return `<h${element.attrs?.level}>${childrenHTML}</h${element.attrs?.level}>`;
case 'blockquote':
return `<blockquote>${childrenHTML}</blockquote>`;
case 'image':
return `<img src="${element.attrs?.src}" alt="${element.attrs?.alt}" />`;
default:
return childrenHTML;
}
}).join('');
}

4. 输入法兼容(CompositionEvent)

输入法(IME)是中日韩等语言必须使用的输入方式。在 IME 输入过程中,浏览器会触发 CompositionEvent 系列事件。编辑器必须正确处理这些事件,否则会出现输入丢失、文字重复、光标跳动等问题。

input/composition.ts
class InputHandler {
private isComposing = false;

constructor(private editor: Editor, private element: HTMLElement) {
this.bindEvents();
}

private bindEvents(): void {
this.element.addEventListener('compositionstart', this.onCompositionStart);
this.element.addEventListener('compositionupdate', this.onCompositionUpdate);
this.element.addEventListener('compositionend', this.onCompositionEnd);
this.element.addEventListener('beforeinput', this.onBeforeInput);
}

private onCompositionStart = (): void => {
// 进入输入法组合状态:暂停所有 DOM 同步操作
this.isComposing = true;
this.editor.setComposing(true);
};

private onCompositionUpdate = (_e: CompositionEvent): void => {
// 输入法候选窗口更新,不做处理
// 关键:不要在这里更新编辑器模型!
};

private onCompositionEnd = (e: CompositionEvent): void => {
// 输入法确认:提交最终文本到编辑器模型
this.isComposing = false;
this.editor.setComposing(false);

// 将确认的文本插入到数据模型中
if (e.data) {
this.editor.commands.insertText(e.data);
}
};

private onBeforeInput = (e: InputEvent): void => {
// 输入法组合中,跳过 beforeinput 处理
if (this.isComposing) return;

e.preventDefault();

switch (e.inputType) {
case 'insertText':
if (e.data) this.editor.commands.insertText(e.data);
break;
case 'insertParagraph':
this.editor.commands.splitBlock();
break;
case 'deleteContentBackward':
this.editor.commands.deleteBackward();
break;
case 'deleteContentForward':
this.editor.commands.deleteForward();
break;
}
};

destroy(): void {
this.element.removeEventListener('compositionstart', this.onCompositionStart);
this.element.removeEventListener('compositionupdate', this.onCompositionUpdate);
this.element.removeEventListener('compositionend', this.onCompositionEnd);
this.element.removeEventListener('beforeinput', this.onBeforeInput);
}
}
警告

输入法兼容是富文本编辑器最容易出 Bug 的地方之一。核心原则是:composing 状态下绝不同步 DOM,也不更新模型。等 compositionend 事件触发后再一次性处理。

5. 快捷键系统

keybindings/keybindings.ts
/** 快捷键描述 */
interface KeyBinding {
/** 快捷键组合,如 'Mod-b' (Mod = Ctrl/Cmd) */
key: string;
/** 处理函数 */
handler: (editor: Editor) => boolean;
}

/** 解析快捷键字符串 */
function parseKeyBinding(keyStr: string): {
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
key: string;
} {
const parts = keyStr.toLowerCase().split('-');
const key = parts.pop()!;
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);

return {
ctrl: parts.includes('mod') ? !isMac : parts.includes('ctrl'),
meta: parts.includes('mod') ? isMac : parts.includes('meta'),
shift: parts.includes('shift'),
alt: parts.includes('alt'),
key,
};
}

/** 默认快捷键 */
const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
{ key: 'Mod-b', handler: (editor) => editor.commands.toggleBold() },
{ key: 'Mod-i', handler: (editor) => editor.commands.toggleItalic() },
{ key: 'Mod-u', handler: (editor) => editor.commands.toggleUnderline() },
{ key: 'Mod-z', handler: (editor) => editor.history.undo(editor) },
{ key: 'Mod-Shift-z', handler: (editor) => editor.history.redo(editor) },
{ key: 'Mod-a', handler: (editor) => editor.commands.selectAll() },
{ key: 'Tab', handler: (editor) => editor.commands.indent() },
{ key: 'Shift-Tab', handler: (editor) => editor.commands.outdent() },
{ key: 'Mod-Enter', handler: (editor) => editor.commands.exitBlock() },
];

/** 快捷键处理器 */
function handleKeyDown(
editor: Editor,
event: KeyboardEvent,
bindings: KeyBinding[]
): void {
for (const binding of bindings) {
const parsed = parseKeyBinding(binding.key);
if (
event.ctrlKey === parsed.ctrl &&
event.metaKey === parsed.meta &&
event.shiftKey === parsed.shift &&
event.altKey === parsed.alt &&
event.key.toLowerCase() === parsed.key
) {
if (binding.handler(editor)) {
event.preventDefault();
return;
}
}
}
}

6. 复杂 Block 设计

图片、视频、表格等非文本 Block 需要特殊处理:

blocks/complex-blocks.ts
/** 图片 Block */
interface ImageBlock extends ElementNode {
type: 'image';
attrs: {
src: string;
alt: string;
width: number | null;
height: number | null;
alignment: 'left' | 'center' | 'right';
};
children: [TextNode]; // Void 节点:children 为空文本占位
}

/** 视频 Block */
interface VideoBlock extends ElementNode {
type: 'video';
attrs: {
src: string;
poster: string;
width: number;
aspectRatio: string;
};
children: [TextNode];
}

/** 表格相关节点 */
interface TableBlock extends ElementNode {
type: 'table';
children: TableRowBlock[];
}

interface TableRowBlock extends ElementNode {
type: 'table_row';
children: TableCellBlock[];
}

interface TableCellBlock extends ElementNode {
type: 'table_cell';
attrs: {
colspan: number;
rowspan: number;
headerCell: boolean;
};
children: ElementNode[]; // 单元格内可以包含段落、列表等
}
要点

Void 节点(如图片、视频)在数据模型中仍需要一个空文本子节点 { text: '' },这是为了保证选区系统的一致性——光标可以在 Void 节点的前后定位。Slate.js 和 ProseMirror 都采用了这种设计。


性能优化

大文档优化策略

策略说明适用场景
虚拟渲染只渲染可视区域内的 Block,类似虚拟列表万级 Block 文档
不可变数据使用 Immer 或 Immutable.js,利用结构共享减少 Diff 开销频繁操作
增量渲染只重新渲染变更的 Block,而不是整个文档所有场景
异步 NormalizeSchema 校验放到 requestIdleCallback 中执行大批量粘贴
懒加载 Block图片、视频等重资源 Block 使用 IntersectionObserver 懒加载多媒体文档
optimization/incremental-render.ts
/** 增量渲染:只更新变更的节点 */
function getChangedPaths(
prevDoc: EditorDocument,
nextDoc: EditorDocument
): Path[] {
const changedPaths: Path[] = [];

function diff(prev: EditorNode[], next: EditorNode[], path: Path): void {
const maxLen = Math.max(prev.length, next.length);

for (let i = 0; i < maxLen; i++) {
const currentPath = [...path, i];

if (i >= prev.length || i >= next.length) {
changedPaths.push(currentPath); // 新增或删除
continue;
}

if (prev[i] !== next[i]) {
changedPaths.push(currentPath);

// 如果都是 Element 节点,递归比较 children
if ('children' in prev[i] && 'children' in next[i]) {
diff(
(prev[i] as ElementNode).children,
(next[i] as ElementNode).children,
currentPath
);
}
}
}
}

diff(prevDoc.children, nextDoc.children, []);
return changedPaths;
}

扩展设计:协同编辑基础

协同编辑是在线文档的核心能力,面试中经常被问到 OT 和 CRDT 的区别。

OT vs CRDT

特性OT(Operational Transform)CRDT(Conflict-free Replicated Data Type)
代表产品Google Docs、腾讯文档Figma、Notion(Yjs)、Liveblocks
核心思想服务端收到并发操作后做变换(Transform)使结果一致数据结构本身保证最终一致性,无需中心服务器
服务端依赖强依赖中心服务器做操作排序可 P2P,也可有中心服务器
实现复杂度变换函数组合爆炸(O(n2)O(n^2) 操作类型组合)数据结构设计复杂,但合并逻辑简单
冲突处理必须正确实现所有操作对的 Transform数据结构自动收敛
空间开销较小需要保存操作历史(墓碑机制)

OT 操作变换示例

collaboration/ot.ts
/** OT 操作类型 */
type OTOperation =
| { type: 'insert'; position: number; text: string }
| { type: 'delete'; position: number; count: number };

/**
* 操作变换函数
* 当两个客户端并发操作时,服务端需要对后到达的操作做变换
*/
function transform(
op1: OTOperation, // 先执行的操作
op2: OTOperation // 需要变换的操作
): OTOperation {
if (op1.type === 'insert' && op2.type === 'insert') {
// 两个插入:如果 op1 在 op2 前面或相同位置,op2 的位置需要后移
if (op1.position <= op2.position) {
return { ...op2, position: op2.position + op1.text.length };
}
return op2;
}

if (op1.type === 'insert' && op2.type === 'delete') {
if (op1.position <= op2.position) {
return { ...op2, position: op2.position + op1.text.length };
}
return op2;
}

if (op1.type === 'delete' && op2.type === 'insert') {
if (op1.position < op2.position) {
return {
...op2,
position: Math.max(op1.position, op2.position - op1.count),
};
}
return op2;
}

// delete + delete:更复杂的区间重叠处理
return op2;
}

CRDT 简要思路(Yjs)

Yjs 是目前最流行的前端 CRDT 协同编辑库,已经为 Slate.js、ProseMirror、TipTap、Lexical 等框架提供了绑定层:

collaboration/yjs-binding.ts
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

/** 初始化 Yjs 协同文档 */
function initCollaboration(roomId: string) {
const ydoc = new Y.Doc(); // 创建 CRDT 文档

// 通过 WebSocket 同步
const provider = new WebsocketProvider(
'wss://your-server.com',
roomId,
ydoc
);

// Y.XmlFragment 是 Yjs 提供的富文本专用 CRDT 类型
const yXmlFragment = ydoc.getXmlFragment('document');

// 监听远端变更
yXmlFragment.observeDeep((events) => {
events.forEach(event => {
// 将 Yjs 变更同步到编辑器数据模型
console.log('Remote change:', event);
});
});

// 感知信息(光标位置、用户名等)
const awareness = provider.awareness;
awareness.setLocalStateField('user', {
name: 'User A',
color: '#ff6b6b',
});

return { ydoc, provider, yXmlFragment, awareness };
}

框架选型对比

特性Slate.jsProseMirrorQuillTipTapLexical
维护方社区Marijn(个人)SlabTiptap GmbHMeta
框架绑定React框架无关框架无关Vue / ReactReact
数据模型嵌套 JSON扁平 TokenDelta (OT)基于 ProseMirror类链表节点
Schema无(自由)强 Schema有限基于 ProseMirror有(Class 级别)
学习曲线中等较高较低中等中等
协同支持Yjs 绑定Yjs 绑定内置 Delta OTYjs 绑定Yjs 绑定
适用场景自定义程度高企业级编辑器轻量表单快速开发大规模应用
包体积~50KB~80KB~40KB~120KB+~30KB
选型建议
  • 快速开发、产品原型:TipTap(封装好、文档全、生态丰富)
  • 高度自定义、React 项目:Slate.js(API 灵活、React 友好)
  • 企业级在线文档:ProseMirror(严格 Schema、性能可控)
  • 超大规模 + React:Lexical(Meta 出品、性能优先)
  • 简单富文本表单:Quill(开箱即用)

常见面试问题

Q1: 为什么不直接使用 contentEditable + execCommand 来实现富文本编辑器?

答案

contentEditable + execCommand 是早期的 L0 方案,存在以下致命问题:

  1. 跨浏览器行为不一致:同一个操作(如按 Enter)在不同浏览器产生不同的 DOM 结构。Chrome 生成 &lt;div&gt;,Firefox 曾生成 &lt;br&gt;,Safari 生成 &lt;div&gt;。这导致编辑器行为不可预测。

  2. execCommand 已被废弃MDN 明确标记为 Deprecated。各浏览器不再修复 Bug,也不会添加新功能。

  3. DOM 即数据的弊端:没有独立的数据模型,意味着:

    • 无法实现可靠的撤销重做(浏览器内置的 undo 不可控)
    • 无法做序列化(DOM 结构不规范)
    • 无法实现协同编辑(需要基于操作的数据同步)
    • 无法做自动化测试(逻辑和视图耦合)
  4. 粘贴内容不可控:用户从 Word、网页粘贴内容时,会带入大量脏 HTML(内联样式、无意义标签),无法有效清洗。

现代编辑器(L1)的做法是:仍然利用 contentEditable 作为输入层(接收键盘事件、输入法事件),但拦截所有浏览器默认行为,在自建的数据模型上处理操作,然后由编辑器自己决定如何更新 DOM。

Q2: Slate.js 和 ProseMirror 架构上有什么核心差异?如何选型?

答案

两者是当前最主流的 L1 编辑器框架,核心差异体现在三个方面:

维度Slate.jsProseMirror
数据模型自由的嵌套 JSON 树,没有强制 Schema严格的 Schema 系统,编译时确定文档结构
视图层与 React 深度绑定,节点渲染用 React 组件框架无关,自己管理 DOM(可适配 React/Vue)
操作模型基于 Operation 的命令式 API基于 Transaction 的函数式 API

数据模型差异示例

对比:Mark 处理方式
// Slate.js:Mark 扁平化到 Text Node 属性上
const slateText = { text: '加粗', bold: true, italic: true };

// ProseMirror:Mark 是独立的数组
const pmText = {
type: 'text',
text: '加粗',
marks: [{ type: 'bold' }, { type: 'italic' }],
};

选型建议

  • Slate.js:项目使用 React,需要高度自定义渲染(如 Notion 风格的 Block Editor),团队对 React 生态熟悉
  • ProseMirror:需要严格的文档结构控制(如在线文档),需要框架无关的方案,需要精细的性能控制

Q3: 协同编辑中 OT 和 CRDT 有什么区别?各有什么优缺点?

答案

OT(Operational Transform)和 CRDT(Conflict-free Replicated Data Type)是两种解决多人协同冲突的技术方案:

OT 的工作原理

  1. 每个客户端在本地执行操作并发送给服务端
  2. 服务端接收到并发操作后,使用 Transform 函数将一个操作相对于另一个操作做变换
  3. 变换后的操作发送给其他客户端执行
  4. 所有客户端的文档最终收敛到相同状态
OT 变换示例
// 客户端 A 在位置 0 插入 "Hello"
const opA = { type: 'insert', position: 0, text: 'Hello' };
// 客户端 B 在位置 0 插入 "World"
const opB = { type: 'insert', position: 0, text: 'World' };

// 服务端变换:opB 需要右移 opA.text.length 个位置
const opBTransformed = { type: 'insert', position: 5, text: 'World' };
// 最终结果:'HelloWorld'(所有客户端一致)

CRDT 的工作原理

  1. 每个字符/操作都有唯一 ID(通常是 {clientId, clock}
  2. 数据结构自身保证了合并的确定性(交换律、结合律、幂等性)
  3. 不需要中心服务器做变换,可以 P2P 同步
对比OTCRDT
依赖必须有中心服务器排序可 P2P
实现难度变换函数的正确性证明困难(操作类型 nn 种,需 n2n^2 种组合)数据结构设计复杂,但合并逻辑简单
空间开销只需保留操作日志需要保留删除标记(墓碑),内存开销更大
延迟需要等待服务端确认本地立即生效
生态成熟(Google Docs 15+ 年验证)快速发展(Yjs、Automerge)

面试总结:OT 成熟稳定但实现复杂,适合有强后端团队的公司;CRDT 无需中心服务器,前端生态好(Yjs),适合前端主导的项目。目前趋势是 CRDT 逐步取代 OT。

Q4: 如何处理富文本编辑器中的输入法(IME)兼容问题?

答案

输入法兼容是富文本编辑器开发中最常踩坑的地方,核心问题在于 IME 输入是一个异步多步过程,编辑器必须正确区分"正在组合中"和"已确认"两个阶段。

关键事件流

  1. compositionstart:用户开始输入法输入(如按下拼音的第一个字母)
  2. compositionupdate:输入法候选窗口更新(每按一个键都会触发)
  3. compositionend:用户选择了候选词,输入完成

核心处理策略

IME 兼容核心代码
class IMEHandler {
private isComposing = false;

handleCompositionStart(): void {
this.isComposing = true;
// 关键:暂停所有从模型到 DOM 的同步
// 因为输入法正在修改 DOM,此时同步会破坏输入法的状态
}

handleCompositionEnd(data: string): void {
this.isComposing = false;
// 关键:compositionend 之后才将最终文本写入模型
// 并重新开启 DOM 同步
this.editor.insertText(data);
}

handleBeforeInput(e: InputEvent): void {
if (this.isComposing) return; // composing 期间不处理
// 正常处理 beforeinput 事件...
}
}

常见陷阱

  1. Android 浏览器:部分 Android 设备不触发 compositionstart/end,需要通过 beforeinputinputType 判断(insertCompositionText
  2. compositionend 与 input 事件顺序:在某些浏览器中 compositionendinput 事件之后触发,需要用 setTimeout 延迟处理
  3. 选区修复:IME 输入结束后,浏览器的选区可能指向错误位置,需要在 compositionend 后主动修复编辑器选区

相关链接