ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零实现轻量级代码编辑器:核心原理、语法高亮与踩坑实战

从零实现轻量级代码编辑器:核心原理、语法高亮与踩坑实战 想亲手写一个编辑器这事听起来门槛高但拆开来看就是“文本状态”加“视图渲染”加“输入处理”这三块肌肉在协同工作。我最初在兴趣驱动下花了一个周末撸了一个能用的Markdown编辑器踩了不少坑也把编辑器和编译器到底差在哪弄明白了。这篇文章会从零拆解一个编辑器的完整构造思路用Web技术实现一个具备语法高亮、撤销重做、输入法兼容的轻量级代码编辑器内容既适合前端刚入门的朋友也适合想理解编辑器底层原理的后端同学更重要的是它能让你对着“编辑器”三个字不再觉得神秘。我一直觉得编程领域被过度神话的组件不多编辑器算一个。很多人天天用VS Code、Vim、Sublime却从没想过自己实现一个。实际上写一个编辑器真正锻炼的是系统工程能力数据建模、事件驱动、DOM操作、性能优化甚至还有一点编译原理的影子。你不需要造出IDE或者支持插件化架构的庞然大物只要能把“光标移动、输入字符、语法高亮、撤销重做”这四样做好你就已经掌握编辑器核心模型了。1. 编辑器与编译器的根本区别别在一开始就搞混1.1 编辑器是什么编译器又是什么要写编辑器第一件事是搞清楚你写的不是编译器。从热搜词里能看到“编译器和编辑器的区别”这说明很多人确实容易把这两个词搅在一起。编辑器Editor负责的是文本的接受、展示、修改它关心的是“你怎么把字符放进缓冲区怎么在屏幕上呈现它们”。编译器Compiler负责的是把一种语言翻译成另一种语言关键词是“语法分析”“语义分析”“代码生成”。一个非常直白的类比编辑器是Word编译器是翻译官。你用Word写中文稿件Word不会帮你把中文翻译成英文它只负责让你能打字、删字、排版。编译器则是一台翻译机器你给它一段符合语法规则的源代码它输出目标代码。这个边界不划清楚后面设计架构的时候就会走偏比如试图在编辑器内部直接做语法树分析而不是只做语法高亮。1.2 编辑器的最小模型任何编辑器不管长什么样底层都逃不开一个核心数据结构文本缓冲区。这个缓冲区可以是字符串、数组、行块链表、piece table分段表甚至是一棵rope树。Vim和Emacs用了不同的数据结构VS Code也有一套自己的高性能文本模型。从最简单的角度建模把文本看作一个字符数组光标看作一个位置索引。每次输入就是在数组的某个位置插入若干字符每次删除就是移除一段区间。然后在此基础上扩展出选区、撤销栈、多光标。这就像在Python的list上操作一样直觉但在性能上会有瓶颈所以成熟的编辑器会使用行缓存、分段树等优化。我们作为入门者第一版先用朴素模型理解原理最重要。1.3 一篇文章、一个编辑器的能力边界我在实战中把目标锁定为“写一个支持Markdown语法的轻量级编辑器”而不是直接去复刻VSCode。你选择的第一个编辑器项目越小越能坚持做完。Markdown编辑器天然包含两大部分编辑区纯文本输入和预览区解析渲染成HTML。这恰好能拆开“文本操作”与“渲染展示”两个层面还能让你顺手理解“编辑器解析器”的协作关系这也是未来你把编辑器接到任何编译器前端时的工作模式。2. 技术选型为什么我放弃了contenteditable和textarea裸奔2.1 三种常见编辑区的实现方案对比实现编辑区Web技术栈下基本有三个思路直接用textarea、用contenteditable、自己用DOM或Canvas画一个视图层。textarea是最简单的自带光标、输入、复制粘贴、移动端键盘适配但缺点也明显无法在文本上自由叠加复杂样式语法高亮很难实现很难做自定义布局而且不同浏览器对textarea的内部事件行为并不完全一致。可以把它看作“编辑器的壳”但不适合做高端定制的引擎。contenteditable则是HTML自带的“可编辑区域”它能让你在div里直接编辑富文本浏览器会保留排版结构。听起来很美好但实际是深坑——浏览器会偷偷产生各种DOM节点插入图片、粘贴内容时可能带上一堆垃圾标签处理选区需要用复杂的Range API撤销栈经常错乱。我试过用contenteditable写富文本编辑器结果光清理脏标签就耗费了大量时间。如果是做像知乎回答框那种富文本编辑器contenteditable是绕不开的老大难但对代码编辑器场景它不是个好选择。第三种方案是完全自己控制用普通div做容器监听键盘事件手动维护文本状态和光标位置把渲染工作全部交给js控制。这就是VS Code走的路线只不过它用Canvas渲染。这种方案前期工作量大但每一个细节都可控代码编辑器的语法高亮、行内代码块、代码折叠都能干净实现。2.2 我的选型可编辑的div透明文本层方案最终我采用了一个折中方案不直接使用textarea而是用一个隐藏的textarea或contenteditable作为输入源在上面覆盖一个渲染层渲染层用pre元素来展示带高亮样式的文本同时透明地接收键盘输入。这是很多代码编辑器早期采用的“overlay”方案。流程是这样的真实的输入源是一个透明存在的textarea它的内容和文本缓冲区实时同步。用户看到的则是渲染层里带span高亮的HTML。每次输入textarea触发input事件我更新文本模型再把模型渲染成高亮HTML到pre中。这一步打通后后面的光标、撤销都好办了因为我可以完全控制数据流。我的项目中Markdown预览区直接使用marked解析器处理文本并渲染编辑区只负责维护原文焦点也在textarea中。2.3 编辑器与IDE的边界不要过早试图集成代码补全、调试器、文件树IDE是在编辑器基础上叠加大量工具链而成的产品。编辑器可以只处理纯文本然后在文本上做一层语法着色。比如写一个支持Python语法高亮的编辑器你不需要解释Python代码只需要按照正则规则把关键字加上class。意思就是“编辑器不管代码对不对只负责画颜色”。你如果一开始就把编辑器定位成IDE会陷入依赖注入、语言服务器协议LSP等复杂话题撑不住一个周末就能做完的项目。3. 核心难点光标、选区、输入法每一个都是隐藏的坑3.1 光标和选区为什么这么难编辑器的灵魂在于光标点一下鼠标光标到指定位置输入文字它出现按退格它左边字符消失。听起来简单难点在于光标是“可见的”且“可定位的”。textarea自带的selection可以返回start和end操作时直接用setSelectionRange就可以跳到定位。但在overlay方案里textarea光标和渲染层文本是两套坐标系textarea里的位置字符偏移和渲染层里HTML节点的位置DOM节点需要一一对应。如果你在渲染层插入了一个span高亮标签偏移量就会被标签本身占据一不小心就对错位置。我采用的办法是只在textarea上维护光标输入后从textarea读取start和end然后用这个偏移值去文本模型里索引渲染层完全不做选中处理。用户视觉上会看到textarea里的文字是透明的但高亮层是用模型重新生成的所以不会出现偏移错乱。3.2 处理组合输入法IME的惨痛经验如果你不做中文输入适配编辑器会在输入拼音时活活“卡死”。早期版本里每次input事件我都强制重新渲染整个文本结果当用户正在输入中文拼音时textarea中会出现一段“未提交”的拼音字符但我的文本模型并没有真正收到最终汉字导致高亮层和拼音状态互相冲突。正确做法是监听compositionstart、compositionupdate和compositionend三种事件。在组合候选期间停止立刻同步渲染在compositionend之后一次性读取textarea的value并同步到模型。换句话说把输入法过程当作一个原子操作。这个细节是开发编辑器的人常踩的坑但不写编辑器的人完全不会感知。我在写博客时也经常卡在这最后通过状态锁变量解决。3.3 撤销重做的实现命令模式撤销重做不能简单缓存历史字符串快照那样又慢又笨。更通用的做法是命令模式把每次修改抽象为一个命令对象包含undo操作和redo操作。例如insert命令undo时从指定位置删掉插入的字符串redo时再插回去。delete命令则要记录被删除的内容和位置。我实际写的时候定义了一个栈结构push到撤销栈同时清空重做栈。每次input事件里根据状态对比计算出这次操作改变的区域生成一个“插入”或“删除”命令。这样CtrlZ和CtrlY就能精准工作。为了减小命令数量我还会合并短时间内连续输入的命令比如你连续打了10个字母它们应该被合并成一个事务transaction否则一次撤销只会删掉一个字母体验很碎。4. 实战从零实现一个Markdown编辑器4.1 项目规划和文件结构我用原生JavaScript完成没有使用框架目的就是为了看清核心逻辑。整个项目分成四个模块index.html // 页面结构 style.css // 布局和视觉 editor.js // 核心编辑器类管理文本模型、输入、光标、渲染 markdown.js // 调用marked.js或其他解析器进行预览渲染在index.html中我做了两栏布局左侧编辑区放一个透明textarea和渲染层右侧是预览区。textarea透明但文字颜色设为透明同时光标不能隐藏否则看不到光标位置。CSS里我用caret-color: transparent会隐藏光标所以不能这样写正确做法是让textarea文字颜色透明但caret-color保持可见。实际结构是这样div classeditor-container div idcodeLayer classcode-layer aria-hiddentrue/div textarea idinputArea classinput-area spellcheckfalse autocompleteoff/textarea /div div classpreview-container div idpreview classpreview/div /divtextarea覆盖在code-layer上方但背景透明文字透明因此用户看到的是下面的高亮层。textarea仍然接收所有键盘和鼠标事件这是最关键的布局机制。4.2 文本模型与同步策略我先定义了一个简单模型类包含content字符串、以及当前选区的start、end。每次textarea的input事件触发后我把textarea.value赋值给model.content然后重新调用render()。class EditorModel { constructor() { this.content ; this.start 0; this.end 0; } getSelectedText() { return this.content.slice(this.start, this.end); } replaceRange(text, start, end) { return this.content.slice(0, start) text this.content.slice(end); } }这里要注意的坑是textarea的value变化不只是由键盘输入引起右键菜单粘贴、拖拽文本、手机输入法都会触发input事件。所以不要假设每次都是“插入一个字符”要对input事件做统一处理。我用一个pendingSync标志位来避免composition期间同步但最终处理逻辑是一样的将textarea的当前value同步给model然后根据旧的value和新的value计算出diff生成undo命令。计算diff不一定要得特别精细简单做法是记录旧文本和新文本以及光标位置生成整体替换命令但为了性能我选择更小粒度的diff比如使用“最长公共前缀/后缀”法来提取插入和删除的具体区域。这段提取插入/删除区域的代码逻辑如下比较旧值oldValue和新值newValue找到第一个不同的位置prefix再从尾部找到第一个不同的位置suffix最终中间就是修改差异。如果是新增oldValue中那段为空如果是删除newValue中那段为空。这让undo命令非常精确。4.3 语法高亮的实现正则DOM节点语法高亮是通过分词和着色完成的。Markdown语言的语法高亮我用正则匹配加粗、斜体、代码块、标题、链接等并生成HTML片段。最关键的是渲染出来的HTML要放在pre中同时必须保证用户看不到原样文本。具体步骤是function highlightMarkdown(text) { // 必须先转义HTML实体防止用户输入变成标签 let escaped escapeHtml(text); // 然后做Markdown语法替换为每个匹配添加span escaped escaped .replace(/^### (.*)$/gm, span classheading3$1/span) .replace(/^## (.*)$/gm, span classheading2$1/span) .replace(/^# (.*)$/gm, span classheading1$1/span) .replace(/\*\*(.*?)\*\*/g, strong$1/strong) .replace(/([^])/g, code$1/code); return escaped; }注意顺序问题先转义HTML再做Markdown正则替换。如果顺序反了用户输入的script会被HTML标签解析成真实节点引发安全问题。在所有编辑器实现中转义永远是第一道防线。然后是渲染性能我一开始对整个文本每次都做全量replace和innerHTML赋值几行文本没问题但当文本量到几百K的时候每次输入都延迟明显。后来我加了节流把渲染操作放到requestAnimationFrame中执行并只在文本内容真正变化时渲染。对于更大的文本量应当只渲染视口内可见的行这是下一步优化空间。4.4 快捷键扩展与选区操作Tab键默认会在textarea中切换焦点但编辑器里Tab应该插入缩进。我用keydown事件接管Tab键inputArea.addEventListener(keydown, (e) { if (e.key Tab) { e.preventDefault(); insertText( ); // 插入两个空格 } if (e.key Enter) { e.preventDefault(); // 取出当前行前面的缩进自动补全新的缩进 const lineStart model.content.lastIndexOf(\n, model.start - 1) 1; const indent model.content.slice(lineStart, model.start).match(/^\s*/)[0]; insertText(\n indent); } });这个自动缩进逻辑是很多编辑器体验的分水岭。没有它写完一行回车上不自动对齐写代码会非常痛苦。在这里你也能看到为什么需要维护模型而不是依赖textarea自带行为——因为textarea根本不知道什么是缩进。4.5 Markdown编辑器的实时预览如何衔接当model.content更新时我用同样内容调用marked.parse把返回的HTML放入预览区。这里也踩过一个大坑编辑区的高亮和预览区是两套解析体系如果不小心把预览渲染后的HTML放回编辑区会产生无限循环更新。所以必须保证renderEditor()里只用原始文本永远不去调用预览解析器。预览区的滚动联动也是常见需求编辑区滚动时预览区可以跟着滚动。这里简单做的是监听textarea的scroll事件等比映射到预览区的scrollTop。不过textarea和pre的滚动条不一致实际上我采用的方式是在textarea滚动时同步设置code-layer的scrollTop否则高亮层和输入层会出现错位。这个联动看起来是小功能但不容忽视。在CSS中我把textarea和code-layer都设置为同样的padding、字号、行高确保两者文本排列完全重合。只要有一个属性不一致光标就会漂移。5. 常见问题与排查技巧实录5.1 高亮层和输入层错位怎么办这是我做过最频繁的调试。症状是输入到第十行后下面高亮的文字明显比光标位置偏右或偏下。常见原因有三个一是textarea和code-layer的字体不一致二是滚动条没有同步三是textarea有默认的border或padding但渲染层没有。解决方案是给两者设置完全相同的font-family、font-size、line-height、padding和border。另外在scroll事件里手动同步两个容器的scrollTop和scrollLeft。还有一个小细节textarea默认有internal padding和自动换行需要在CSS里设置white-space: pre; overflow: auto;并保持宽度一致。5.2 中文输入法导致的高亮闪烁中文输入时的composition事件会让textarea内容出现“拼音串”此时如果同步到模型语法高亮会把拼音当作正文渲染出现奇怪的颜色块。代码中要添加状态锁let isComposing false; inputArea.addEventListener(compositionstart, () { isComposing true; }); inputArea.addEventListener(compositionend, (e) { isComposing false; syncFromTextarea(); }); inputArea.addEventListener(input, (e) { if (isComposing) return; // 别急着渲染 syncFromTextarea(); });组合输入没有结束时放弃一切同步操作。等用户选好汉字compositionend之后再把完整的中文字符串同步进模型。这个处理不复杂但如果不加编辑器在中文环境里几乎不可用。5.3 光标位置错乱怎么定位定位关键数据只有两个selectionStart和selectionEnd。但有时候用户点击高亮层textarea的焦点不会自动落到正确位置因为高亮层在textarea下方点击事件被textarea捕获但位置计算不精确。一种简单做法是在textarea上层覆盖一个透明点击层click事件发生时根据页面上坐标换算成偏移量然后setSelectionRange。另一种做法是直接忽略高亮层的点击始终让textarea获得焦点因为textarea本身覆盖了整个编辑区你点击屏幕上的任何位置实际上都可能落在textarea上。后者在布局正确时最省心但注意不能让render层挡住textarea的事件我在CSS里使用pointer-events: none让code-layer不响应点击。这列的解决思路是先确认textarea的层级最高code-layer虽然视觉在上但不能拦截事件。我把textarea的z-index设为2code-layer设为1textarea背景透明。这样自然点击事件落在textarea上光标不会乱跑。5.4 撤销栈的错误跳转早期撤销经常出现“撤销跳回很久以前”的问题原因是每次input都会push命令但一次键盘输入可能触发多次input事件比如中文输入只产生一次但在Android键盘上一个字符会带来多次变化。我通过对input事件设置250ms的合并窗口窗口内的命令全部合并成一个事务。CtrlZ时一次撤销一组连续输入体验就正常了。5.5 为何必须手动处理空行高亮Markdown在空行时正则^#不能匹配到任何内容但如果有连续空行和换行符代码块判断容易出错。我在实际解析时先统一把\r\n转换成\n避免Windows换行干扰。解析后再输出时再把文本中的\n转换回br或保持在pre内pre本身对\n可以正常表现。如果用的是普通div则需要转成br不然换行会消失。6. 写一个编辑器带来的额外收获动手写完这个迷你编辑器之后我再回头用真正的代码编辑器会明显感觉底层的思路很清晰了。比如VS Code的diff算法、语法高亮服务、光标模型对现在的我来说不再是魔法。我建议每位开发者都做一次这种“自己造轮子”的练习不一定要造出商业级产品而是通过这个过程把“输入事件→文本模型→视图渲染”这条流水线跑通一遍。最后顺手分享一个细节如果你也想试建议从一个纯文本编辑器做起先支持字符插入、删除、光标移动然后在此基础上加高亮最后再加Markdown预览。别一上来就做多光标、代码折叠、LSP之类的基础模型不牢这些高级功能会像多米诺骨牌一样全部倒掉。我那个项目的最终版本也就几百行代码但注释里塞满了踩坑记录这比任何库都宝贵。
返回列表