1. 项目概述:为什么需要从零构建富文本编辑器?
在React生态中实现一个可编辑的富文本节点,远比想象中复杂。市面上的现成方案如TinyMCE、Quill虽然功能完善,但当你需要深度定制编辑行为或优化性能时,往往会遇到黑箱限制。三年前我在开发一个法律文书协作平台时,就曾因Quill无法实现特定段落锁定功能而不得不重构。
React的可编辑节点(contentEditable)本质上是对浏览器原生能力的封装,但直接使用会遇到光标跳转、状态同步、跨平台样式等经典问题。通过组件化预设,我们可以将富文本的核心能力——格式控制、选区管理、撤销重做等——拆解为可组合的React单元,既保留灵活性又避免重复造轮子。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心架构设计
2.1 数据模型设计
富文本的数据结构选择直接影响功能实现复杂度。经过对比三种方案:
javascript复制// 方案1:纯HTML字符串(最简单但最难做协同编辑)
const content = '<p>Hello <b>world</b></p>'
// 方案2:JSON描述的Delta格式(Quill采用)
const delta = {
ops: [
{ insert: 'Hello ' },
{ insert: 'world', attributes: { bold: true } }
]
}
// 方案3:Slate.js的嵌套节点树(最适合复杂场景)
const document = {
children: [
{
type: 'paragraph',
children: [
{ text: 'Hello ' },
{ text: 'world', bold: true }
]
}
]
}
最终选择类Slate的树状结构,因其能完美映射React组件树,且支持自定义节点类型(如表格、代码块)。关键设计点在于每个节点都实现isVoid属性标记(如图片节点),这类节点需要特殊处理选区逻辑。
2.2 编辑器内核实现
编辑器核心类需要管理三部分状态:
typescript复制class EditorCore {
// 当前文档内容(与React状态同步)
children: Node[]
// 选区状态(需要兼容DOM Selection API)
selection: Range | null
// 操作历史记录(实现undo/redo)
operations: Operation[]
// 关键方法
applyOperation(op: Operation) {
// 处理内容变更并记录操作历史
}
normalizeNode(node: Node) {
// 规范化节点结构(如合并相邻文本节点)
}
}
重要提示:必须使用MutationObserver监听DOM变化,而非依赖React的onInput事件。因为用户粘贴内容或快捷键操作可能绕过React事件系统。
3. React组件层实现
3.1 可编辑节点组件
核心组件需处理内容与光标的双向同步:
jsx复制function Editable({ editor, renderElement }) {
const [value, setValue] = useState(editor.children)
useEffect(() => {
const onUpdate = () => setValue([...editor.children])
editor.on('update', onUpdate)
return () => editor.off('update', onUpdate)
}, [editor])
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
editor.insertBreak() // 自定义换行逻辑
}
}
return (
<div
contentEditable
onKeyDown={handleKeyDown}
dangerouslySetInnerHTML={{ __html: serialize(value) }}
/>
)
}
3.2 格式工具栏实现
工具栏需要与当前选区状态联动:
jsx复制function Toolbar({ editor }) {
const [isBold, setIsBold] = useState(false)
useEffect(() => {
const updateState = () => {
if (!editor.selection) return
const marks = editor.getActiveMarks()
setIsBold(!!marks?.bold)
}
editor.on('selectionChange', updateState)
return () => editor.off('selectionChange', updateState)
}, [editor])
const toggleBold = () => {
editor.toggleMark('bold')
}
return (
<button
onClick={toggleBold}
style={{ fontWeight: isBold ? 'bold' : 'normal' }}
>
B
</button>
)
}
4. 关键问题解决方案
4.1 光标跳动问题
当React重新渲染时,原生选区可能丢失。解决方案:
- 在render前保存选区状态:
javascript复制const range = window.getSelection().getRangeAt(0)
const anchorPath = findPath(editor, range.startContainer)
const focusPath = findPath(editor, range.endContainer)
- 在render后恢复选区:
javascript复制const startNode = findNode(editor, anchorPath)
const endNode = findNode(editor, focusPath)
const newRange = document.createRange()
newRange.setStart(startNode, range.startOffset)
newRange.setEnd(endNode, range.endOffset)
4.2 粘贴内容处理
需要拦截paste事件并自定义处理:
javascript复制onPaste={(e) => {
e.preventDefault()
const html = e.clipboardData.getData('text/html')
const cleanHtml = sanitize(html) // 使用DOMPurify等库
editor.insertFragment(deserialize(cleanHtml))
}}
5. 性能优化实践
5.1 虚拟滚动实现
对于长文档,需实现按需渲染:
jsx复制function VirtualEditable({ editor, itemHeight = 30 }) {
const [visibleRange, setVisibleRange] = useState([0, 20])
const handleScroll = (e) => {
const start = Math.floor(e.target.scrollTop / itemHeight)
setVisibleRange([start, start + 20])
}
return (
<div onScroll={handleScroll} style={{ height: '500px', overflow: 'auto' }}>
<div style={{ height: `${editor.children.length * itemHeight}px` }}>
{editor.children.slice(...visibleRange).map((node, i) => (
<NodeComponent
key={node.id}
node={node}
style={{ position: 'absolute', top: `${(visibleRange[0] + i) * itemHeight}px` }}
/>
))}
</div>
</div>
)
}
5.2 操作批处理
频繁的单字符输入会导致性能问题,需要合并操作:
javascript复制let batchOps = []
let batchTimer = null
const handleInput = (e) => {
batchOps.push(createInsertOp(e.data))
clearTimeout(batchTimer)
batchTimer = setTimeout(() => {
editor.applyOperations(batchOps)
batchOps = []
}, 100)
}
6. 扩展能力实现
6.1 协同编辑支持
通过OT算法实现多人协作:
javascript复制class Collaboration {
constructor(editor) {
this.version = 0
this.pendingOps = []
}
applyRemoteOperations(ops) {
const transformedOps = ot.transform(this.pendingOps, ops)
editor.applyOperations(transformedOps)
this.version++
}
}
6.2 插件系统设计
通过中间件模式扩展功能:
javascript复制function withHistory(editor) {
const { apply } = editor
const history = []
editor.apply = (op) => {
history.push(op)
apply(op)
}
editor.undo = () => {
const op = history.pop()
if (op) apply(invert(op))
}
return editor
}
7. 测试策略
7.1 单元测试重点
- 光标位置计算:
javascript复制test('getPathFromDOMNode', () => {
const div = document.createElement('div')
div.innerHTML = '<p>a<span>b</span>c</p>'
const path = getPathFromDOMNode(div.querySelector('span'))
expect(path).toEqual([0, 1]) // 第0个p节点的第1个子节点
})
- 操作逆运算:
javascript复制test('invertOperation', () => {
const op = { type: 'insert_text', path: [0,0], offset: 3, text: 'x' }
expect(invert(op)).toEqual({
type: 'remove_text', path: [0,0], offset: 3, text: 'x'
})
})
8. 生产环境注意事项
- XSS防护:必须在使用dangerouslySetInnerHTML的任何地方配合DOMPurify:
javascript复制import DOMPurify from 'dompurify'
const safeHtml = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'b', 'i', 'u', 'br']
})
- 移动端适配:
- 需要额外处理touch事件导致的选区变化
- iOS上需添加-webkit-user-select: text样式
- 虚拟键盘弹出时需要调整滚动位置
- 无障碍支持:
jsx复制<div
role="textbox"
aria-multiline="true"
aria-label="富文本编辑器"
tabIndex={0}
>
在实现过程中,最耗时的部分是光标位置与React状态的同步。我的经验是:当遇到光标异常跳动时,优先检查MutationObserver是否正确处理了DOM变化,其次确认选区保存/恢复逻辑是否覆盖了所有渲染路径。对于需要复杂格式的场景,建议采用类似Slate的插件架构,而非直接修改核心逻辑。
