资讯动态

从零手写代码高亮编辑器:原理、实现与工程实践

发布时间:2026/10/3 18:57:55 来源:尧图企业网站定制
说实话写这个编辑器纯属被“逼”出来的。当时我负责一个内部工具页面需要在网页里让用户直接编辑一段脚本示例还要实时显示语法高亮。第一反应是引 CodeMirror包体积不小、要配语言包还得调主题。我又想了想这个页面真的需要这么重的东西吗与其套一个我不完全理解的编辑器黑盒不如自己动手用原生 JS 写一个轻量的交互式代码高亮编辑器顺便把原理彻底吃透。写完之后回头再看核心思路简单到一句话让透明的 textarea 躺在渲染高亮颜色的 pre 层上方输入时实时同步。这篇文章我会把从选型、CSS 对齐、核心 token 识别到 Ajax 保存、ECharts 可视化统计的完整过程拆开讲适合有一定 JS 基础、想告别复制粘贴组件、或者想搞清楚“实时高亮到底怎么实现”的前端开发者。1. 编辑器方案选型与核心原理拆解1.1 为什么不直接引 CodeMirror 或 Monaco先说到底该不该自己写。CodeMirror 和 Monaco 确实是行业级方案Monaco 是 VS Code 的网页版内核功能强到没话说。但接手一个轻量工具页面时引入它们的成本往往被低估一个核心包几十 KB加上语言模式、主题、自动补全打包体积轻松上几百 KB而且 API 很多想定制一个不常见的行为比如把代码内容和统计面板联动你得先翻好久文档。实际工程里选型有个很朴素的标准你的编辑区域只占整个页面一个角落用户只是粘贴一段配置、改两行参数那自研方案的成本远低于引入重型编辑器。我自己做下来的体会是一个可用的实时高亮编辑器核心代码量大约 300 行左右没有构建依赖没有样式冲突完全可控。它不适合做的事我也承认复杂的代码折叠、断点调试、千人千面的插件生态这些不要碰那确实是 Monaco 的赛道。对比维度原生 JS 自研CodeMirror 5Monaco包体积几乎为零中等按需引入较大建议 ESM 按需学习曲线低原理透明中API 多高定制能力任意改需要钩子机制受内部架构约束适用场景轻量工具页、表单内嵌中型项目大型 IDE 场景1.2 高亮编辑器的核心运行机制一层透明输入层一层渲染层实现“输入的同时高亮”这个效果绕不开的问题是HTML 里的 textarea 不支持局部着色你没法在 textarea 里给关键字画颜色。contenteditable 倒是能塞 HTML但光标位置失控、粘贴格式混乱、移动端兼容性差维护成本极高。所以主流轻量方案都采用叠层模型底层是 pre 元素用来渲染高亮后的 HTML 内容上层是一个透明的 textarea真正负责接收键盘输入。textarea 的文字颜色设成透明光标颜色单独设置这样用户看到的光标和选中高亮背景来自 textarea而实际的代码颜色来自底层 pre两层视觉叠在一起就是“带高亮的输入框”。我常用一个类比textarea 是一张透明的玻璃纸pre 是底下印好彩色字的底图。玻璃纸上的字透明所以你看到的是底图的颜色但光标、选中框、输入事件都在玻璃纸上编辑动作完全由浏览器原生处理不需要手动模拟光标。这套模型的关键工程点有两个一是两个元素的字体、字号、行高、缩进、padding 必须完全一致否则错位二是滚动行为要同步textarea 滚动多少pre 就跟着滚动多少。这两点后面都会详细给代码。2. 基础骨架构建可实时高亮的编辑区域2.1 HTML 结构和必不可少的 CSS 样式先搭一个最小的 HTML 结构。核心点是 textarea 和 pre 放在同一个相对定位的容器里pre 绝对定位覆盖在 textarea 上并且不接收鼠标事件div classeditor-root pre idhighlightLayer aria-hiddentrue/pre textarea idcodeInput spellcheckfalse autocompleteoff/textarea /div样式上最容易踩坑的是两个元素逐像素对齐。我强烈建议用一个公共类去控制两者的字体和间距而不是分开写否则迟早会出现“字对不上”的灵异现象.editor-root { position: relative; width: 100%; height: 400px; border: 1px solid #d0d7de; border-radius: 8px; overflow: hidden; } #highlightLayer, #codeInput { margin: 0; padding: 16px; border: 0; width: 100%; height: 100%; font-family: JetBrains Mono, Fira Code, Consolas, monospace; font-size: 14px; line-height: 1.6; letter-spacing: 0; white-space: pre-wrap; word-wrap: break-word; box-sizing: border-box; } #highlightLayer { position: absolute; top: 0; left: 0; pointer-events: none; overflow: hidden; color: #abb2bf; background: #282c34; } #codeInput { position: relative; display: block; color: transparent; caret-color: #ffffff; background: transparent; resize: none; overflow: auto; outline: none; z-index: 1; } #codeInput::selection { background: rgba(255, 255, 255, 0.2); }几个细节我说下理由。white-space 必须一致而且要用 pre-wrap否则代码超过宽度时 textarea 和 pre 的换行规则会不一致。text-align 保持默认左对齐letter-spacing 两处都要一样差一个像素在短代码里看不出来一旦写到几十行的函数就开始飘。background 用透明这样才能露出 pre 层的底色。至于 caret-color 只作用光标颜色配合 color: transparent 一起用是这套方案的精髓。2.2 高亮核心函数从转义到 Token 替换高亮的核心函数负责把一段纯文本变成带 span 标签的 HTML。直接拿来调用的版本我写成了这样const PLACEHOLDER_PREFIX __HL__; const PLACEHOLDER_MAP {}; function escapeHTML(str) { return str .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;); } function setPlaceholder(type, text) { const key \\x00 Object.keys(PLACEHOLDER_MAP).length \\x00; PLACEHOLDER_MAP[key] { type, text }; return key; } function highlightCode(raw) { let code escapeHTML(raw); code code.replace(/(\/\/[^\n]*|\/\*[\s\S]*?\*\/)/g, (m) { return setPlaceholder(comment, m); }); code code.replace(/(([^\\]|\\.)*|([^\\]|\\.)*)/g, (m) { return setPlaceholder(string, m); }); code code.replace( /\b(var|let|const|function|return|if|else|for|while|class|extends|new|this|typeof|instanceof|try|catch|finally|async|await|import|from|export|default|switch|case|break|continue|do|void|delete|in|of)\b/g, span classtoken-keyword$1/span ); code code.replace(/\b(\d(\.\d)?)\b/g, span classtoken-number$1/span); code code.replace(/\/?[a-zA-Z][^]*/g, span classtoken-tag$/span); code code.replace(/__HL__/g, (m) { return ; }); code code.replace(/\x00(\d)\x00/g, (m, idx) { const token PLACEHOLDER_MAP[m]; if (!token) return m; return span class${token.type}${token.text}/span; }); return code; }为什么先用占位符因为正则处理是顺序执行的如果先把注释换成 span后面匹配字符串时可能把注释里的引号再次包装产生嵌套标签。把注释和字符串先“藏”起来等关键字、数字、标签都替换完之后再把它们还原成自己的带色 span顺序错乱问题就消失了。这里要额外提醒上面的正则方案能覆盖 90% 的场景但不是银弹。比如http://example.com字符串中的//会被注释正则先吃掉导致字符串高亮失效。更完善的解法是逐字符扫描的 tokenizer用一个状态机去判断当前处于注释、字符串还是普通代码代码会多一些但边界情况可控。真实项目里如果只需要高亮 HTML/CSS 片段这套正则版完全够用要做正经代码编辑器建议用状态机扫描。2.3 监听输入、滚动同步与光标位置高亮触发的入口是 input 事件。用户在 textarea 里每敲一个字符都从 value 取出全文跑一遍 highlightCode把结果填进 pre 的 innerHTML。这块逻辑很简单const input document.getElementById(codeInput); const output document.getElementById(highlightLayer); input.addEventListener(input, () { output.innerHTML highlightCode(input.value); }); // 初始化 input.value function hello() {\n const msg hello world;\n console.log(msg);\n}; output.innerHTML highlightCode(input.value);但只做这一步往下滚动几行就会发现 pre 不跟着走。原因是 textarea 滚动时pre 是绝对定位的不会自动跟随。必须在 textarea 的 scroll 事件里同步两个轴的偏移input.addEventListener(scroll, () { output.scrollTop input.scrollTop; output.scrollLeft input.scrollLeft; });pre 元素本身需要设置 overflow: hidden这样它内部的 scrollTop/scrollLeft 虽然可写但不会出现自己的滚动条。为什么这么设计因为真正滚动的是 textarea滚动条也属于 textareapre 只是被“推”着走的渲染层始终维持同样的视觉位置。cursor 定位不需要手动处理虚拟光标本来就是 textarea 的原生能力这也是叠层方案相比 contenteditable 最大的优势。3. 交互体验增强让编辑器真正“好用”3.1 Tab 键、自动缩进与快捷键处理代码编辑器不能按 Tab 跳焦点否则根本没法写。我用 keydown 拦截 Tab 键手动往光标处插入四个空格。现代浏览器里 textarea 的 setRangeText 方法可以精准地替换选中区域比直接用 value 拼接更省事input.addEventListener(keydown, (e) { if (e.key Tab) { e.preventDefault(); const start input.selectionStart; const end input.selectionEnd; input.setRangeText( , start, end, end); // 手动触发高亮 output.innerHTML highlightCode(input.value); } if (e.key Enter) { // 自动缩进获取当前行行首的空白字符并追加 const start input.selectionStart; const lineStart input.value.lastIndexOf(\n, start - 1) 1; const currentLine input.value.slice(lineStart, start); const indent currentLine.match(/^\s*/)[0]; e.preventDefault(); input.setRangeText(\n indent, start, input.selectionEnd, end); output.innerHTML highlightCode(input.value); } if ((e.ctrlKey || e.metaKey) e.key s) { e.preventDefault(); // 这里可以触发保存回调 onSaveCode(input.value); } });Enter 自动缩进这段有个细节我取了光标所在行用 lastIndexOf(\n) 找到行首把行首到光标位置的空白截出来在换行之后拼上去。这样在if (x) {后面回车下一行会自动带上一行的缩进长度但不会增加新的层级缩进。想继续加一层缩进可以在 indent 后面再加一个缩进单位看个人习惯。Ctrl/CmdS 的拦截是为了防止用户敲 CtrlS 把页面整体保存了浏览器弹出来源页面另存为对话框体验很割裂。3.2 行号渲染与性能优化行号是代码编辑器的灵魂。新行号方案我用了左侧独立 gutter 的方式结构上在 .editor-root 里加一个行号列div classeditor-root div idlineNumbers classline-numbers/div pre idhighlightLayer/pre textarea idcodeInput/textarea /divCSS 上让 gutter 宽度根据行数动态调整行号不参与 pre 层的对齐pre 的 padding-left 和 textarea 的 padding-left 要联动预留 gutter 宽度。核心同步逻辑是在每次 input 后重新计算行数并更新行号内容function updateLineNumbers() { const count input.value.split(\n).length; let html ; for (let i 1; i count; i) { html span i /span; } lineNumbers.innerHTML html; }这个方案在几百行内体验很顺上千行会有明显性能问题。优化思路有两个方向一是用 requestAnimationFrame 合并渲染帧输入期间高频触发时只在下一帧执行一次高亮二是只渲染可视区域内的行号超出视口上下的部分不渲染。对单文件代码片段来说前一个方案就能救回来let rafId null; input.addEventListener(input, () { if (rafId) return; rafId requestAnimationFrame(() { output.innerHTML highlightCode(input.value); updateLineNumbers(); rafId null; }); });3.3 内容统计、清空与导出编辑器接到工具页里免不了要统计行数、字符数、清空、导出之类的功能。这几个都可以挂在 input 事件上function updateStats() { const text input.value; statsEl.textContent 行数 ${text.split(\n).length} | 字符 ${text.length}; } function exportCode() { const blob new Blob([input.value], { type: text/plain;charsetutf-8 }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download code.txt; a.click(); URL.revokeObjectURL(url); } function clearCode() { input.value ; output.innerHTML ; updateLineNumbers(); updateStats(); }导出这里我用 Blob 生成下载链接注意下载完记得 revokeObjectURL 释放内存。清空功能有一个坑只清 textarea 不够必须同时清空 pre 的 innerHTML否则视觉上代码还残留着初学者最容易漏这一步。4. 实战扩展结合 Ajax 与 ECharts 做代码统计可视化4.1 通过 Ajax 保存和读取代码片段编辑器本身只解决输入问题放到完整网页里往往要和后端交互。现在的前端项目里虽然 fetch 是主流但不少历史项目还在用 jQuery热词场景也是“原生 JS jQuery Ajax ECharts 配合”我就用两种方式都写一下。保存代码片段到服务端的 jQuery 写法function saveCode(codeString) { $.ajax({ url: /api/code, method: POST, contentType: application/json, data: JSON.stringify({ code: codeString }), success: (res) { console.log(保存成功, res.id); }, error: (xhr) { console.error(保存失败, xhr.status); } }); }原生 fetch 对应的写法async function saveCode(codeString) { const res await fetch(/api/code, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ code: codeString }) }); if (!res.ok) throw new Error(保存失败); return res.json(); }读取代码回填编辑器同样简单拿到 string 后设置 input.value然后必须手动调一次 highlightCode 和 updateLineNumbers因为代码不是用户敲进去的不会触发 input 事件。这个“外部赋值后手动同步”的操作是我见过最多漏掉的地方回填完发现 pre 是空的。4.2 用 ECharts 实时展示代码结构交互式编辑器配合可视化图表非常加分。我的想法是在编辑器的下方放一个 ECharts 饼图实时展示当前代码片段里注释行、字符串数量、代码行数的占比。实现的前提是统计函数能从高亮环节拿到 token 类型信息我改造一下 highlightCode让它在处理 placeholder 时顺带计数function analyzeCode(raw) { let comments 0; let strings 0; let codeLines 0; raw.split(\n).forEach((line) { const trimmed line.trim(); if (trimmed.startsWith(//) || trimmed.startsWith(/*) || trimmed.startsWith(*)) { comments; } else if (trimmed.length) { codeLines; } }); const stringMatches raw.match(/(([^\\]|\\.)*|([^\\]|\\.)*)/g); strings stringMatches ? stringMatches.length : 0; return { comments, strings, codeLines }; }图表更新逻辑用防抖控制不希望在用户输入的每一帧都触发 ECharts 的 setOption否则页面会有明显卡顿。ECharts 更新部分let chart echarts.init(document.getElementById(codeChart)); function updateChart(codeString) { const { comments, strings, codeLines } analyzeCode(codeString); chart.setOption({ tooltip: {}, series: [{ type: pie, data: [ { name: 注释行, value: comments }, { name: 字符串, value: strings }, { name: 代码行, value: codeLines } ] }] }); } // 300ms 防抖 let chartTimer null; input.addEventListener(input, () { clearTimeout(chartTimer); chartTimer setTimeout(() updateChart(input.value), 300); });用 ECharts 的典型好处是开箱即用饼图、柱状图、折线图都能快速渲染。统计维度也不局限在上面三种你可以统计函数个数、大括号匹配深度、代码缩进层级等只要 tokenizer 能识别图表就能画。4.3 与 jQuery 混编时的工程细节把原生 JS 模块、jQuery、ECharts 塞进同一个页面时最常见的三个坑是 $ 符号冲突、事件重复绑定、DOM 就绪时机不一致。首先 $ 冲突。如果页面同时引了 jQuery 和其他库建议把 jQuery 局部限定(function($) { // 这里面的 $ 一定是 jQuery $(.save-btn).click(function() { saveCode(input.value); }); })(jQuery);其次是事件重复绑定。动态渲染的按钮如果父容器被替换再调一次 $(selector).click() 会绑定多个处理函数。解决方式是事件委托绑定到不销毁的父容器$(document).on(click, .save-btn, function() { saveCode(input.value); });更根本的问题是模块边界。我在这个工具页里把高亮编辑器封装成了一个独立对象对外只暴露 init、setValue、getValue、onSave 四个方法ECharts 统计逻辑单独放一个模块两者通过自定义事件通信不直接相互操作。这样以后移除 jQuery 或换掉 ECharts改动范围都被限制在各自模块内不会剪不断理还乱。5. 常见问题排查与避坑记录5.1 高亮闪烁、字体错位和滚动不同步我调试过程中最想砸键盘的几个问题集中写在这里。首当其冲的是字体错位。表现是 pre 里的代码和 textarea 的光标位置不在一条线上越往下越歪。原因几乎都是字体或行高不一致我排查时会在两个元素上分别 getComputedStyle 对比 font-family、font-size、line-height、letter-spacing这四个属性必须完全一致差一像素都不行。滚动不同步的表现是 textarea 滚到底了pre 还在中间。原因多半是 pre 高度撑满了容器且没设置 overflow: hidden或者 scrollTop 同步代码被写在 resize 而不是 scroll 事件上。记一个原则textarea 是唯一滚动源pre 永远跟随不要在 pre 上做任何滚动操作。高亮闪烁的表现是输入时背景先闪白再变色原因通常是 pre 的 background 设置不对导致在 reflow 时露出容器底色。把 background 放在 pre 上并保证它有完整的高度这个问题就消失了。对应速查表如下现象可能原因解决方案代码错位字体/行高不一致统一公共样式类滚动不同步未同步 scrollTop监听 textarea 的 scroll高亮闪烁pre 无背景或高度坍塌pre 设背景和 100% 高度光标不可见caret-color 未设置显式设置 caret-color行号错位行号和内容行高不一致行号容器同样设置行高5.2 中文输入法下输入抖动的问题中文输入法的坑是后知后觉的。拼音输入过程中textarea 会持续触发 input 事件但此时 value 里是拼音字母而不是最终汉字高亮层跟着拼写变化高频刷新视觉上会抖动而且性能开销很大。我用了 composition 事件来解决let isComposing false; input.addEventListener(compositionstart, () { isComposing true; }); input.addEventListener(compositionend, (e) { isComposing false; output.innerHTML highlightCode(input.value); }); input.addEventListener(input, () { if (isComposing) return; output.innerHTML highlightCode(input.value); });组合输入期间跳过高亮compositionend 之后再补一次完整高亮。这个处理在表单类输入框里同样适用能有效避免实时搜索、实时校验的意外触发。另外移动端输入法也会有类似问题统一用这个 flag 解决最省事。5.3 避免 XSS直接 innerHTML 的教训只要涉及 innerHTML就必须谈安全问题。很多代码高亮教程直接把用户输入拼进 innerHTML 而不做转义这样的编辑器在本地自娱自乐没事一旦上了生产环境就是灾难。攻击路径很简单用户粘贴一段包含img srcx onerroralert(1)的代码如果没转义这段内容会被当作真实 HTML 解析恶意脚本直接执行。正确的流程永远是先转义再替换。escapeHTML 把变成lt;变成gt;浏览器只会把它当作文本显示不会被解释成标签后续正则匹配到的是转义后的实体再包上 span 也不会破坏结构。高亮函数里的每个 span 都是我们自己生成的不是用户输入所以安全边界是清晰的。还有一点服务端返回的代码内容同样不能用 innerHTML 直接塞所有渲染路径都必须走同一套转义函数。5.4 大文本卡顿与性能优化清单代码编辑器写个 5000 行文本后每次都全量正则替换DOM 重绘会越来越慢。我试过 100KB 的文本输入明显的延迟可以到几百毫秒根本没法用。性能优化有几个方向按性价比排序首先用 requestAnimationFrame 合并渲染把多次输入合并成一帧内的高亮计算把 30 次/秒的重绘降到 10 次/秒以下。其次限制高亮范围只处理 textarea 可视区域附近的行或者只对前 2000 行做完整高亮超出部分不渲染。再往下可以用 Web Worker 跑正则替换把高亮计算放到后台线程不过代码复杂度会提升并且 DOM 更新仍需主线程。对一个工具页来说前两个方案足够。缓存也是一个容易被忽略的点。同一段代码反复修改一个字符没必要全量重新匹配。可以对代码内容做 hash内容没变就跳过高亮更新实测在内容不变时性能提升明显。写在最后我把这套交互式代码高亮编辑器方案完整跑通之后最大的收获不是造了个轮子而是搞清楚了“输入层与渲染层分离”这种模型的价值。它不止适用于代码高亮评论区 人的高亮、Markdown 实时预览、关键词标记本质上都是同一个套路真实输入在底层美化显示在上层两者靠定位和事件同步保持一致。工具页连上 jQuery、Ajax、ECharts 之后一个轻量编辑器已经能支撑用户编辑、保存、统计可视化一整条流程。后续如果你想继续扩展可以增加主题切换、代码折叠、快捷键面板或者把高亮 tokenizer 换成更精确的状态机实现。实际项目中这个自研方案已经在公司的工具页里跑了一个多月稳定性和体验都还不错。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