跳到主要内容

文本编辑器实现原理:光标、选区、输入法、撤销栈与大文档性能

面试速答(30 秒版 TL;DR)

  • 文本编辑器看起来像“输入框”,本质上是一个文档模型 + 布局渲染层 + 输入事件系统 + 历史记录系统
  • 核心问题通常有五个:数据结构怎么存文本、光标和选区怎么映射、输入法怎么处理、撤销重做怎么设计、大文档怎么保证流畅。
  • 小型编辑器可以基于 textarea / contenteditable 快速实现;专业编辑器通常会维护自己的文档模型和渲染层。
  • 文本存储常见结构有 gap buffer、piece table、rope;现代代码编辑器常偏向 piece table / rope 这类适合频繁插删的数据结构。
  • 真正常见难点不是“把字打进去”,而是组合输入、语法高亮、换行折叠、虚拟滚动、协同编辑这些边界场景。

先建立整体架构图

一句话解释:

  • 用户操作先变成“编辑意图”;
  • 编辑意图修改文档模型;
  • 渲染层再把模型映射成屏幕上的内容、光标和选区。

一、为什么专业编辑器不直接依赖 DOM

很多人第一反应是:

  • contenteditable 不就能编辑了吗?

它能做“可编辑”,但很难做“可控”:

  • DOM 结构会随浏览器编辑行为变化;
  • 跨浏览器细节多;
  • 选区、格式、撤销、输入法事件并不完全统一;
  • 文档很大时,直接操作大量 DOM 性能差。

所以工程上通常有三层路线:

路线适合场景特点
textarea 增强简单编辑器、代码编辑器雏形文本输入稳定,但富文本能力弱
contenteditable 包装富文本编辑器快速起步,但需要大量兼容修正
自研文档模型 + 自定义渲染专业代码编辑器 / 大型富文本成本高,但可控性最强

二、文本存储结构为什么重要

如果底层直接用普通字符串,每次插入 / 删除都可能导致整段复制,频繁编辑成本很高。

1. Gap Buffer

思路:

  • 在光标附近维护一个“空洞(gap)”;
  • 光标附近插入很快;
  • 光标远距离跳转时需要移动 gap。

优点:

  • 实现简单;
  • 单点附近连续编辑效率高。

缺点:

  • 多处随机编辑体验一般;
  • 超大文本场景伸缩性有限。

2. Piece Table

思路:

  • 原始文本只读保存;
  • 新输入追加到另一个 buffer;
  • 文档内容由一系列“片段引用”拼出来,而不是频繁改原文。

优点:

  • 插入删除代价低;
  • 很适合做 undo / redo;
  • 很多编辑器都偏爱这种思想。

3. Rope

思路:

  • 把大文本拆成树状片段;
  • 插入、删除、切分更容易控制复杂度。

优点:

  • 适合超大文本;
  • 随机位置编辑更平衡。

面试结论:

  • “小编辑器用字符串也能做。”
  • “专业编辑器往往要上 piece table 或 rope,否则频繁编辑和大文档性能都扛不住。”

三、光标和选区本质是什么

本质不是“一个竖线和一块蓝底”,而是:

  • 光标:文档中的一个逻辑位置;
  • 选区:两个逻辑位置之间的范围;
  • 渲染:把逻辑位置映射到屏幕坐标。

常见做法:

  1. 文档模型里用 offsetline + column 或路径坐标表示位置。
  2. 布局阶段把位置换算成像素坐标。
  3. 鼠标点击时再反向把坐标映射回文档位置。

这就是为什么编辑器需要维护:

  • 行索引;
  • 换行信息;
  • 字符宽度测量;
  • 折叠区、软换行、装饰层位置。

四、输入法(IME)为什么麻烦

中文、日文、韩文输入法不是“一按键就确定一个字符”,而是组合过程:

  • 开始组合:compositionstart
  • 组合更新:compositionupdate
  • 组合提交:compositionend

因此编辑器不能把每次 keydown 都当成最终输入结果。

