从零实现富文本编辑器#-浏览器选区与编辑器选区模型同步
大家好,我是你们的技术博主。今天我们来聊聊富文本编辑器实现中一个非常核心、也最容易让人"掉头发"的问题------浏览器选区与编辑器内部选区模型的同步 。很多小伙伴一开始接触富文本编辑器,觉得不就是给 div 加上 contenteditable="true" 嘛,完事儿了。但当你真正动手做加粗、斜体、超链接、甚至自定义格式时,就会发现,浏览器自己那套选区(Selection/Range)规则,跟咱们想要的"文档模型"完全是两码事 。打个比方,浏览器选区是"物理坐标"------它告诉你"我选中了页面上第 3 个字到第 8 个字之间的这些 DOM 节点";但编辑器模型是"逻辑坐标"------它关心的是"我选中的是这段文本的第 2 个字符到第 5 个字符,中间跨了一个加粗节点"。要把这两者对齐,就必须建立一套映射机制 。### 一、为什么不能直接用浏览器选区?我们先看一个简单的例子。假设我们的编辑器内容是这样的:html<div id="editor" contenteditable="true"> <p>Hello <strong>World</strong>!</p></div>现在用户用鼠标选中了"World"这个词。浏览器给你的 Range 对象大概是:- startContainer:指向 <strong> 元素- startOffset:0- endContainer:指向 <strong> 元素- endOffset:5看起来挺简单对吧?但如果用户选中了"Hello Wo"(跨了 <strong> 的边界),那 Range 就变成了:- startContainer:指向 <p> 元素(或文本节点)- startOffset:0- endContainer:指向 <strong> 里的文本节点- endOffset:2这时你会发现,"偏移量"的含义在不同节点类型下完全不同 。文本节点的偏移是字符数,元素节点的偏移是子节点数。如果编辑器里嵌套了多层标签(比如 <span style="color:red"><b>xxx</b></span>),那这个偏移量的计算会让人崩溃。更麻烦的是,用户可能把光标放在空行上、图片旁边、甚至表格单元格里 ,这些情况浏览器给出的 Range 都会千奇百怪。所以,我们必须把浏览器的"物理选区"翻译成我们自己的"文档模型选区"。### 二、核心思路:双模型映射我们的编辑器内部,通常维护一个扁平化的文档模型 (类似 JSON 数组),比如:json[ { "type": "paragraph", "children": [ { "text": "Hello " }, { "text": "World", "bold": true }, { "text": "!" } ]}]而浏览器的 DOM 是我们根据这个模型渲染出来的。所以,同步的核心就是:1. 浏览器选区 → 模型选区 :当用户点击、拖拽、或通过键盘改变光标位置时,我们监听 selectionchange 事件,拿到 Range,然后通过 DOM 结构反推出它对应模型里的哪个位置(offset)。2. 模型选区 → 浏览器选区 :当我们在代码里设置选区(比如程序化地加粗后恢复光标)时,要能根据模型的位置,找到对应的 DOM 节点和偏移量,然后创建新的 Range 并设置到浏览器上。这两条路,就像是"翻译官"来回翻译。### 三、关键实现:从 DOM 到模型位置我们先来实现第一个方向:给定一个 DOM 节点和偏移量,如何找到它在模型里的位置。这里我以一个简化版为例(假设我们的编辑器只包含段落和行内文本,没有嵌套块级元素)。我们给每个文本节点加一个自定义属性 data-model-path,记录它在模型里的路径(比如 0-1 表示第 0 段第 1 个子节点)。这样查找就变得非常简单。javascript// 简化的模型const model = [ { type: 'paragraph', children: [ { text: 'Hello ' }, { text: 'World', bold: true }, { text: '!' } ]}];// 渲染 DOM(假设已有函数 renderToDOM 完成了构建,并给文本节点加了 data-model-path)function domToModelPosition(node, offset) { // 如果节点是文本节点,直接通过属性找到路径 if (node.nodeType === Node.TEXT_NODE) { const pathStr = node.parentElement.getAttribute('data-model-path'); if (pathStr) { const [blockIdx, childIdx] = pathStr.split('-').map(Number); // 计算该文本节点在模型中的起始字符偏移 let charOffset = 0; const children = model[blockIdx].children; for (let i = 0; i < childIdx; i++) { charOffset += children[i].text.length; } return { blockIdx, offset: charOffset + offset }; } } // 如果是元素节点,我们取它的第一个子节点(通常是最深的文本) if (node.nodeType === Node.ELEMENT_NODE) { if (node.childNodes.length > 0) { return domToModelPosition(node.childNodes[0], 0); } } // 兜底:返回起始位置 return { blockIdx: 0, offset: 0 };}// 使用时:const range = window.getSelection().getRangeAt(0);const start = domToModelPosition(range.startContainer, range.startOffset);const end = domToModelPosition(range.endContainer, range.endOffset);console.log('模型选区:', start, end);注意 :这个实现非常简化,真实编辑器里需要处理各种边界情况(比如空段落、光标在块级元素开头/结尾等)。但核心思想就是这样:给 DOM 节点打上模型标签,然后通过标签反向计算 。### 四、关键实现:从模型位置到 DOM 选区现在反过来,假设我们在代码里有了一个模型位置(比如 {blockIdx: 0, offset: 7}),要把它转成浏览器能识别的 Range。这个方向相对简单,因为我们控制渲染过程,可以保证 DOM 结构完全按照模型来 。所以我们只需要遍历模型,找到对应的文本节点和字符偏移即可。javascriptfunction modelToDomPoint(blockIdx, offset) { const block = model[blockIdx]; if (!block) return null; // 找到该块对应的 DOM 元素(假设我们给每个块元素加了 data-block-idx) const blockDom = document.querySelector(`[data-block-idx="${blockIdx}"]`); if (!blockDom) return null; // 遍历该块内的文本节点,累加长度 let remaining = offset; for (let i = 0; i < block.children.length; i++) { const child = block.children[i]; const childDom = blockDom.childNodes[i]; // 假设子节点一一对应 if (remaining <= child.text.length) { // 找到目标文本节点 return { node: childDom.firstChild, offset: remaining }; } remaining -= child.text.length; } // 如果 offset 超过了最后,就放在块尾 const lastChild = blockDom.lastChild; return { node: lastChild, offset: lastChild.textContent.length };}// 使用:设置选区function setModelSelection(startPos, endPos) { const start = modelToDomPoint(startPos.blockIdx, startPos.offset); const end = modelToDomPoint(endPos.blockIdx, endPos.offset); const range = document.createRange(); range.setStart(start.node, start.offset); range.setEnd(end.node, end.offset); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range);}### 五、同步的时机与注意事项同步不是无时无刻都在做,我们需要在合适的时机触发:- 用户交互时 :监听 selectionchange(注意这个事件会频繁触发),用 requestAnimationFrame 或 setTimeout 做节流,然后更新我们的模型选区状态。- 程序化修改时 :当我们执行加粗、插入链接等操作后,模型变了,DOM 会重新渲染,此时需要重新计算选区位置(通常根据之前保存的模型位置来恢复)。还有一个大坑:当 DOM 被修改时,浏览器会自动调整选区 。比如你加粗一段文本,DOM 结构变了,原来的 Range 可能会变得无效或指向错误的位置。所以在修改 DOM 之前,一定要先把浏览器选区转换成模型选区;修改完成后再把模型选区设回去 。### 六、总结浏览器选区与编辑器模型同步是富文本编辑器最核心的"地基"之一。我们通过双模型映射 ,把浏览器的"物理 DOM 坐标"和编辑器的"逻辑文档坐标"互相翻译。具体实现上:- 给 DOM 节点打上 data-* 属性来标识模型路径- 写两个函数:domToModelPosition 和 modelToDomPoint- 在用户交互和程序化修改时做好同步当然,真实世界的编辑器要复杂得多(比如处理嵌套列表、表格、图片、跨块选区等),但基本原理是不变的。一旦你掌握了这个映射思想,再去看 Quill、Slate 这些库的源码,就会觉得豁然开朗。希望这篇文章能让你对富文本编辑器的实现有一个更清晰的认识。如果你也在写自己的编辑器,不妨从这两个函数开始,慢慢构建你的"选区帝国"。我们下期见!