设计富文本编辑器
需求分析
核心需求
- 基础编辑能力:文本输入、删除、选中、复制粘贴、撤销重做
- 富文本格式:加粗、斜体、下划线、标题、列表、引用、代码块
- 复杂 Block:图片、视频、表格、分割线等非文本内容
- 工具栏交互:固定工具栏(Toolbar)、浮动菜单(Bubble Menu)、斜杠命令(Slash Command)
- 序列化与反序列化:支持 HTML、JSON、Markdown 等格式的导入导出
- 插件化架构:功能可拆分、可扩展,第三方可开发自定义插件
- 协同编辑:多人同时编辑同一篇文档,实时同步
非功能需求
- 性能:大文档(万级 Block)流畅编辑,输入延迟 < 16ms
- 兼容性:主流浏览器支持,输入法(IME)兼容
- 可扩展性:新增 Block 类型、Mark 类型零入侵
- 可测试性:数据模型与视图分离,核心逻辑可单元测试
富文本编辑器发展史
富文本编辑器按技术架构可分为三个阶段(L0/L1/L2),理解这个演进有助于面试时展示技术深度:
| 等级 | 代表框架 | 核心思路 | 优点 | 缺点 |
|---|---|---|---|---|
| L0 | 早期编辑器 | 直接使用 contentEditable + document.execCommand | 实现简单、浏览器原生支持 | 行为不一致、不可控、已废弃 |
| L1 | Quill、Draft.js、Slate.js | 自建数据模型,拦截用户输入,自己控制 DOM 更新 | 行为可预测、跨浏览器一致 | 仍依赖 contentEditable 做输入 |
| L2 | Google Docs、腾讯文档 | 完全自绘(Canvas / 自定义排版引擎),不依赖 contentEditable | 完全可控、支持复杂排版 | 开发成本极高、需自行实现光标/选区/输入法 |
大多数开源编辑器处于 L1 阶段。面试中被问到"如何设计富文本编辑器",通常指的是 L1 架构。L2 是大厂在线文档团队的技术方向,了解即可。
ContentEditable 原理与问题
浏览器提供了 contentEditable 属性,将任意 HTML 元素变为可编辑区域。配合已废弃的 document.execCommand API 可以实现基本的富文本操作:
// execCommand 已被废弃,不推荐使用
document.execCommand('bold'); // 加粗
document.execCommand('italic'); // 斜体
document.execCommand('insertHTML', false, '<hr>'); // 插入 HTML
ContentEditable 的核心问题:
- 行为不一致:不同浏览器按 Enter 生成的标签不同(Chrome 生成
<div>,Firefox 生成<br>,Safari 生成<div>) - DOM 不可预测:粘贴、拖拽、输入法等操作会产生非预期的 DOM 结构
- 无数据模型:DOM 即数据,无法做序列化、协同编辑、撤销重做栈
- execCommand 已废弃:浏览器不再维护,无法扩展新命令
L1 编辑器仍然使用 contentEditable 作为输入层(接收键盘、输入法事件),但不再信任浏览器生成的 DOM。编辑器会拦截事件,在自己的数据模型上操作,然后按自己的规则重新渲染 DOM。
整体架构
整体遵循 单向数据流:
- 用户在 ContentEditable 区域输入
- 事件处理层拦截浏览器事件
- 转化为编辑器命令(Command)
- 命令操作数据模型(Document Model)
- 模型变更触发视图重新渲染
- 插件系统在各环节提供扩展点
核心模块设计
1. 数据模型(Document Model)
数据模型是编辑器的核心,定义了文档的结构化表示。主流编辑器的数据模型可以抽象为四层结构:
TypeScript 类型定义
// ===== 基础类型 =====
/** 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
- ProseMirror
- Lexical
Slate.js 采用嵌套 JSON 树,灵活度高:
const slateDocument = {
children: [
{
type: 'paragraph',
children: [
{ text: '这是一段' },
{ text: '加粗', bold: true }, // Mark 扁平化到 Text 上
{ text: '的文本' },
],
},
{
type: 'heading',
level: 2,
children: [{ text: '二级标题' }],
},
],
};
ProseMirror 使用扁平 Token 序列(类似 HTML),配合 Schema 做严格约束:
const pmDocument = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: '这是一段' },
{
type: 'text',
text: '加粗',
marks: [{ type: 'bold' }], // Mark 是独立数组
},
{ type: 'text', text: '的文本' },
],
},
],
};
Lexical(Meta 出品)使用类链表节点,每个节点有 key、parent、prev、next:
// Lexical 序列化后的 JSON 结构
const lexicalDocument = {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{ type: 'text', text: '这是一段', format: 0 },
{ type: 'text', text: '加粗', format: 1 }, // format 用位运算标记样式
{ type: 'text', text: '的文本', format: 0 },
],
},
],
},
};
2. 选区与光标(Selection & Range)
编辑器需要精确控制用户的选区(Selection)和光标(Cursor)。浏览器提供了 Selection API 和 Range API,编辑器在此基础上做抽象。
/** 路径:从根节点到目标节点的索引数组 */
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])
);
}
编辑器选区与浏览器选区的映射
/** 将编辑器选区同步到浏览器 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)
命令系统是编辑器操作的唯一入口,所有对数据模型的修改都必须通过命令完成。这是典型的 命令模式,便于实现撤销重做和插件拦截。
/** 命令类型 */
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):
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 的核心设计理念之一:
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. 插件架构
插件化是编辑器可扩展性的基础。一个好的插件系统应该允许插件在多个维度扩展编辑器:
/** 插件接口 */
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 快捷输入
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、飞书文档 |
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 实现思路
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. 序列化与反序列化
编辑器需要将内部数据模型转换为多种外部格式,也需要解析外部格式导入。这是一个经典的 适配器模式 应用。
- HTML 序列化
- JSON 序列化
- Markdown 序列化
/** 将编辑器节点序列化为 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('');
}
/** JSON 序列化(通常就是模型本身) */
function serializeToJSON(doc: EditorDocument): string {
return JSON.stringify(doc, null, 2);
}
/** JSON 反序列化(需做校验) */
function deserializeFromJSON(json: string, schema: SchemaSpec): EditorDocument {
const parsed = JSON.parse(json) as EditorDocument;
// 用 Schema 校验并修复文档结构
return normalizeDocument(parsed, schema);
}
/** 文档规范化:确保符合 Schema 约束 */
function normalizeDocument(
doc: EditorDocument,
schema: SchemaSpec
): EditorDocument {
// 递归遍历所有节点,移除不合法的子节点和 Mark
const normalized = {
...doc,
children: doc.children.map(child => normalizeNode(child, schema)),
};
return normalized;
}
/** 将编辑器节点序列化为 Markdown */
function serializeToMarkdown(nodes: EditorNode[]): string {
return nodes.map(node => {
if ('text' in node) {
let md = node.text;
(node.marks ?? []).forEach(mark => {
switch (mark.type) {
case 'bold':
md = `**${md}**`;
break;
case 'italic':
md = `*${md}*`;
break;
case 'code':
md = `\`${md}\``;
break;
case 'link':
md = `[${md}](${mark.attrs?.href})`;
break;
}
});
return md;
}
const element = node as ElementNode;
const childrenMd = serializeToMarkdown(element.children);
switch (element.type) {
case 'paragraph':
return `${childrenMd}\n\n`;
case 'heading': {
const hashes = '#'.repeat(element.attrs?.level as number ?? 1);
return `${hashes} ${childrenMd}\n\n`;
}
case 'blockquote':
return childrenMd.split('\n').map(line => `> ${line}`).join('\n') + '\n\n';
case 'image':
return `\n\n`;
default:
return childrenMd;
}
}).join('');
}
4. 输入法兼容(CompositionEvent)
输入法(IME)是中日韩等语言必须使用的输入方式。在 IME 输入过程中,浏览器会触发 CompositionEvent 系列事件。编辑器必须正确处理这些事件,否则会出现输入丢失、文字重复、光标跳动等问题。
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. 快捷键系统
/** 快捷键描述 */
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 需要特殊处理:
/** 图片 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,而不是整个文档 | 所有场景 |
| 异步 Normalize | Schema 校验放到 requestIdleCallback 中执行 | 大批量粘贴 |
| 懒加载 Block | 图片、视频等重资源 Block 使用 IntersectionObserver 懒加载 | 多媒体文档 |
/** 增量渲染:只更新变更的节点 */
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,也可有中心服务器 |
| 实现复杂度 | 变换函数组合爆炸( 操作类型组合) | 数据结构设计复杂,但合并逻辑简单 |
| 冲突处理 | 必须正确实现所有操作对的 Transform | 数据结构自动收敛 |
| 空间开销 | 较小 | 需要保存操作历史(墓碑机制) |
OT 操作变换示例
/** 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 等框架提供了绑定层:
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.js | ProseMirror | Quill | TipTap | Lexical |
|---|---|---|---|---|---|
| 维护方 | 社区 | Marijn(个人) | Slab | Tiptap GmbH | Meta |
| 框架绑定 | React | 框架无关 | 框架无关 | Vue / React | React |
| 数据模型 | 嵌套 JSON | 扁平 Token | Delta (OT) | 基于 ProseMirror | 类链表节点 |
| Schema | 无(自由) | 强 Schema | 有限 | 基于 ProseMirror | 有(Class 级别) |
| 学习曲线 | 中等 | 较高 | 较低 | 中等 | 中等 |
| 协同支持 | Yjs 绑定 | Yjs 绑定 | 内置 Delta OT | Yjs 绑定 | Yjs 绑定 |
| 适用场景 | 自定义程度高 | 企业级编辑器 | 轻量表单 | 快速开发 | 大规模应用 |
| 包体积 | ~50KB | ~80KB | ~40KB | ~120KB+ | ~30KB |
- 快速开发、产品原型:TipTap(封装好、文档全、生态丰富)
- 高度自定义、React 项目:Slate.js(API 灵活、React 友好)
- 企业级在线文档:ProseMirror(严格 Schema、性能可控)
- 超大规模 + React:Lexical(Meta 出品、性能优先)
- 简单富文本表单:Quill(开箱即用)
常见面试问题
Q1: 为什么不直接使用 contentEditable + execCommand 来实现富文本编辑器?
答案:
contentEditable + execCommand 是早期的 L0 方案,存在以下致命问题:
-
跨浏览器行为不一致:同一个操作(如按 Enter)在不同浏览器产生不同的 DOM 结构。Chrome 生成
<div>,Firefox 曾生成<br>,Safari 生成<div>。这导致编辑器行为不可预测。 -
execCommand 已被废弃:MDN 明确标记为 Deprecated。各浏览器不再修复 Bug,也不会添加新功能。
-
DOM 即数据的弊端:没有独立的数据模型,意味着:
- 无法实现可靠的撤销重做(浏览器内置的 undo 不可控)
- 无法做序列化(DOM 结构不规范)
- 无法实现协同编辑(需要基于操作的数据同步)
- 无法做自动化测试(逻辑和视图耦合)
-
粘贴内容不可控:用户从 Word、网页粘贴内容时,会带入大量脏 HTML(内联样式、无意义标签),无法有效清洗。
现代编辑器(L1)的做法是:仍然利用 contentEditable 作为输入层(接收键盘事件、输入法事件),但拦截所有浏览器默认行为,在自建的数据模型上处理操作,然后由编辑器自己决定如何更新 DOM。
Q2: Slate.js 和 ProseMirror 架构上有什么核心差异?如何选型?
答案:
两者是当前最主流的 L1 编辑器框架,核心差异体现在三个方面:
| 维度 | Slate.js | ProseMirror |
|---|---|---|
| 数据模型 | 自由的嵌套 JSON 树,没有强制 Schema | 严格的 Schema 系统,编译时确定文档结构 |
| 视图层 | 与 React 深度绑定,节点渲染用 React 组件 | 框架无关,自己管理 DOM(可适配 React/Vue) |
| 操作模型 | 基于 Operation 的命令式 API | 基于 Transaction 的函数式 API |
数据模型差异示例:
// 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 的工作原理:
- 每个客户端在本地执行操作并发送给服务端
- 服务端接收到并发操作后,使用 Transform 函数将一个操作相对于另一个操作做变换
- 变换后的操作发送给其他客户端执行
- 所有客户端的文档最终收敛到相同状态
// 客户端 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 的工作原理:
- 每个字符/操作都有唯一 ID(通常是
{clientId, clock}) - 数据结构自身保证了合并的确定性(交换律、结合律、幂等性)
- 不需要中心服务器做变换,可以 P2P 同步
| 对比 | OT | CRDT |
|---|---|---|
| 依赖 | 必须有中心服务器排序 | 可 P2P |
| 实现难度 | 变换函数的正确性证明困难(操作类型 种,需 种组合) | 数据结构设计复杂,但合并逻辑简单 |
| 空间开销 | 只需保留操作日志 | 需要保留删除标记(墓碑),内存开销更大 |
| 延迟 | 需要等待服务端确认 | 本地立即生效 |
| 生态 | 成熟(Google Docs 15+ 年验证) | 快速发展(Yjs、Automerge) |
面试总结:OT 成熟稳定但实现复杂,适合有强后端团队的公司;CRDT 无需中心服务器,前端生态好(Yjs),适合前端主导的项目。目前趋势是 CRDT 逐步取代 OT。
Q4: 如何处理富文本编辑器中的输入法(IME)兼容问题?
答案:
输入法兼容是富文本编辑器开发中最常踩坑的地方,核心问题在于 IME 输入是一个异步多步过程,编辑器必须正确区分"正在组合中"和"已确认"两个阶段。
关键事件流:
compositionstart:用户开始输入法输入(如按下拼音的第一个字母)compositionupdate:输入法候选窗口更新(每按一个键都会触发)compositionend:用户选择了候选词,输入完成
核心处理策略:
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 事件...
}
}
常见陷阱:
- Android 浏览器:部分 Android 设备不触发
compositionstart/end,需要通过beforeinput的inputType判断(insertCompositionText) - compositionend 与 input 事件顺序:在某些浏览器中
compositionend在input事件之后触发,需要用setTimeout延迟处理 - 选区修复:IME 输入结束后,浏览器的选区可能指向错误位置,需要在
compositionend后主动修复编辑器选区