一个安全心智模型:

  • 键盘事件描述的是“按了什么键”;
  • beforeinput / input 更接近“最终要插入什么内容”;
  • IME 组合阶段要允许临时态存在,直到提交为止。

如果这块处理不好,会出现:

  • 候选词重复插入;
  • 光标乱跳;
  • 撤销栈断裂;
  • 选区被错误覆盖。

五、撤销 / 重做怎么设计

最简单的做法是“每次保存整个文本快照”,但大文档会很浪费。

更常见的做法是记录操作:

  • 插入了什么;
  • 删除了什么;
  • 发生在什么位置;
  • 操作前后选区是什么。

这样 undo / redo 只需要反向应用操作。

面试常考点:

  • 撤销不是只还原文本,还要还原光标 / 选区。
  • 连续输入通常会做“合并事务”,否则按 10 个字母要撤销 10 次,体验很差。

六、大文档为什么容易卡

真正让编辑器卡的往往不是“字符串处理”本身,而是:

  • DOM 节点太多;
  • 每次编辑都整篇重新布局;
  • 语法高亮全量重算;
  • 滚动时一次性渲染所有行。

常见优化手段

1. 虚拟滚动 / 可视区渲染

只渲染当前视口附近的行,而不是整个文档。

2. 增量更新

改一小段文本时,只重算受影响的行和 token。

3. 分层渲染

文本层、选区层、光标层、装饰层分开,避免一次小改动引发全量重绘。

4. 异步化重任务

像语法分析、Lint、代码提示索引,可以放到 Worker 或后台任务,不阻塞主交互。


七、富文本编辑器和代码编辑器的差异

虽然都是“文本编辑器”,但目标不同:

维度代码编辑器富文本编辑器
关注点行号、缩进、补全、语法高亮段落、样式、图片、表格、嵌套结构
数据结构偏文本流偏树形文档模型
渲染难点大文件性能、光标定位富节点结构、跨块选区
扩展能力LSP、格式化、诊断Slate/ProseMirror 风格 schema、节点变换

面试时不要笼统说“文本编辑器都一样”,因为富文本和代码编辑器的内部模型差异很大。


八、协同编辑再往前一步是什么

如果继续深挖,就会进入协同编辑。

关键问题:

  • 两个人同时改同一段内容怎么办?
  • 远端操作怎么合并到本地光标和撤销栈里?

这里会涉及:

  • OT(Operational Transform)
  • CRDT(Conflict-free Replicated Data Type)

面试里知道到这层就够了:

  • 单机编辑器重点是本地数据结构和渲染性能;
  • 协同编辑器还要解决并发修改的一致性。

高频题标准答法

1. 为什么 contenteditable 不够

因为它给的是浏览器默认编辑行为,而专业编辑器要的是可预测、可控、可扩展的编辑模型。

2. 文本编辑器最核心的数据结构是什么

没有唯一答案,但大方向是“避免每次编辑都复制整段文本”,常见是 gap buffer、piece table、rope。

3. 光标定位为什么复杂

因为光标不是简单索引,它要和行列信息、折叠、软换行、字符宽度、屏幕坐标做双向映射。

4. 为什么中文输入法经常出 bug

因为 IME 输入是组合态,不是每个按键都对应最终字符;如果把 keydown 直接当最终输入,状态就会乱。


易错点 / 坑

  • 直接把 DOM 当唯一真相,后期很难维护复杂编辑语义。
  • 撤销只还原文本,不还原选区和光标。
  • 忽略 IME 组合输入,中文场景必出问题。
  • 全量渲染大文档,不做虚拟化和增量更新。
  • 语法高亮、Lint、补全都放主线程同步算,导致输入卡顿。

速记要点(可背诵)

  • 文本编辑器 = 文档模型 + 输入系统 + 渲染层 + 历史记录。
  • 专业编辑器核心不是输入,而是“如何高效维护可编辑文档”。
  • 数据结构常见:gap buffer、piece table、rope。
  • 难点集中在 IME、选区映射、撤销重做、大文档性能、协同编辑。