在当今Web应用开发中,富文本编辑器已成为内容管理系统的标配组件。不同于简单的textarea,一个真正的富文本编辑器需要处理复杂的文档结构、样式继承和用户交互模式。React生态虽然提供了像Draft.js、Slate这样的解决方案,但它们的抽象层级较高,隐藏了许多关键技术细节。
这次我们要从零构建的,是一个基于contentEditable的React富文本编辑器核心模块。这个方案的优势在于:
核心挑战在于如何将React的声明式范式与contentEditable的命令式特性相结合。传统方案常见的问题包括:
我们的解决方案采用三层架构设计:
code复制[Editable Component]
↑ ↓
[State Manager]
↑ ↓
[DOM Adapter]
DOM Adapter层负责:
State Manager层核心功能:
Editable Component层特性:
编辑器状态采用类似Slate的数据结构,但做了简化:
typescript复制interface Node {
id: string;
type: 'paragraph' | 'heading' | 'list-item';
children: Node[];
text?: string;
marks?: Mark[];
}
interface Mark {
type: 'bold' | 'italic' | 'link';
attrs?: Record<string, string>;
}
interface Selection {
anchor: { id: string; offset: number };
focus: { id: string; offset: number };
}
这种设计的特点:
核心难点在于保持React状态与DOM的同步。我们采用受控组件模式:
jsx复制function Editable({ value, onChange }) {
const ref = useRef(null);
// 同步DOM到状态
const handleInput = useCallback((e) => {
const newValue = parseDOM(ref.current);
onChange(newValue);
}, [onChange]);
// 同步状态到DOM
useEffect(() => {
if (!isEqual(renderValue(value), ref.current.innerHTML)) {
ref.current.innerHTML = renderHTML(value);
restoreSelection(savedSelection);
}
}, [value]);
return (
<div
ref={ref}
contentEditable
onInput={handleInput}
dangerouslySetInnerHTML={{ __html: renderHTML(value) }}
/>
);
}
关键优化点:
光标跳动问题的根本原因是React的异步渲染与浏览器同步更新之间的冲突。我们的解决方案:
javascript复制function saveSelection() {
const sel = window.getSelection();
return {
anchorNode: findReactNodeId(sel.anchorNode),
anchorOffset: sel.anchorOffset,
focusNode: findReactNodeId(sel.focusNode),
focusOffset: sel.focusOffset
};
}
function restoreSelection(sel) {
const range = document.createRange();
range.setStart(findDOMNode(sel.anchorNode), sel.anchorOffset);
range.setEnd(findDOMNode(sel.focusNode), sel.focusOffset);
const newSel = window.getSelection();
newSel.removeAllRanges();
newSel.addRange(range);
}
支持段落、标题等块级元素需要特殊处理:
javascript复制const handleKeyDown = (e) => {
if (e.key === 'Enter') {
if (e.shiftKey) {
// 插入软换行
insertText('\n');
} else {
// 创建新段落
const newBlock = createBlock('paragraph');
insertNode(newBlock);
e.preventDefault();
}
}
if (e.key === 'Backspace' && isBlockStart(selection)) {
// 合并前一个块
mergeBlocks();
e.preventDefault();
}
};
对于长文档,采用类似React Window的虚拟化方案:
jsx复制function VirtualEditable({ nodes }) {
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 30 });
const onScroll = useThrottle((e) => {
const start = Math.floor(e.target.scrollTop / ROW_HEIGHT);
setVisibleRange({
start,
end: start + Math.ceil(e.target.clientHeight / ROW_HEIGHT) + 5
});
}, 50);
return (
<div onScroll={onScroll} style={{ height: '100%', overflow: 'auto' }}>
<div style={{ height: `${nodes.length * ROW_HEIGHT}px` }}>
{nodes.slice(visibleRange.start, visibleRange.end).map(node => (
<NodeComponent
key={node.id}
node={node}
style={{
position: 'absolute',
top: `${node.index * ROW_HEIGHT}px`
}}
/>
))}
</div>
</div>
);
}
采用类似React Reconciler的差异比较策略:
javascript复制function reconcile(oldNodes, newNodes) {
const patches = [];
// 简单比较
for (let i = 0; i < Math.min(oldNodes.length, newNodes.length); i++) {
if (!isEqual(oldNodes[i], newNodes[i])) {
patches.push({ type: 'UPDATE', index: i, node: newNodes[i] });
}
}
// 处理新增
if (newNodes.length > oldNodes.length) {
patches.push(
...newNodes.slice(oldNodes.length).map((node, i) => ({
type: 'INSERT',
index: oldNodes.length + i,
node
}))
);
}
// 处理删除
if (oldNodes.length > newNodes.length) {
patches.push({
type: 'DELETE',
index: newNodes.length,
count: oldNodes.length - newNodes.length
});
}
return patches;
}
采用中间件模式实现插件系统:
typescript复制type EditorMiddleware = (next: EditorHandler) => EditorHandler;
function createEditor() {
let handler: EditorHandler = baseHandler;
const use = (middleware: EditorMiddleware) => {
handler = middleware(handler);
};
const apply: EditorHandler = (action) => {
return handler(action);
};
return { use, apply };
}
// 示例:历史记录插件
function historyPlugin(editor) {
const stack = [];
let pointer = -1;
editor.use(next => action => {
if (action.type === 'UNDO') {
return stack[--pointer];
}
const result = next(action);
stack.length = pointer + 1;
stack.push(cloneDeep(result));
pointer++;
return result;
});
}
支持通过React组件扩展节点类型:
jsx复制function renderNode(node) {
switch (node.type) {
case 'code':
return <CodeBlock node={node} />;
case 'image':
return <ImageComponent node={node} />;
default:
return <Paragraph node={node} />;
}
}
// 自定义代码块组件
function CodeBlock({ node }) {
const [code, setCode] = useState(node.text);
return (
<pre>
<code
contentEditable={false}
dangerouslySetInnerHTML={{ __html: highlight(code) }}
/>
<textarea
value={code}
onChange={e => setCode(e.target.value)}
style={{ opacity: 0, height: 0, position: 'absolute' }}
/>
</pre>
);
}
问题1:输入法组合期间光标跳动
javascript复制let isComposing = false;
editorRef.addEventListener('compositionstart', () => {
isComposing = true;
});
editorRef.addEventListener('compositionend', () => {
isComposing = false;
forceUpdate();
});
useEffect(() => {
if (!isComposing) {
// 正常更新
}
}, [value]);
问题2:粘贴内容格式错乱
javascript复制function handlePaste(e) {
e.preventDefault();
const html = e.clipboardData.getData('text/html');
const clean = sanitizeHTML(html);
insertHTML(clean);
}
建议监控以下关键指标:
可以通过Performance API进行测量:
javascript复制const measure = (name) => {
const start = performance.now();
return {
end: () => {
const duration = performance.now() - start;
reportMetric(name, duration);
}
};
};
// 使用示例
const m = measure('input');
editor.onChange(newValue);
m.end();
这个基础实现可以进一步扩展:
实现一个健壮的富文本编辑器就像建造一座大桥,需要在React的声明式范式与DOM的命令式特性之间架起可靠的桥梁。经过多个版本的迭代,我们发现最关键的成功因素是保持架构的简单性和可预测性。每个新增功能都应该经过严格的性能影响评估,特别是在移动端环境下。