手戳实现文本编辑器:从输入事件到光标/选区/撤销的核心链路
在 Web 里做“文本编辑器”,表面看起来就是让用户能输入、删除、复制粘贴。
但一旦你希望它具备更接近产品的能力(光标稳定、选区不乱跳、中文输入法不炸、撤销/重做可控、粘贴可清洗),你就会发现:真正难的是把“浏览器事件”变成“可预测的编辑操作”。
这篇文档的目标是:带你手戳一个“最小但完整”的编辑器内核(以纯文本为例),并告诉你扩展到富文本/代码编辑器时要补哪些关键能力。
- 编辑内容:纯文本(支持换行)
- 渲染方式:DOM(一个
contenteditable容器) - 核心思路:浏览器只负责“输入设备事件”,内容与选区由我们维护
1. 先想清楚:你要做的是哪种“编辑器”?
常见有三类,它们的技术路线完全不同:
-
原生输入控件:
<textarea>
优点:输入法/撤销/选择/无障碍基本都帮你做好了;缺点:定制弱(高亮、块级结构、复杂排版难)。 -
富文本编辑器(contenteditable):Notion / 飞书文档 / Slate / Lexical / ProseMirror
优点:自然的光标与选区、排版能力强;缺点:DOM 变更难控,需要强约束和严谨的映射。 -
代码编辑器(自绘/虚拟渲染):VS Code/Monaco / CodeMirror
特点:通常用“隐藏输入层(textarea)+ 自己渲染视图(DOM/Canvas)”,追求大文本性能与精准控制。
本文选择第 2 类的入口(contenteditable),但用“纯文本渲染 + 自己维护状态”的方式,尽量让你看清最核心的链路。
2. 一个可落地的架构:事件 → 操作(Operation)→ 状态(State)→ 渲染
你可以把编辑器当作一个“小型状态机”:
关键点只有一句话:DOM 是视图,不是数据源。
只要你让 DOM 成为“单一事实来源”,后面就会出现各种不可控的边界行为(尤其是粘贴、输入法、撤销)。
3. 必懂事件:beforeinput 才是编辑器“总开关”
如果你想自己实现插入/删除/撤销,强烈建议从 beforeinput 开始(而不是只盯 keydown):
beforeinput:在浏览器真正改 DOM 前触发,适合preventDefault()后自己应用变更input:DOM 已经变了,再去读写往往更难“收拾干净”
你最常需要处理的几类 inputType:
insertText:普通输入(字母、数字等)insertLineBreak:回车换行deleteContentBackward:BackspacedeleteContentForward:DeleteinsertFromPaste:粘贴deleteByCut:剪切historyUndo/historyRedo:撤销/重做(是否触发取决于你是否让浏览器接管历史)
不同浏览器/输入法组合下,beforeinput 行为会有差异;尤其是中文输入法期间会产生 composition* 事件。实现时要把“输入法”当一等公民处理(后面专门讲)。
4. 选区是第二份状态:用 Selection API 把“光标位置”也变成数据
编辑器里最常见的 bug 就是:打字时光标乱跳、撤销后选区不对、点一下光标跑到奇怪位置。
根因通常是:你只保存了文本,却没保存“选区”。
建议把选区抽象成最简单的形式:
type SelectionRange = { start: number; end: number }; // [start, end)
start === end表示光标(折叠选区)start !== end表示选择了一段文本
Selection / Range 的入门与如何获取选区矩形定位浮层,可以配合阅读:
Selection API 与 getClientRects()
5. 手戳一个“最小可用”纯文本编辑器(核心代码)
5.1 HTML 与 CSS(一个可编辑容器)
<div id="editor" contenteditable="true" spellcheck="false"></div>
#editor {
white-space: pre-wrap;
word-break: break-word;
outline: none;
font: 14px/1.6 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
}
这里使用
white-space: pre-wrap,让换行与空格更接近“文本编辑器”的直觉。
5.2 State:文本 + 选区 + 历史
type SelectionRange = { start: number; end: number };
type EditorState = {
text: string;
selection: SelectionRange;
undoStack: { before: EditorState; after: EditorState }[];
redoStack: { before: EditorState; after: EditorState }[];
};
为了让示例更聚焦,我们用“快照式”历史(占内存,但好理解)。真实产品通常会用 operation 反向回放或增量结构。
5.3 Operation:对状态的最小变更单位
type Operation =
| { type: "insert"; text: string }
| { type: "deleteBackward" }
| { type: "deleteForward" }
| { type: "insertLineBreak" }
| { type: "setSelection"; selection: SelectionRange };
5.4 applyOperation:把操作作用到状态上
function clampSelection(textLength: number, sel: SelectionRange): SelectionRange {
const start = Math.max(0, Math.min(textLength, sel.start));
const end = Math.max(0, Math.min(textLength, sel.end));
return start <= end ? { start, end } : { start: end, end: start };
}
function replaceRange(text: string, sel: SelectionRange, insertText: string) {
const before = text.slice(0, sel.start);
const after = text.slice(sel.end);
const nextText = before + insertText + after;
const nextCaret = before.length + insertText.length;
return { nextText, nextSelection: { start: nextCaret, end: nextCaret } as SelectionRange };
}
function applyOperation(state: EditorState, op: Operation): EditorState {
const selection = clampSelection(state.text.length, state.selection);
if (op.type === "setSelection") {
return { ...state, selection: clampSelection(state.text.length, op.selection) };
}
if (op.type === "insert") {
const { nextText, nextSelection } = replaceRange(state.text, selection, op.text);
return { ...state, text: nextText, selection: nextSelection };
}
if (op.type === "insertLineBreak") {
const { nextText, nextSelection } = replaceRange(state.text, selection, "\n");
return { ...state, text: nextText, selection: nextSelection };
}
if (op.type === "deleteBackward") {
if (selection.start !== selection.end) {
const { nextText, nextSelection } = replaceRange(state.text, selection, "");
return { ...state, text: nextText, selection: nextSelection };
}
if (selection.start === 0) return state;
const delSel = { start: selection.start - 1, end: selection.start };
const { nextText, nextSelection } = replaceRange(state.text, delSel, "");
return { ...state, text: nextText, selection: nextSelection };
}
if (op.type === "deleteForward") {
if (selection.start !== selection.end) {
const { nextText, nextSelection } = replaceRange(state.text, selection, "");
return { ...state, text: nextText, selection: nextSelection };
}
if (selection.start === state.text.length) return state;
const delSel = { start: selection.start, end: selection.start + 1 };
const { nextText, nextSelection } = replaceRange(state.text, delSel, "");
return { ...state, text: nextText, selection: nextSelection };
}
return state;
}
5.5 渲染:用“一个 TextNode”让选区映射变简单
纯文本编辑器最省心的做法是:编辑区域里永远只有一个文本节点。
function render(editorEl: HTMLElement, state: EditorState) {
editorEl.textContent = state.text;
restoreSelection(editorEl, state.selection);
}
function restoreSelection(editorEl: HTMLElement, sel: SelectionRange) {
const textNode = editorEl.firstChild;
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) return;
const range = document.createRange();
range.setStart(textNode, sel.start);
range.setEnd(textNode, sel.end);
const selection = window.getSelection();
if (!selection) return;
selection.removeAllRanges();
selection.addRange(range);
}
为什么强调“一个 TextNode”?因为这样你就可以把:
Range.startOffset / endOffset
直接当作“字符下标”,不用做复杂的 DOM 遍历与映射。
6. 把事件翻译成 Operation:用 beforeinput 接管编辑
下面是一个最小版本的事件处理逻辑(示意):
let state: EditorState = {
text: "",
selection: { start: 0, end: 0 },
undoStack: [],
redoStack: [],
};
function commit(next: EditorState) {
const before = state;
state = next;
render(editorEl, state);
// 快照历史(示例写法:每次都 push)
state.undoStack = [...before.undoStack, { before, after: state }];
state.redoStack = [];
}
function readSelectionFromDOM(editorEl: HTMLElement): SelectionRange {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return state.selection;
const range = selection.getRangeAt(0);
const textNode = editorEl.firstChild;
if (!textNode || range.startContainer !== textNode || range.endContainer !== textNode) {
// 只处理“单 TextNode”场景:复杂 DOM 需要做映射
return state.selection;
}
return { start: range.startOffset, end: range.endOffset };
}
editorEl.addEventListener("beforeinput", (e) => {
// 输入法期间先别拦(后面第 8 节会讲更完整策略)
if ((e as InputEvent).isComposing) return;
const ie = e as InputEvent;
const selection = readSelectionFromDOM(editorEl);
let next = applyOperation({ ...state, selection }, { type: "setSelection", selection });
if (ie.inputType === "insertText" && typeof ie.data === "string") {
e.preventDefault();
next = applyOperation(next, { type: "insert", text: ie.data });
commit(next);
return;
}
if (ie.inputType === "insertLineBreak") {
e.preventDefault();
next = applyOperation(next, { type: "insertLineBreak" });
commit(next);
return;
}
if (ie.inputType === "deleteContentBackward") {
e.preventDefault();
next = applyOperation(next, { type: "deleteBackward" });
commit(next);
return;
}
if (ie.inputType === "deleteContentForward") {
e.preventDefault();
next = applyOperation(next, { type: "deleteForward" });
commit(next);
return;
}
});
readSelectionFromDOM 再 apply?因为“选区”可能是用户鼠标点出来的,也可能是键盘选出来的。
先把 DOM 里的选区同步回 state,才能保证操作作用在正确位置。
7. 粘贴、复制、剪切:永远优先 text/plain
对纯文本编辑器而言,粘贴最稳妥的策略是:
- 只读取
text/plain - 统一换行(把
\r\n变成\n) - 作为一次
insert操作插入
editorEl.addEventListener("paste", (e) => {
const text = e.clipboardData?.getData("text/plain");
if (typeof text !== "string") return;
e.preventDefault();
const normalized = text.replaceAll("\r\n", "\n");
const selection = readSelectionFromDOM(editorEl);
const base = applyOperation(state, { type: "setSelection", selection });
commit(applyOperation(base, { type: "insert", text: normalized }));
});
剪切(cut)也类似:你要自己删掉选区内容,并把文本写入剪贴板(受权限/浏览器限制,复杂度更高)。如果你不想踩坑,可以先只实现 copy/paste,把 cut 交给浏览器默认行为。
8. 输入法(IME)是硬骨头:别让中文输入“断流”
很多“手戳编辑器”在英文下工作正常,一到中文输入法就开始:
- 候选词窗口消失/闪烁
- 上屏内容重复插入
- 光标位置错乱
本质原因是:输入法合成(composition)期间,浏览器需要连续修改 DOM 来展示“临时文本”。
你如果在 beforeinput 一直 preventDefault(),相当于把输入法的“预编辑”通道掐断了。
8.1 最简单可用的策略(推荐从这里起步)
isComposing === true时:不拦截,让浏览器处理 DOM(保证输入法体验)compositionend后:把 DOM 的最终文本同步回 state(一次性收敛)
let composing = false;
editorEl.addEventListener("compositionstart", () => {
composing = true;
});
editorEl.addEventListener("compositionend", () => {
composing = false;
// 收敛:以 DOM 为准同步一次(纯文本场景)
const domText = editorEl.textContent ?? "";
const selection = readSelectionFromDOM(editorEl);
state = { ...state, text: domText, selection };
render(editorEl, state);
});
editorEl.addEventListener("beforeinput", (e) => {
const ie = e as InputEvent;
if (composing || ie.isComposing) return;
// ...其余逻辑同上
});
这套策略的缺点是:合成过程中你的“状态/历史”是不更新的,但它非常适合做第一版,把 IME 稳住后再逐步精细化。
8.2 进阶思路(了解即可)
- 识别
inputType: insertCompositionText,把“临时文本”作为 transient state(不入历史) - 将“合成文本”单独存放并渲染为特殊片段,compositionend 时再转为正式 insert
9. 撤销/重做:要么交给浏览器,要么自己全接管
只要你 preventDefault() 并自己修改内容,浏览器的原生撤销栈通常就不可靠了。建议二选一:
- 完全交给浏览器:不拦截编辑,让 DOM 自由变化,你只做监听与格式化(适合轻量需求)
- 完全自己实现:把所有变更都变成 operation,并维护 undo/redo(适合重度定制)
本文示例采用第 2 种。
一个简单的 undo/redo(快照版):
function undo() {
const last = state.undoStack.at(-1);
if (!last) return;
state = last.before;
render(editorEl, state);
state.redoStack = [...state.redoStack, last];
state.undoStack = state.undoStack.slice(0, -1);
}
function redo() {
const last = state.redoStack.at(-1);
if (!last) return;
state = last.after;
render(editorEl, state);
state.undoStack = [...state.undoStack, last];
state.redoStack = state.redoStack.slice(0, -1);
}
你也可以监听快捷键:
editorEl.addEventListener("keydown", (e) => {
const isMac = /Mac|iPhone|iPad/.test(navigator.platform);
const mod = isMac ? e.metaKey : e.ctrlKey;
if (!mod) return;
if (e.key.toLowerCase() === "z" && !e.shiftKey) {
e.preventDefault();
undo();
}
if (e.key.toLowerCase() === "z" && e.shiftKey) {
e.preventDefault();
redo();
}
});
快照式历史会非常吃内存。更常见的是记录 operation,并为每个 operation 生成 inverse operation(可逆操作),实现“增量撤销”。
10. 从纯文本走向富文本:你需要升级“数据结构与映射”
当你开始支持加粗、标题、列表、引用、代码块时,你就不再是“一个字符串”能解决了。通常会升级为“树结构”:
你需要解决的新增难题包括:
- 模型 ↔ DOM 的双向映射:选区落在 DOM 的哪个节点/偏移,对应模型里的哪段文本?
- 规范化(normalize):DOM 里出现了你不允许的结构怎么办?(比如粘贴进来一堆乱七八糟的标签)
- 分块渲染与性能:长文档不能每次全量 rerender
如果你准备继续深挖,可以优先把这两块吃透:
- 选区与定位:
Selection API 与 getClientRects() - 帧调度:
requestAnimationFrame(把“高频 UI 更新”放到合适的时机)
11. 常见坑速查(非常实用)
- 不要把
innerHTML当文本:你会引入 XSS、换行/空格不一致、以及粘贴污染。纯文本优先textContent。 - 选区恢复要在渲染之后:先 setSelection 再改 DOM,Range 立刻失效。
- 输入法优先级最高:先让中文输入稳定,再谈“优雅架构”。
- history 只记“有意义的边界”:连续输入可以合并成一个历史项,不要每个字符一个快照。
- 移动端要额外注意:虚拟键盘、触摸选择、长按菜单都会触发不同事件组合。
12. 面试高频问答(围绕“能落地”的回答)
1)实现一个编辑器,最核心的抽象是什么?
- 把用户行为归一化为 Operation(插入/删除/选区变更)。
- 用 State(内容 + 选区 + 历史)作为单一事实来源,再渲染到 DOM。
2)为什么很多编辑器要从 beforeinput 开始,而不是只监听 keydown?
beforeinput更接近“真正的编辑意图”(插入/删除/粘贴/撤销),跨输入方式更统一。keydown更像“硬件按键”,不等价于内容变更(输入法/移动端会更复杂)。
3)contenteditable 最大的坑是什么?
- DOM 变更不可控:浏览器会插入你没预期的节点结构(尤其是粘贴、回车、格式化)。
- 解决思路:自己维护模型,并对 DOM 做强约束/规范化。
4)编辑器里“选区”为什么必须进入状态管理?
- 选区决定操作作用的位置;只存文本会导致撤销/重做/重渲染后光标跳动。
- 选区本质是一段范围:
{start, end}。
5)如何把 DOM 选区映射成“字符下标”?
- 纯文本且只有一个 TextNode:
Range.startOffset/endOffset就是下标。 - 多节点/富文本:需要做 DOM 遍历与模型映射(通常维护 node key 与 offset 表)。
6)中文输入法为什么会把“手戳编辑器”搞崩?
- IME 合成期间需要不断修改 DOM 展示临时文本。
- 你如果
preventDefault()拦截所有输入,会阻断合成通道,导致候选词/上屏异常。
7)实现粘贴时为什么建议优先 text/plain?
text/html可能携带脏结构与样式,甚至安全风险。- 纯文本编辑器只需要干净文本;富文本也需要“白名单 + 清洗”。
8)撤销/重做是交给浏览器还是自己实现?
- 轻量需求:交给浏览器更省心。
- 重度定制:自己实现更可控(尤其是你拦截并手动改 DOM 时)。
9)快照式 undo 的缺点是什么?更好的做法?
- 缺点:占内存、难合并、长文档性能差。
- 更好:记录 operation 并生成 inverse operation(增量撤销)。
10)为什么很多代码编辑器不用 contenteditable,而是“隐藏输入 + 自己渲染”?
- 代码编辑需要极致性能、行号/高亮/折叠/虚拟滚动等能力。
- 自己渲染更可控;
contenteditable的 DOM 结构会成为性能与一致性瓶颈。
11)实现编辑器时,如何保证“渲染后选区不丢”?
- 渲染完成后,用 Selection API 恢复 Range。
- 或者在更新前记录“逻辑选区”(字符下标/路径),更新后再映射回 DOM。
12)如果要做富文本加粗(Bold),最小的实现路线是什么?
- 在模型里引入 mark(例如
bold=true)并作用到一段范围。 - 渲染时把带 mark 的片段包一层
<strong>,并维护选区映射与规范化逻辑。