我从年初开始动手写一个自研的轻量级代码编辑器(editor)内核,前后折腾了三个多月。目标很简单:做一个启动秒开、打开大文件不卡、编辑手感干净的编辑器,不套 Electron 外壳,不做成另一个“浏览器里跑 IDE”。做完之后最大的感受是,编辑器这个领域看起来简单,真正扎进去全是细节——文本模型、光标状态、IME 输入、渲染调度、撤销策略,每一层都能单独写一篇很深的文章。
这篇文章把整个项目的设计思路、核心模块的落地方案、以及我踩过的几个大坑原原本本记录下来。如果你也想从零写一个编辑器,或者正在为现有编辑器的性能问题头疼,这里面的内容可以直接抄作业。项目技术栈选的是 TypeScript + Canvas 自绘渲染,核心编辑逻辑没有依赖任何现成框架,前端领域的朋友会比较好上手。
1. 项目动机与需求边界
1.1 为什么非要自己造一个轮子
做之前我先盘了一下现有方案。Monaco 很强,但太重,光编辑器部分压缩完就有好几 MB,启动加载和内存占用都不适合轻量场景;CodeMirror 6 的模块化做得很好,但它对高度的定制和性能控制仍然隔了一层;至于市面上那些基于 contenteditable 的编辑器,中文输入法场景下简直是重灾区。
我想要的是一个“文本处理核心 + 渲染层”完全自己掌控的编辑器内核。这样任何一层出问题都能直接定位,不会像查第三方源码那样绕好几个弯。而且编辑器本身就是一个极好的性能试验场:键盘事件到屏幕像素的链路极短,但数据结构和渲染都要求极致性能,做一遍下来对前端性能的认知会刷新很多。
我给自己定了一个非常克制的范围:打开 10 万行代码文件首屏渲染低于 500ms,常规输入延迟低于 16ms,支持基础语法高亮、撤销重做、查找替换。不做插件系统,不做调试器,不做完整的 LSP 协议解析,因为那些都是独立的大工程。
1.2 功能取舍背后的逻辑
做编辑器最忌讳的就是“什么都想要”。我在第一版就把功能列表写成了两张:一张是做,一张是明确不做。
明确做的列表:文本编辑基础操作、行号显示、软换行、光标渲染、选区、复制粘贴、撤销重做、查找替换、按行虚化渲染、极简词法高亮。
明确不做的列表:插件体系、多光标、代码折叠、内置终端、完整 LSP。这些功能每一项都会反向影响核心数据结构的设计,比如代码折叠要求行模型支持折叠区间的动态映射,多光标要求命令系统能接受多个 selection 作为输入。如果第一版就考虑这些,核心模型的复杂度会翻倍,我大概率会中途放弃。
这种边界意识也是整个项目能做完的关键。编辑器不像普通 Web 页面,它需要长期稳定运行、快速响应,用户对细节的敏感度极高。从最小闭环开始,做成一个真正好用的核心,比做一个到处是洞的大杂烩有价值得多。
2. 核心数据结构:文本模型与光标设计
2.1 文本存储为什么选了 Gap Buffer
编辑器最底层的问题就是:文本内容以什么结构存在内存里。最早我试过直接把整个文件读成一个大字符串,输入时用字符串拼接。简单是简单,但 10 万行文本的中间位置插入一个字符,最坏情况要搬运 1MB 以上的内存,实测在低端手机上肉眼可见的卡顿。
后来我换成了 Gap Buffer,这是很多经典编辑器都在用的方案,比如古老的 Emacs 就类似这种结构,现代编辑器也有很多在变种使用。它的核心思想很朴素:在一段连续内存里维护一个 gap(空隙),光标在哪个位置,gap 就挪到哪个位置。插入时如果 gap 空间够,就直接往里写,类似打字的文本输入过程,不需要移动大量数据。
我把 Gap Buffer 的插入和删除都设计成均摊 O(1) 复杂度。本质上就是维护两个字符数组,左边和右边,移动光标时需要把字符在左右区之间搬运。为了减少搬运频率,我把 gap 的初始容量设成 4KB,并且在 gap 被填满时按 1.5 倍扩容,这样连续输入场景下的扩容次数很少。
这里有个很关键的启发:很多前端同学习惯了“万物皆数组、插入用 splice”,但 splice 内部就是 O(n) 的内存移动,对长文本就是性能杀手。写编辑器最大的思维转变就是,每一处操作都要考虑摊还分析和最坏情况。
2.2 行索引与行号的计算
有了全局文本缓冲区还不够,编辑器的很多操作都基于“行”。行号计算、行首行尾跳转、语法按行高亮、渲染按行绘制,全部依赖一个快速的行定位索引。
我的方案是维护一个排好序的行偏移量数组,数组里存的是每一行在全局缓冲区中的起始 offset。由于大多数编辑操作都发生在光标附近,行索引的变化是局部的,所以我在每次编辑后不做全量重建,而是只把受影响的行边界更新一下,这个操作均摊是 O(m),m 是被改动行附近的行数,实际使用中接近 O(1)。
为了把行号从 offset 映射成行号,我实现了一个二分查找。10 万行文本的查找只需要约 17 次比较,性能完全不是问题。真正让我头疼的是结构体的同步问题:缓冲区内容变了、行索引也要变、语法 token 缓存也要变、选区还要修正,这四者必须在一个事务里完成,否则滚动高亮和光标位置就会对不上。
2.3 光标不是一个坐标,而是一组状态
初学编辑器实现时,很容易把光标理解成“屏幕上一个 x,y 坐标”。实际远没有这么简单。文本编辑器的光标最少要承载三层信息:插入位置在缓冲区里的 offset、光标的视觉偏好列(preferred column)、以及当前选中区域的两端锚点。
我实现的光标模型是三态:anchor、focus、caret。anchor 是选区固定端,focus 是活动端,caret 是视觉上光标所在的位置。做纯粹的单光标编辑时三者重合,但一旦涉及 Shift+方向键扩展选区,anchor 就留在原地,focus 移动;涉及鼠标拖拽选定时,anchor 和 focus 分别绑定到鼠标按下和当前指针位置。
还有一个小细节:当光标上下移动时,需要记录 preferred column。比如一行很长,你光标在第 100 列,按下箭头移动到短行,这行只有 60 列,光标会停在行尾。如果你不记住 100 这个偏好列,再按上箭头回去时光标就不能回到原来的位置了。
代码层面的光标模型大概长这样:
interface CursorState { anchorOffset: number; // 选区锚点 focusOffset: number; // 选区焦点 preferredColumn: number; // 上下移动时的偏好列 visualMode: 'bar' | 'block' | 'underline'; }2.4 撤销重做:命令合并与事务边界
撤销系统我一开始用最经典的命令模式:每个操作都实现 do 和 undo 两个方向。但很快发现一个问题:用户按住键盘连续输入一个单词,会产生几十个单字符命令,如果每按一下撤销都只退一个字符,操作体验就很反人类。
我设计了一套命令合并策略,核心规则是三条:同一光标位置的连续文本插入合并为一个事务;同一选区上的连续删除合并为一个事务;输入法组合态产生的连续插入合并为一个事务。每次合并的边界会被记录成一个 CommandGroup,组内还有具体的光标快照。
实战中我遇到的主要问题是:撤销时选区丢失。比如你先选中一起文本然后删除,再撤销,期望的结果是选区恢复、文本重新出现。如果命令里没有保存选区的 before 和 after,undo 之后选区就变成一个纯光标,非常难受。这个坑浪费了我一个周末,最后解决方案是在每个命令里同时记录 selection 的完整前后状态,才能做到无损恢复。
3. 渲染层与交互通道的实现
3.1 为什么渲染层选择 Canvas 自绘
渲染方案是编辑器技术栈里的一个关键分叉。DOM 渲染的好处是浏览器原生排版、语义化清楚、CSS 控制样式方便;坏处是文本排版性能有上限,尤其是超长行、混合字体、连字场景,很容易出现布局抖动。而 Canvas 自绘的好处是渲染完全自主控制,每次重绘只画可视区域,性能确定性更高;代价是一切都要自己算:字体度量、换行、行高、滚动。
我最后选了 Canvas 自绘为主,同时用一个隐藏 textarea 作为输入代理。这是一个很常见的混合架构:真正的输入事件和 IME 都发生在 textarea 上,但用户看到的内容全部来自 Canvas 绘制。textarea 覆盖层被做成永远跟随光标位置,这样移动端键盘弹起和桌面浏览器 IME 候选窗口定位都能正常工作。
这套架构下最需要保证的就是:textarea 的屏幕位置与 Canvas 里光标的位置严格一致。任何偏差都会导致输入法候选框错位,这是所有自绘编辑器最容易翻车的地方。
3.2 文本渲染与字体度量
Canvas 的 fillText 接口本身很简单,但要把文本渲染到正确的位置,需要搞清楚每个字符占多宽。浏览器提供了 measureText 接口,可以测量字符串宽度,但性能很差——测量一个字符就要发起一次底层调用,上万字符根本扛不住。
而且等宽字体(比如 Fira Code、JetBrains Mono)在实际渲染时并非每个字形都严格等宽,连字 ligature 更是会把几个字符合并成一个特殊字形,导致字符数跟累进宽度完全脱钩。比如=>在 Fira Code 里显示成一个箭头符号,measureText('=>') 的宽度不等于 measureText('=') 加 measureText('>')。
我的方案是建立两层度量缓存:第一层按代码点缓存最常用的 ASCII 字符宽度,因为普通代码 95% 都是 ASCII;第二层对连续相同字重字体的文本块做整体测量,块内不再逐个字符测量。这种方式把绝大多数绘制路径的测量耗时降到了原来的 1/10 左右。
3.3 虚拟化渲染与脏区域
编辑器窗口只有几千像素高,可视区大概能展示 50 行左右,但文件有 10 万行,全量绘制肯定是灾难。虚拟化渲染的思路是:只计算并绘制当前视口内可见的那几十行,其他行完全不碰。
但这里有个容易忽略的细节:如果每帧都重新计算可见行范围并全量绘制,那纯文本滚动倒是没问题,可一旦涉及语法高亮的 token 缓存读取、行号重新排列,仍然会浪费大量计算。所以我引入了脏区域机制:编辑操作只标记受影响的矩形区域为“脏”,渲染循环只重绘脏区域,其他区域直接保留上一帧的像素。
从 visble range 到 dirty rect 这套体系,本质上跟游戏引擎的做法完全一致。做之前我觉得这是小题大做,做完发现没有脏区域机制,稍微复杂一点的编辑操作都会在快速输入时露馅。
3.4 输入通道与事件时序
编辑器的键盘输入链路比想象中复杂得多。用户按下键盘 → 系统产生 keydown → textarea 触发 beforeinput/input → IME 可能插入组合态文本 → 编辑核心应用变更 → 脏区域更新 → 渲染。这一步中间任何一环的顺序错了,中文输入法或者日韩输入法就都会出问题。
我的实现是把 keydown 只做前置拦截:如果检测到是组合键或方向键,提前处理并 preventDefault;如果只是一个普通字符键,不拦截,让 textarea 走原生的 input 流程,然后在 input 事件里读取 value 变化,再应用到底层文本模型。这样做最重要的是保证了 IME 的组合态不会被打断,因为一旦你拦截了 keydown 并自己插入文本,输入法就会彻底混乱。
IME 的细节还需要单独描述:中文输入法在组合态会连续触发 input 事件,每次发送中间状态。最后一次触发才代表确定提交。主编辑器绝不能中间渲染出半截拼音,必须跟踪一个 composing 状态位,在组合态期间只更新预览,不写入底层文档。
4. 性能优化与大数据量策略
4.1 启动与首屏渲染:分帧加载与懒计算
性能优化的第一个战场就是打开大文件。传统做法是把整个文件读完再解析再显示,但 10 万行文件读进来可能就是几 MB,同步处理分分钟卡死浏览器主线程。
我的方案是分帧加载:文件读取走异步,数据每次只切一小片交给文本模型,然后用 requestIdleCallback 在浏览器空闲时间继续加载。为了保证首屏尽快出现,我设计了优先级调度:光标所在位置(初始是文件头部)所在行优先进入渲染队列,用户马上能看到内容;后台继续把剩余行灌入 Gap Buffer。
实测下来,10 万行文件首屏出现时间控制在 420ms 左右,完整加载完毕大约 1.2 秒,这期间用户可正常编辑。相比以前同步加载方式的 2-3 秒白屏,体验是质的飞跃。
4.2 输入延迟优化:从按键到像素的链路
编辑器最重要的体感指标就是输入延迟。从 keydown 到屏幕上出现字符,理想目标是 < 16ms(一帧的时间)。
我排查性能瓶颈用的是 Chrome DevTools 的 Performance 面板,录制一次键盘输入事件,观察主线程的每一段耗时。第一次实测结果让我很崩溃:一次纯字符输入从事件到绘制花了 40ms 以上,根本不可接受。定位下来有三个原因:一是输入时触发了一次全量行索引重建,O(n) 操作在 10 万行上去几十毫秒;二是语法高亮的 token 行缓存没有命中,每次编辑都重新跑一轮正则;三是渲染循环里对整行的字体度量没有走到缓存分支。
逐一修复之后(局部更新行索引、token 缓存失效范围缩到最小、强制命中字体宽度缓存),输入延迟降到了平均 8ms 左右。键盘连续快速输入时,肉眼能看到字符跟手,不再有拖影和卡顿。
优化前后的数据对比:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 10 万行首屏渲染 | 2200ms | 420ms |
| 普通输入事件主线程耗时 | 40ms | 8ms |
| 快速连续输入帧率 | 明显掉帧 | 稳定 60fps |
| 10 万行内存占用 | 约 48MB | 约 31MB |
4.3 Web Worker 的重用与边界
我把文本加载和语法高亮的初始解析都丢到了 Web Worker 里,避免大数据量的处理阻塞渲染进程。但踩了一个很重要的坑:Web Worker 里不能共享主线程的 Gap Buffer,因为结构化克隆会把整个缓冲序列化一份,代价甚至比同步处理还大。所以我的做法是 Worker 只负责“文件预处理”——读数组、按行切分、生成初始行索引和 token 缓存的数据结构,然后再一次性克隆到主线程当作初始状态。后续的增量编辑全部在主线程完成,Worker 就不用了。
如果把整个文本模型都放到 Worker 里做,所有编辑操作都要走 postMessage 通信,延迟会高出不少。编辑器这种高频低延迟场景,还是应该把核心模型留在主线程,Worker 做一次性重活更合适。
5. 常见问题排查与避坑实录
5.1 输入法候选框跳动错位
这是自绘编辑器最经典的翻车现场。表现为:中文输入时,候选框不是跟随光标,而是跑到左上角或上一次光标位置。
排查下来根因是 textarea 的视觉位置没有在 IME 组合态期间保持同步。我在组合态开始时隐藏了文本的重新绘制,导致光标视觉位置和 textarea 实际坐标脱节,输入法依据的是 textarea 的位置确定候选框。修复方案:每个渲染帧都强制把 textarea 移动到当前光标的位置,即使文本没有变化也同步坐标。
修复后中文输入法在 Windows 和 macOS 上都表现稳定,候选框跟随光标,不再跳动。
5.2 软换行导致光标定位错乱
开启软换行(长行自动折行)后,屏幕上的行和文本逻辑行不是一一对应的。光标按屏幕坐标换算时,如果用逻辑行号做坐标参照,就会出现明明点击的是第 10 屏行,光标却跳到第 8 逻辑行。
我的解决方法是建立 screenLineIndex → { logicalLine, offsetInLine } 的映射表,每次重绘时重构这个映射。光标定位问题本质上是两套坐标系(逻辑坐标系和屏幕坐标系)的互相转换,映射表是唯一可靠的桥梁。
5.3 撤销栈把连续输入拆成几百步
这个之前提过,是命令合并规则的锅。修复后我制定了更严格的合并条件:不仅要求位置相同,还要求输入时间连续(两次输入间隔小于 800ms)。这样用户暂停思考再继续输入时,撤销也会分成合理的步骤,不会一次撤销整个段落。
这里要注意,合并规则过强会导致撤销粒度太大,过弱又会导致步骤太碎。我的经验是“间隔时间 + 位置 + 操作类型”三条件同时满足才合并。
5.4 Canvas 字体加载导致光标偏移
Canvas 在字体文件尚未加载完成时,会使用 fallback 字体测量文本宽度。用户如果设置了自定义等宽字体,在字体异步加载期间输入字符,会出现光标和实际渲染位置逐渐偏移。
解决办法是监听document.fonts.ready,字体加载完成后强制重建整行字体度量缓存,并触发一次全量重绘。这期间的输入操作全部冻结光标偏移量修正记录,等字体就绪后统一恢复。
5.5 CRLF 与 LF 混用引发的行尾处理问题
Windows 写的文件用 CRLF 换行,macOS/Linux 用 LF。如果打开文件时不做统一转换,移动光标时经常多走一个位置。我在加载文件时统一转成\n,保存时再根据配置转回去。还有一个隐藏坑:从浏览器粘贴的文本可能混有\r\n,粘贴处理器也必须做规范化。
我写了一个测试用例,专门混入各种换行符来验证光标偏移逻辑,目前所有用例都能稳定通过。
5.6 排查问题速查表
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 中文输入候选框错位 | textarea 坐标未与光标同步 | 每次渲染帧同步 textarea 位置 |
| 软换行时光标乱跳 | 逻辑行与屏幕行映射缺失 | 建立 screenLine 映射表 |
| 撤销步骤太碎 | 命令合并规则过弱 | 位置+时间+类型三条件合并 |
| 光标与字符逐渐偏移 | Canvas 字体异步加载 | fonts.ready 后重建度量缓存 |
| 打开大文件白屏 | 同步读取解析阻塞主线程 | 分层分帧加载,优先渲染光标行 |
| 粘贴内容换行符混乱 | CRLF/LF 未统一 | 统一转 LF,保存时按配置转换 |
6. 从编辑器内核还能往哪走
6.1 增量语法树与语义高亮
目前我的语法高亮还是正则级别的词法高亮,够用但不聪明。下一步打算接入 tree-sitter 这样的增量解析器,把语法分析做成真正的增量计算,让高亮能够做到“非局部变化”,不再因为一个字符串没闭合就把后续整块代码都标红。
树级解析还能带来很多高级能力,比如结构匹配、缩进自动调整、代码块检测,都是非常实用的扩展方向。
6.2 多光标与输入增强
多光标是很多现代编辑器的杀手级功能。它要求命令系统支持多个选区同时执行,且所有选区的操作要保证原子性。实现上难点在于命令执行时如何同步维护多个光标的运动,以及撤销重做如何记录多个选区的前后状态。
这个功能对核心模型的侵入并不大,但边界情况很多(比如选区重叠合并、输入法在多光标下怎么表现),是编辑器做得更深之后一个好的进阶方向。
6.3 轻量模块化架构的启示
整个项目下来我最大的感悟是:一个复杂系统能不能做好,不取决于你堆了多少功能,而取决于核心模型是否足够干净。编辑器的一切都是围绕“文本模型 + 输入通道 + 渲染调度”这三个核心转的,功能可以一层层叠上去,但核心必须稳定。
现在如果有同事问我“想学前端性能该练什么”,我一直推荐写一个简单的文本编辑器。因为它问题清晰、反馈即时代、性能指标非常容易量化,是少有的既能练算法、练架构、又能练性能调优的复合型小项目。做一次编辑器,比做十个普通管理后台学到的东西都多。
最后说一个实操中的小技巧:给编辑器的所有核心操作接口都加上 trace log,调试时打开日志追踪,能省掉无数个“看着正常就是找不到 bug”的夜晚。我用这个方法排查了大半的诡异问题,非常值得做编辑器内核的同学采纳。