跳到主要内容

设计富文本编辑器

需求分析​

核心需求​

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

非功能需求​

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

面试速答版

怎么设计一个富文本编辑器(类 Notion / 飞书文档)? 核心架构是「自定义数据模型 + 拦截浏览器输入 + 受控渲染」,主流框架(Slate / ProseMirror / Tiptap)都是这个套路:

  • 数据模型:定义 Document → Block → Inline → Text 的嵌套树结构,Mark(加粗/斜体/链接)作为 Text 的修饰;用 Schema 严格约束哪些节点能嵌套哪些子节点。
  • 输入层:contentEditable 只用来接收键盘 / 输入法事件,不信任浏览器自动产生的 DOM;事件转换成 Command(如 insertText / toggleMark / wrapInBlock)作用到 Model。
  • 渲染层:Model 变化触发 React/Vue 受控渲染,DOM 永远是 Model 的纯函数,避免浏览器把粘贴/拖拽搞出非预期 DOM。
  • 插件化:每个能力(标题/列表/代码块/图片)是一个独立 Plugin,注册自己的 Schema、Command、键盘快捷键、Toolbar 项;扩展新能力不动核心。

为什么不能直接用 contentEditable + execCommand? 能用,但只能做最简单的「demo 级」编辑器,工业级用是地狱:

  • 行为不一致:按 Enter 在 Chrome 生成 <div>,Firefox 生成 <br>,Safari 又是 <div>,无法跨浏览器统一。
  • DOM 不可预测:粘贴富文本会带入一堆 inline style、span,输入法选词会临时插入 <span>,根本控制不了产物。
  • 无法做协同 / 撤销重做:DOM 即数据,没有结构化模型就没有 op,没法做 OT/CRDT 协同,也没法做精细的 Undo/Redo。
  • execCommand 已废弃,浏览器不再维护,新 API 也不补。

Slate / ProseMirror / Tiptap 怎么选?

  • Slate:嵌套 JSON + React 友好,灵活度最高,Schema 自由;适合产品形态特殊的编辑器,但要自己处理大量边界。
  • ProseMirror:扁平 Token 序列 + 严格 Schema,协同生态最成熟(y-prosemirror),社区方案多;适合做严肃文档(飞书、Atlassian 都用它)。
  • Tiptap:基于 ProseMirror 做的「框架封装」,开箱即用、API 优雅,适合快速搭建标准富文本编辑器。

富文本编辑器发展史​

富文本编辑器按技术架构可分为三个阶段(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 API 和 Range 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 不允许包含 image,code_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,需要通过 beforeinput 的 inputType 判断(insertCompositionText)
  2. compositionend 与 input 事件顺序:在某些浏览器中 compositionend 在 input 事件之后触发,需要用 setTimeout 延迟处理
  3. 选区修复:IME 输入结束后,浏览器的选区可能指向错误位置,需要在 compositionend 后主动修复编辑器选区

相关链接​