构建高性能富文本编辑器:从架构设计到性能优化完全指南
富文本编辑器是现代 Web 应用中最复杂的组件之一。从简单的博客编辑到专业的文档协作平台,编辑器的性能直接影响用户体验。本文将深入探讨构建高性能编辑器的核心技术与最佳实践。
为什么编辑器开发如此困难
在开始技术实现之前,我们需要理解富文本编辑器面临的独特挑战:
1. ContentEditable 的历史包袱
浏览器原生的 contenteditable 属性看似提供了开箱即用的编辑能力,但实际上充满陷阱:
跨浏览器不一致性:同样的操作在不同浏览器中产生不同的 HTML 结构。例如,按下 Enter 键,Chrome 可能生成 <div>,而 Firefox 生成 <br>。
Selection API 复杂性:光标位置和选区的处理在不同浏览器中行为各异,需要大量兼容代码。
DOM 操作不可预测:浏览器会自动"优化" HTML 结构,导致内容模型难以维护。
2. 性能与功能的平衡
编辑器需要实时响应用户输入,同时处理:
- 复杂的格式化操作
- 撤销/重做历史管理
- 实时协同编辑
- 大文档的渲染和滚动
任何一个环节的性能问题都会导致输入延迟,严重影响用户体验。
3. 内容模型的设计抉择
编辑器的内容模型决定了整个架构:
- 基于 HTML:直接操作 DOM,实现简单但难以维护
- 自定义数据结构:如 Slate、ProseMirror 的文档模型,灵活但学习成本高
- CRDT/OT:支持协同编辑,但实现复杂度极高
核心架构设计
一个高性能编辑器通常采用分层架构:
┌─────────────────────────────────────┐
│ 用户界面层 (UI Layer) │
│ 工具栏、菜单、快捷键、拖拽处理 │
├─────────────────────────────────────┤
│ 视图层 (View Layer) │
│ 虚拟 DOM、渲染引擎、选区管理 │
├─────────────────────────────────────┤
│ 模型层 (Model Layer) │
│ 文档模型、Schema 验证、Transform │
├─────────────────────────────────────┤
│ 历史层 (History Layer) │
│ 撤销/重做、操作合并、状态快照 │
├─────────────────────────────────────┤
│ 协同层 (Collaboration Layer) │
│ OT/CRDT、冲突解决、实时同步 │
└─────────────────────────────────────┘
文档模型设计
文档模型是编辑器的基础。以下是一个类似 Slate.js 的设计:
// 基础节点类型定义
interface BaseNode {
type: string;
children?: Node[];
}
// 文本节点 - 编辑器的叶子节点
interface TextNode {
text: string;
// 格式化标记
bold?: boolean;
italic?: boolean;
underline?: boolean;
code?: boolean;
link?: string;
}
// 元素节点 - 可包含子节点
interface ElementNode extends BaseNode {
type: 'paragraph' | 'heading' | 'blockquote' | 'code-block' | 'list-item';
level?: number; // 用于标题级别
language?: string; // 用于代码块
children: (ElementNode | TextNode)[];
}
// 编辑器状态
interface EditorState {
document: ElementNode[];
selection: Selection | null;
history: HistoryState;
}
// 选区表示
interface Selection {
anchor: Point; // 选区起点
focus: Point; // 选区终点(可能在 anchor 之前)
}
interface Point {
path: number[]; // 节点路径,如 [0, 2, 1] 表示第一个块的第三个子节点的第二个子节点
offset: number; // 文本偏移量
}
为什么采用这种设计?
- 树形结构:自然映射到 DOM,便于渲染
- 路径寻址:通过
path数组精确定位任意节点 - 不可变数据:便于实现撤销重做和性能优化
- Schema 可验证:确保文档结构始终有效
操作(Operations)系统
所有对文档的修改都通过标准化的操作来完成:
// 操作类型定义
type Operation =
| InsertTextOperation
| DeleteTextOperation
| InsertNodeOperation
| RemoveNodeOperation
| SetNodeOperation
| MergeNodeOperation
| SplitNodeOperation
| MoveNodeOperation
| SetSelectionOperation;
// 插入文本操作
interface InsertTextOperation {
type: 'insert_text';
path: number[];
offset: number;
text: string;
}
// 删除文本操作
interface DeleteTextOperation {
type: 'delete_text';
path: number[];
offset: number;
text: string; // 被删除的文本,用于撤销
}
// 设置节点属性
interface SetNodeOperation {
type: 'set_node';
path: number[];
properties: Partial<ElementNode>;
newProperties: Partial<ElementNode>;
}
// 操作应用函数
function applyOperation(state: EditorState, op: Operation): EditorState {
switch (op.type) {
case 'insert_text':
return applyInsertText(state, op);
case 'delete_text':
return applyDeleteText(state, op);
case 'set_node':
return applySetNode(state, op);
// ... 其他操作类型
}
}
// 操作反转 - 用于撤销
function inverseOperation(op: Operation): Operation {
switch (op.type) {
case 'insert_text':
return {
type: 'delete_text',
path: op.path,
offset: op.offset,
text: op.text
};
case 'delete_text':
return {
type: 'insert_text',
path: op.path,
offset: op.offset,
text: op.text
};
// ... 其他反转逻辑
}
}
操作系统的优势:
- 原子性:每个操作都是最小单位,便于组合和回滚
- 可序列化:操作可以通过网络传输,支持协同编辑
- 可预测性:相同的操作序列总是产生相同的结果
高性能渲染策略
虚拟 DOM 与差量更新
编辑器不应该在每次修改后重新渲染整个文档。我们需要精确计算变更并只更新受影响的部分:
interface VNode {
type: string | Component;
props: Record<string, any>;
children: (VNode | string)[];
key?: string;
el?: HTMLElement; // 对应的真实 DOM 元素
}
class EditorRenderer {
private container: HTMLElement;
private vdom: VNode | null = null;
render(state: EditorState): void {
const newVdom = this.stateToVdom(state);
if (this.vdom) {
// 差量更新
this.patch(this.container, this.vdom, newVdom);
} else {
// 首次渲染
this.container.appendChild(this.createDom(newVdom));
}
this.vdom = newVdom;
}
private stateToVdom(state: EditorState): VNode {
return {
type: 'div',
props: {
class: 'editor-content',
contenteditable: 'true',
spellcheck: 'false' // 禁用浏览器拼写检查,提升性能
},
children: state.document.map((block, index) =>
this.nodeToVdom(block, [index])
)
};
}
private nodeToVdom(node: ElementNode | TextNode, path: number[]): VNode | string {
if ('text' in node) {
// 文本节点
return this.renderText(node);
}
// 元素节点
return {
type: this.getTagName(node.type),
props: {
'data-path': path.join(','),
class: this.getNodeClass(node)
},
children: node.children.map((child, index) =>
this.nodeToVdom(child as ElementNode | TextNode, [...path, index])
),
key: path.join('-')
};
}
private renderText(node: TextNode): VNode | string {
let content: VNode | string = node.text;
// 按格式包装文本
if (node.bold) {
content = { type: 'strong', props: {}, children: [content] };
}
if (node.italic) {
content = { type: 'em', props: {}, children: [content] };
}
if (node.code) {
content = { type: 'code', props: {}, children: [content] };
}
if (node.link) {
content = {
type: 'a',
props: { href: node.link, target: '_blank' },
children: [content]
};
}
return content;
}
private patch(parent: HTMLElement, oldVNode: VNode, newVNode: VNode): void {
// 类型不同,完全替换
if (oldVNode.type !== newVNode.type) {
const newEl = this.createDom(newVNode);
parent.replaceChild(newEl, oldVNode.el!);
return;
}
// 复用 DOM 元素
const el = oldVNode.el!;
newVNode.el = el;
// 更新属性
this.patchProps(el, oldVNode.props, newVNode.props);
// 更新子节点(使用 key 优化)
this.patchChildren(el, oldVNode.children, newVNode.children);
}
private patchChildren(
parent: HTMLElement,
oldChildren: (VNode | string)[],
newChildren: (VNode | string)[]
): void {
// 使用 key 进行高效的子节点对比
// 这里简化处理,实际应实现完整的 diff 算法
const keyedOld = new Map<string, VNode>();
oldChildren.forEach(child => {
if (typeof child !== 'string' && child.key) {
keyedOld.set(child.key, child);
}
});
newChildren.forEach((newChild, index) => {
if (typeof newChild === 'string') {
// 文本节点直接更新
const textNode = parent.childNodes[index];
if (textNode?.textContent !== newChild) {
textNode.textContent = newChild;
}
return;
}
const oldChild = newChild.key ? keyedOld.get(newChild.key) : oldChildren[index];
if (oldChild && typeof oldChild !== 'string') {
this.patch(parent, oldChild, newChild);
} else {
parent.insertBefore(this.createDom(newChild), parent.childNodes[index] || null);
}
});
}
}
长文档虚拟滚动
当文档包含数千个段落时,渲染所有内容会导致严重的性能问题。虚拟滚动只渲染可见区域内的内容:
interface VirtualScrollState {
scrollTop: number;
viewportHeight: number;
itemHeights: Map<string, number>; // 缓存每个块的高度
estimatedItemHeight: number;
}
class VirtualScrollEditor {
private state: VirtualScrollState;
private container: HTMLElement;
private content: HTMLElement;
private document: ElementNode[];
constructor(container: HTMLElement) {
this.container = container;
this.state = {
scrollTop: 0,
viewportHeight: container.clientHeight,
itemHeights: new Map(),
estimatedItemHeight: 24 // 默认行高估算
};
this.setupScrollListener();
}
private setupScrollListener(): void {
// 使用 passive 事件监听器提升滚动性能
this.container.addEventListener('scroll', () => {
// 使用 requestAnimationFrame 避免过度渲染
requestAnimationFrame(() => {
this.state.scrollTop = this.container.scrollTop;
this.updateVisibleRange();
});
}, { passive: true });
}
private getVisibleRange(): { start: number; end: number } {
const { scrollTop, viewportHeight, itemHeights, estimatedItemHeight } = this.state;
// 计算可见区域的起始和结束索引
let accumulatedHeight = 0;
let startIndex = 0;
let endIndex = this.document.length;
for (let i = 0; i < this.document.length; i++) {
const itemHeight = itemHeights.get(String(i)) || estimatedItemHeight;
if (accumulatedHeight + itemHeight >= scrollTop && startIndex === 0) {
// 添加缓冲区,提前渲染上方内容
startIndex = Math.max(0, i - 5);
}
if (accumulatedHeight >= scrollTop + viewportHeight) {
// 添加缓冲区,多渲染下方内容
endIndex = Math.min(this.document.length, i + 10);
break;
}
accumulatedHeight += itemHeight;
}
return { start: startIndex, end: endIndex };
}
private updateVisibleRange(): void {
const { start, end } = this.getVisibleRange();
// 计算占位高度
const topPadding = this.getHeightBeforeIndex(start);
const bottomPadding = this.getHeightAfterIndex(end);
// 更新内容区域的 padding 来保持滚动位置
this.content.style.paddingTop = `${topPadding}px`;
this.content.style.paddingBottom = `${bottomPadding}px`;
// 只渲染可见范围内的块
this.renderBlocks(start, end);
}
private renderBlocks(start: number, end: number): void {
const fragment = document.createDocumentFragment();
for (let i = start; i < end; i++) {
const block = this.document[i];
const blockEl = this.renderBlock(block, i);
fragment.appendChild(blockEl);
}
// 使用 replaceChildren 一次性更新,减少重排
this.content.replaceChildren(fragment);
// 渲染后测量实际高度并缓存
requestAnimationFrame(() => {
this.measureRenderedBlocks(start, end);
});
}
private measureRenderedBlocks(start: number, end: number): void {
const children = this.content.children;
for (let i = 0; i < children.length; i++) {
const actualIndex = start + i;
const height = children[i].getBoundingClientRect().height;
this.state.itemHeights.set(String(actualIndex), height);
}
}
}
虚拟滚动的关键优化点:
- 高度缓存:避免重复测量 DOM
- 缓冲区渲染:在可见区域外多渲染一些内容,避免快速滚动时出现空白
- 滚动节流:使用
requestAnimationFrame合并滚动事件 - 批量 DOM 操作:使用
DocumentFragment减少重排次数
输入处理与性能优化
用户输入是编辑器性能的关键瓶颈。每次按键都需要:
- 捕获输入事件
- 更新文档模型
- 重新渲染
- 恢复光标位置
输入事件处理
class InputHandler {
private editor: Editor;
private compositionActive = false; // 输入法组合状态
private pendingOperations: Operation[] = [];
private flushTimeout: number | null = null;
constructor(editor: Editor) {
this.editor = editor;
this.setupEventListeners();
}
private setupEventListeners(): void {
const el = this.editor.element;
// 处理输入法组合输入(中文、日文等)
el.addEventListener('compositionstart', () => {
this.compositionActive = true;
});
el.addEventListener('compositionend', (e: CompositionEvent) => {
this.compositionActive = false;
// 组合输入结束后处理完整文本
this.handleTextInput(e.data);
});
// beforeinput 事件提供了更好的输入控制
el.addEventListener('beforeinput', (e: InputEvent) => {
if (this.compositionActive) return; // 组合输入时跳过
e.preventDefault(); // 阻止默认行为,自己处理
this.handleInputEvent(e);
});
// 处理粘贴
el.addEventListener('paste', (e: ClipboardEvent) => {
e.preventDefault();
this.handlePaste(e);
});
}
private handleInputEvent(e: InputEvent): void {
const { inputType, data } = e;
switch (inputType) {
case 'insertText':
this.handleTextInput(data || '');
break;
case 'insertParagraph':
case 'insertLineBreak':
this.handleEnterKey();
break;
case 'deleteContentBackward':
this.handleBackspace();
break;
case 'deleteContentForward':
this.handleDelete();
break;
case 'deleteByCut':
this.handleCut();
break;
case 'formatBold':
this.toggleFormat('bold');
break;
case 'formatItalic':
this.toggleFormat('italic');
break;
// ... 更多输入类型
}
}
private handleTextInput(text: string): void {
const { selection } = this.editor.state;
if (!selection) return;
// 如果有选区,先删除选中内容
if (!this.isCollapsedSelection(selection)) {
this.deleteSelection(selection);
}
// 插入文本
const op: InsertTextOperation = {
type: 'insert_text',
path: selection.anchor.path,
offset: selection.anchor.offset,
text
};
// 批量处理操作,避免频繁渲染
this.queueOperation(op);
}
// 操作批处理 - 关键性能优化
private queueOperation(op: Operation): void {
this.pendingOperations.push(op);
// 清除之前的定时器
if (this.flushTimeout) {
clearTimeout(this.flushTimeout);
}
// 使用 microtask 在当前事件循环结束时批量应用操作
this.flushTimeout = window.setTimeout(() => {
this.flushOperations();
}, 0);
}
private flushOperations(): void {
if (this.pendingOperations.length === 0) return;
const ops = this.pendingOperations;
this.pendingOperations = [];
this.flushTimeout = null;
// 批量应用操作
this.editor.applyOperations(ops);
}
private handlePaste(e: ClipboardEvent): void {
const clipboardData = e.clipboardData;
if (!clipboardData) return;
// 优先处理 HTML 内容
const html = clipboardData.getData('text/html');
if (html) {
const fragment = this.parseHtml(html);
this.insertFragment(fragment);
return;
}
// 纯文本回退
const text = clipboardData.getData('text/plain');
if (text) {
this.handleTextInput(text);
}
}
private parseHtml(html: string): ElementNode[] {
// 创建安全的解析环境
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// 清理危险内容
this.sanitizeHtml(doc.body);
// 转换为编辑器的文档模型
return this.domToNodes(doc.body);
}
private sanitizeHtml(element: HTMLElement): void {
// 移除脚本标签
element.querySelectorAll('script').forEach(el => el.remove());
// 移除事件属性
element.querySelectorAll('*').forEach(el => {
Array.from(el.attributes).forEach(attr => {
if (attr.name.startsWith('on')) {
el.removeAttribute(attr.name);
}
});
});
// 移除 style 标签中的 expression()(IE 漏洞)
element.querySelectorAll('style').forEach(el => el.remove());
}
}
性能优化技巧
1. 输入延迟测量与监控
class PerformanceMonitor {
private inputLatencies: number[] = [];
private renderTimes: number[] = [];
measureInputLatency(callback: () => void): void {
const start = performance.now();
callback();
// 等待渲染完成后测量
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const latency = performance.now() - start;
this.inputLatencies.push(latency);
// 如果延迟过高,发出警告
if (latency > 100) {
console.warn(`High input latency detected: ${latency.toFixed(2)}ms`);
}
// 只保留最近 100 次测量
if (this.inputLatencies.length > 100) {
this.inputLatencies.shift();
}
});
});
}
getAverageLatency(): number {
if (this.inputLatencies.length === 0) return 0;
const sum = this.inputLatencies.reduce((a, b) => a + b, 0);
return sum / this.inputLatencies.length;
}
getP95Latency(): number {
if (this.inputLatencies.length === 0) return 0;
const sorted = [...this.inputLatencies].sort((a, b) => a - b);
const index = Math.floor(sorted.length * 0.95);
return sorted[index];
}
}
2. 智能防抖与节流
class SmartThrottle {
private lastCall = 0;
private timeout: number | null = null;
private pendingArgs: any[] | null = null;
constructor(
private fn: (...args: any[]) => void,
private options: {
minInterval: number; // 最小调用间隔
maxWait: number; // 最大等待时间
leading: boolean; // 首次立即执行
}
) {}
call(...args: any[]): void {
const now = Date.now();
const elapsed = now - this.lastCall;
// 首次调用或间隔足够长
if (this.options.leading && (this.lastCall === 0 || elapsed >= this.options.minInterval)) {
this.execute(args);
return;
}
// 保存最新参数
this.pendingArgs = args;
// 设置延迟执行
if (!this.timeout) {
const delay = Math.min(
this.options.minInterval - elapsed,
this.options.maxWait
);
this.timeout = window.setTimeout(() => {
if (this.pendingArgs) {
this.execute(this.pendingArgs);
this.pendingArgs = null;
}
this.timeout = null;
}, Math.max(0, delay));
}
}
private execute(args: any[]): void {
this.lastCall = Date.now();
this.fn(...args);
}
cancel(): void {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
this.pendingArgs = null;
}
}
// 使用示例:优化自动保存
const autoSave = new SmartThrottle(
(content) => saveToServer(content),
{
minInterval: 2000, // 最少 2 秒保存一次
maxWait: 10000, // 最多等待 10 秒
leading: false // 不立即保存,等用户停止输入
}
);
撤销重做系统
撤销重做是编辑器的核心功能之一,直接影响用户体验。
基于操作的历史管理
interface HistoryState {
undoStack: OperationBatch[];
redoStack: OperationBatch[];
}
interface OperationBatch {
operations: Operation[];
timestamp: number;
selectionBefore: Selection | null;
selectionAfter: Selection | null;
}
class HistoryManager {
private undoStack: OperationBatch[] = [];
private redoStack: OperationBatch[] = [];
private currentBatch: Operation[] = [];
private batchTimeout: number | null = null;
private lastOperationTime = 0;
// 批处理时间窗口(毫秒)
private readonly BATCH_WINDOW = 300;
// 最大历史记录数
private readonly MAX_HISTORY = 100;
addOperation(op: Operation, selection: Selection | null): void {
const now = Date.now();
// 如果距离上次操作超过时间窗口,创建新批次
if (now - this.lastOperationTime > this.BATCH_WINDOW && this.currentBatch.length > 0) {
this.commitBatch();
}
this.currentBatch.push(op);
this.lastOperationTime = now;
// 清空重做栈(新操作后无法重做)
this.redoStack = [];
// 设置批次提交定时器
if (this.batchTimeout) {
clearTimeout(this.batchTimeout);
}
this.batchTimeout = window.setTimeout(() => {
this.commitBatch();
}, this.BATCH_WINDOW);
}
private commitBatch(): void {
if (this.currentBatch.length === 0) return;
const batch: OperationBatch = {
operations: [...this.currentBatch],
timestamp: Date.now(),
selectionBefore: null, // 实际实现中需要记录
selectionAfter: null
};
this.undoStack.push(batch);
this.currentBatch = [];
// 限制历史记录数量
if (this.undoStack.length > this.MAX_HISTORY) {
this.undoStack.shift();
}
if (this.batchTimeout) {
clearTimeout(this.batchTimeout);
this.batchTimeout = null;
}
}
undo(editor: Editor): boolean {
// 先提交当前未完成的批次
this.commitBatch();
if (this.undoStack.length === 0) return false;
const batch = this.undoStack.pop()!;
// 反转操作顺序并应用
const inverseOps = batch.operations
.slice()
.reverse()
.map(op => inverseOperation(op));
editor.applyOperations(inverseOps);
// 移动到重做栈
this.redoStack.push(batch);
// 恢复选区
if (batch.selectionBefore) {
editor.setSelection(batch.selectionBefore);
}
return true;
}
redo(editor: Editor): boolean {
if (this.redoStack.length === 0) return false;
const batch = this.redoStack.pop()!;
// 重新应用原始操作
editor.applyOperations(batch.operations);
// 移回撤销栈
this.undoStack.push(batch);
// 恢复选区
if (batch.selectionAfter) {
editor.setSelection(batch.selectionAfter);
}
return true;
}
// 合并相似操作(如连续输入)
mergeSimilarOperations(ops: Operation[]): Operation[] {
const merged: Operation[] = [];
for (const op of ops) {
const last = merged[merged.length - 1];
// 合并连续的文本插入
if (
last &&
last.type === 'insert_text' &&
op.type === 'insert_text' &&
this.arePathsEqual(last.path, op.path) &&
last.offset + last.text.length === op.offset
) {
last.text += op.text;
continue;
}
merged.push(op);
}
return merged;
}
private arePathsEqual(a: number[], b: number[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
}
智能操作合并
用户通常希望撤销"一次编辑",而不是每个字符。智能合并让撤销更符合用户预期:
class SmartHistoryManager extends HistoryManager {
// 判断是否应该开始新的撤销批次
shouldBreakBatch(currentOp: Operation, lastOp: Operation | null): boolean {
if (!lastOp) return false;
// 操作类型不同
if (currentOp.type !== lastOp.type) return true;
// 不同位置的操作
if (!this.arePathsEqual(currentOp.path, lastOp.path)) return true;
// 输入了特殊字符(空格、标点、换行)
if (currentOp.type === 'insert_text') {
const specialChars = /[\s.,!?;:'"()\[\]{}]/;
if (specialChars.test(currentOp.text)) return true;
}
// 删除操作方向改变
if (currentOp.type === 'delete_text' && lastOp.type === 'delete_text') {
// 如果删除方向从后向前变成从前向后,开始新批次
// 这里需要更复杂的逻辑来判断
}
return false;
}
// 自定义合并策略
getMergeStrategy(opType: string): 'always' | 'smart' | 'never' {
switch (opType) {
case 'insert_text':
return 'smart'; // 智能合并连续输入
case 'delete_text':
return 'smart'; // 智能合并连续删除
case 'set_node':
return 'never'; // 格式变更不合并
default:
return 'smart';
}
}
}
实战:完整的编辑器组件
将以上概念整合成一个可用的编辑器组件:
// editor-core.ts
class Editor {
private container: HTMLElement;
private state: EditorState;
private renderer: EditorRenderer;
private inputHandler: InputHandler;
private history: HistoryManager;
private plugins: Plugin[] = [];
constructor(container: HTMLElement, options: EditorOptions = {}) {
this.container = container;
this.state = this.createInitialState(options.initialContent);
this.renderer = new EditorRenderer(container);
this.inputHandler = new InputHandler(this);
this.history = new HistoryManager();
// 初始化插件
this.plugins = (options.plugins || []).map(Plugin => new Plugin(this));
// 首次渲染
this.render();
}
private createInitialState(content?: ElementNode[]): EditorState {
return {
document: content || [
{
type: 'paragraph',
children: [{ text: '' }]
}
],
selection: null,
history: {
undoStack: [],
redoStack: []
}
};
}
// 应用操作
applyOperations(ops: Operation[]): void {
let newState = this.state;
for (const op of ops) {
newState = applyOperation(newState, op);
this.history.addOperation(op, newState.selection);
}
this.state = newState;
// 通知插件
this.plugins.forEach(plugin => plugin.onStateChange?.(this.state));
// 重新渲染
this.render();
}
// 渲染
private render(): void {
this.renderer.render(this.state);
// 渲染后恢复选区
if (this.state.selection) {
this.restoreSelection(this.state.selection);
}
}
// 撤销
undo(): void {
this.history.undo(this);
}
// 重做
redo(): void {
this.history.redo(this);
}
// 获取内容
getContent(): ElementNode[] {
return this.state.document;
}
// 获取 HTML
getHtml(): string {
return this.renderer.toHtml(this.state.document);
}
// 设置选区
setSelection(selection: Selection): void {
this.state = { ...this.state, selection };
this.restoreSelection(selection);
}
private restoreSelection(selection: Selection): void {
const domSelection = window.getSelection();
if (!domSelection) return;
try {
const anchorNode = this.pathToDomNode(selection.anchor.path);
const focusNode = this.pathToDomNode(selection.focus.path);
if (anchorNode && focusNode) {
const range = document.createRange();
range.setStart(anchorNode, selection.anchor.offset);
range.setEnd(focusNode, selection.focus.offset);
domSelection.removeAllRanges();
domSelection.addRange(range);
}
} catch (e) {
console.error('Failed to restore selection:', e);
}
}
private pathToDomNode(path: number[]): Node | null {
let node: Element | null = this.container.querySelector('[data-path="' + path.slice(0, -1).join(',') + '"]');
if (!node) return null;
// 找到对应的文本节点
const textIndex = path[path.length - 1];
let textNodeIndex = 0;
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
if (textNodeIndex === textIndex) {
return child;
}
textNodeIndex++;
}
}
return node.firstChild;
}
// 销毁
destroy(): void {
this.plugins.forEach(plugin => plugin.destroy?.());
this.inputHandler.destroy?.();
}
}
// 使用示例
const editor = new Editor(document.getElementById('editor')!, {
initialContent: [
{
type: 'heading',
level: 1,
children: [{ text: '欢迎使用高性能编辑器' }]
},
{
type: 'paragraph',
children: [{ text: '开始编写您的内容...' }]
}
],
plugins: [
MarkdownShortcutsPlugin,
AutoSavePlugin,
CollaborationPlugin
]
});
性能调优清单
在发布编辑器之前,请确保完成以下优化:
渲染性能
- 使用虚拟 DOM 进行差量更新
- 大文档启用虚拟滚动
- 避免强制同步布局(读写分离)
- 使用
will-change: transform优化滚动层 - 禁用不必要的浏览器功能(拼写检查、自动填充)
输入性能
- 操作批处理减少渲染次数
- 使用
beforeinput替代keydown/keypress - 正确处理输入法组合输入
- 粘贴内容异步解析
内存管理
- 限制历史记录数量
- 大文档使用惰性加载
- 及时清理事件监听器
- 避免闭包导致的内存泄漏
用户体验
- 输入延迟 < 100ms
- 滚动流畅(60fps)
- 撤销重做符合用户预期
- 提供加载状态反馈
总结
构建高性能富文本编辑器是前端开发中最具挑战性的任务之一。本文介绍了:
- 文档模型设计:采用树形结构和不可变数据,便于操作和维护
- 操作系统:所有修改通过标准化操作完成,支持撤销重做和协同编辑
- 高效渲染:虚拟 DOM 差量更新和虚拟滚动处理大文档
- 输入优化:操作批处理、输入法支持、性能监控
- 历史管理:智能操作合并,提供自然的撤销体验
编辑器开发没有银弹,需要根据具体场景在功能、性能和复杂度之间取得平衡。建议从成熟的开源项目(如 Slate、ProseMirror、Lexical)入手,理解其设计思想后再进行定制开发。
延伸阅读
- Slate.js 官方文档 - 高度可定制的富文本编辑框架
- ProseMirror 指南 - 学术级的编辑器框架
- Lexical 介绍 - Facebook 开源的现代编辑器框架
- CKEditor 5 架构 - 企业级编辑器的设计思路


