ARTICLE DETAIL

资讯详情

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

手写代码高亮编辑器:textarea覆盖层与Token化实现详解

手写代码高亮编辑器:textarea覆盖层与Token化实现详解 去年做内网部署的系统时我遇到一个特别现实的需求表单里需要内嵌一个能写配置脚本的编辑器。内网环境不能拉CDN打包也不愿意为一个小功能引入上百KB的第三方编辑器源码更别说CodeMirror那套theme和mode加载体系了。于是“用原生JS自己写一个交互式代码高亮编辑器”这个看似离谱的任务就落到了我头上。做完之后我最大的感触是代码高亮听起来很高深拆开之后其实就是“正则切Token 样式拼接”的事真正麻烦的是编辑器交互层面的细节——光标、滚动、缩进、输入法每一项都能让人加班。这篇文章会把这套组件的完整实现拆开讲textarea覆盖层的整体架构、Token化高亮引擎、输入与滚动同步、行号/括号匹配/主题切换这些交互功能以及我在实测中踩出来的几个隐藏很深的坑。如果你有类似的需求或者单纯想搞懂一个代码编辑器是怎么工作的可以照着这篇直接往下做。1. 先别急着写代码textarea方案还是contenteditable方案1.1 什么场景下值得自己动手写编辑器很多人一听“手写编辑器”就觉得是重复造轮子我也不反对这个观点。在有CodeMirror、Monaco可用的场景下确实不应该自己造。但当项目满足下面任一条件时手写一个反而更划算部署环境受限内网、离线环境不能用CDNnpm包又要过审一个小功能引入整个编辑器源码性价比很低。需求高度定制只需要高亮某几个关键词或一种领域语言不希望引入一整套主题体系、插件机制。体积敏感页面本身很轻为了一个编辑器把包体积增加上百KB不划算。学习驱动你想弄懂编辑器底层的原理把它当成技术拆解项目来做。我当时做的内部系统属于前两种。配置脚本是团队自定义的一套类Lua语法界面风格有严格的视觉规范引入CodeMirror之后还要改主题、禁掉一堆用不到的功能折腾一圈下来不如自己写一个。1.2 两条路线的核心差异在动手之前先要把技术路线定下来。主流的“自研”方案有两条差别很大维度textarea 覆盖层contenteditable输入与光标浏览器原生行为稳定可靠各浏览器差异大需要自己管理选区粘贴默认粘成纯文本行为简单会带HTML格式需要拦截清洗高亮机制下层独立渲染与输入层分离直接修改DOM所见即所得表单集成天然支持form提交需要隐藏字段同步视觉短板选区背景会盖住部分高亮视觉最自然实现难度中等难点在对齐与同步较高难点在浏览器行为差异contenteditable方案最吸引人的地方是“所见即所得”用户看到的就是DOM本身光标、选区、高亮都在同一个文档流里没有两层错位的问题。但代价是你要处理浏览器间各种诡异的差异比如不同平台的光标行为、粘贴带格式、中文输入法候选框干扰这些坑深不见底。textarea方案刚好相反输入行为全交给浏览器代价是需要在视觉上做一层“高亮覆盖”把textarea的文字藏起来同时保证两层在空间上严格对齐。1.3 为什么最终选择textarea覆盖层我的选择是textarea覆盖层。理由很简单textarea把最难的部分——输入法、光标、选区、滚动、撤销栈——全部兜住了。你不需要跟Range对象搏斗不需要监听composition事件去猜用户是不是在组词只要把textarea当成一个“真正的内容源”然后在它下面放一层只负责视觉的高亮层。这对中文用户尤其友好因为输入法在textarea里的表现是最稳定的而contenteditable在部分浏览器上会遇到候选框位置错乱的问题。textarea方案的另一个隐藏优势是表单语义。编辑器内嵌在表单里提交时直接取textarea.value就能拿到源码不需要额外写隐藏域同步。复制粘贴时默认也是纯文本对代码编辑器来说这恰恰是正确的行为。这套架构的思路可以概括成一句话数据侧与呈现侧分离。数据永远在textarea.value上呈现永远在pre.innerHTML里两者通过事件同步互不污染。把这句想明白后面所有功能都好设计了。2. 高亮引擎把源码拆成Token再拼回去2.1 Token化到底解决了什么问题高亮的本质不是“对整段字符串做替换”而是把源码切分成若干片段对这些片段逐一判断类型再给不同类型套不同样式。这种切分在编译器领域叫词法分析切出来的片段叫Token。在我们这个场景里不需要构建AST语法树只需要拿到每个Token的类型、起止位置和原文就够了。举个例子const name hello;这段代码应该切出这些Tokenconst关键字name标识符符号hello字符串;符号空格也是Token的一部分只是用默认颜色渲染。实现时最省力的方式是维护一个规则表规定每种Token的正则表达式然后在源码上依次匹配出所有Token合并排序后按位置顺序渲染。有人会问直接对整段代码做全局替换不行吗比如code.replace(/function/g, spanfunction/span)。这种方式的致命伤在于它会无差别替换掉字符串和注释里的function。比如const a function;结果字符串里的function也被上色高亮就错了。Token化方案之所以正确是因为它在同一份源码上先采集所有Token的位置再通过重叠剔除规则决定最终归属这样字符串和注释就会“抢占”内部的普通关键字互不干扰。2.2 规则表、优先级重叠与HTML转义我这里给一份可直接运行的规则表覆盖了JavaScript常用的高亮类型。实际项目中你可以按需增删这个结构非常灵活。const TOKEN_RULES [ { type: comment, pattern: /\/\/[^\n]*|\/\*[\s\S]*?\*\//g }, { type: string, pattern: /(?:[^\\\n]|\\.)*|(?:[^\\\n]|\\.)*/g }, { type: number, pattern: /\b\d(?:\.\d)?\b/g }, { type: keyword, pattern: /\b(?:function|const|let|var|return|if|else|for|while|class|new|typeof|instanceof|switch|case|break|continue|try|catch|finally|throw|this|super|extends|import|export|default|async|await|yield)\b/g }, { type: func, pattern: /[A-Za-z_$][\w$]*(?\s*\()/g } ];匹配完成后每个Token统一成四个字段type类型、start起点、end终点、text原文收集到数组里。接下来要处理重叠问题。以// hello这行注释为例注释规则会匹配到整行字符串规则也会匹配到内部的hello两者范围重叠。处理重叠的办法是先把所有Token按start从小到大排序start相同则按规则顺序排序然后顺序扫描用一个cursor记录当前已覆盖到的位置只要某个Token的start小于cursor就直接跳过否则保留它并推进cursor。还有一个必须处理的点是HTML转义。源码可能包含、、、引号等字符直接拼进innerHTML会把它们当成标签或实体处理轻则高亮错乱重则产生XSS风险。所以从源码切片到HTML碎片时必须先做一次转义function escapeHtml(str) { return str .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, #39;); }注意转义必须作用在所有源码文本上哪怕是Token的原文也要转义。span classtok-keyword这种我们自己拼的HTML标签不受影响因为那是我们在代码里直接写的字符串不经过escapeHtml。2.3 一个能直接用的highlight函数有了规则表和转义逻辑核心高亮函数就只剩下两件事调用tokenize拿到Token数组然后顺序拼接HTML字符串。function tokenize(code) { const tokens []; TOKEN_RULES.forEach((rule, ruleIndex) { const regex new RegExp(rule.pattern.source, rule.pattern.flags); regex.lastIndex 0; let match; while ((match regex.exec(code)) ! null) { tokens.push({ type: rule.type, ruleIndex, start: match.index, end: match.index match[0].length, text: match[0] }); if (match[0] ) regex.lastIndex; } }); tokens.sort((a, b) a.start - b.start || a.ruleIndex - b.ruleIndex); const merged []; let cursor 0; for (const token of tokens) { if (token.end cursor) continue; if (token.start cursor) { token.start cursor; if (token.start token.end) continue; } merged.push(token); cursor token.end; } return merged; } function highlight(code) { const tokens tokenize(code); let html ; let pos 0; for (const token of tokens) { if (token.start pos) { html escapeHtml(code.slice(pos, token.start)); } html span classtok-${token.type}${escapeHtml(token.text)}/span; pos token.end; } if (pos code.length) { html escapeHtml(code.slice(pos)); } return html; }这段代码看起来不长但它已经解决了高亮中最容易出问题的三个点字符串内容不会被误高亮、重叠Token有明确的归属优先级、所有源码字符都经过转义。日常几百行以内的代码匹配性能完全不是问题。我在项目里还把规则表做成了配置项可以传入不同语言的高亮规则。比如JSON高亮只需要字符串、数字、关键字三类SQL高亮再加表名、函数名。Token化流程和渲染逻辑分离之后扩展新语言就是加几条正则的事。3. 编辑器外壳透明输入层与高亮渲染层的对齐艺术3.1 两层布局的CSS对齐要点先贴出最核心的HTML结构和样式。这是整个编辑器稳定的基础值得多看两眼。div classeditor div classeditor-body pre classlayer-code/pre textarea classlayer-input spellcheckfalse autocapitalizeoff autocompleteoff/textarea /div /div.editor { position: relative; width: 100%; height: 420px; font: 14px/1.6 Fira Code, Consolas, Courier New, monospace; } .editor-body { position: absolute; inset: 0; } .layer-code, .layer-input { position: absolute; inset: 0; margin: 0; padding: 16px; border: 0; font: inherit; letter-spacing: normal; tab-size: 4; white-space: pre; word-wrap: normal; } .layer-code { z-index: 1; overflow: hidden; } .layer-input { z-index: 2; color: transparent; caret-color: #333; background: transparent; resize: none; outline: none; overflow: auto; } .layer-input::selection { background: rgba(120, 160, 255, 0.35); }对齐的关键有三个。第一字体必须完全一致。字体栈要统一设置在父容器上子元素用font: inherit继承绝对不要在textarea和pre上分别写一套font-family。只要两边字体不同字符宽度就会有偏差光标和文字会逐渐错位越往下越明显。第二字号、行高、padding、边框必须精确一致。任何一边多一个像素高亮文字和实际输入位置就会漂移。最省心的做法是让两层共享同一个CSS规则只通过z-index和color区分职责。第三white-space都要是pre并且关闭自动换行。textarea的换行逻辑和HTML默认的white-space: pre本来就一致但如果你让pre自动换行了word-wrap: break-word屏幕上行数和textarea内部的行数就对不上了光标和渲染层会彻底错乱。3.2 监听输入、防抖渲染与滚动同步textarea的输入事件会实时更新value但高亮层不会自动跟着变需要监听input事件刷新渲染层。这里要注意不要每次按键都全量渲染加上一层防抖是最简单也最有效的性能手段。const editor document.querySelector(.editor); const input editor.querySelector(.layer-input); const codeLayer editor.querySelector(.layer-code); let renderTimer null; input.addEventListener(input, () { if (renderTimer) clearTimeout(renderTimer); renderTimer setTimeout(render, 120); }); function render() { codeLayer.innerHTML highlight(input.value); syncScroll(); } function syncScroll() { codeLayer.scrollTop input.scrollTop; codeLayer.scrollLeft input.scrollLeft; } input.addEventListener(scroll, syncScroll);这段逻辑很短但它回答了“交互式代码高亮编辑器”里最核心的问题用户能打字高亮能实时跟着变滚动时两层不会错位。为什么不用保存光标位置因为textarea的value没有变化只是下层pre的innerHTML变了textarea的光标完全不受影响这是textarea覆盖层方案比contenteditable简单得多的地方。这里有一个体验细节120ms防抖意味着输入后高亮会“稍微延迟”。对几百行的脚本来说几乎感知不到如果你希望更跟手可以把防抖降到60ms或者用requestAnimationFrame按帧渲染不再叠加防抖。后面性能小节我会再展开。3.3 Tab缩进与回车缩进继承要让编辑器具备“编辑器”的手感至少要处理两个特例按键Tab和Enter。Tab默认会切换焦点但代码编辑器里应该插入缩进。可以在keydown里阻止默认把两个空格或一个\t插入到光标位置同时移动光标input.addEventListener(keydown, (e) { if (e.isComposing) return; if (e.key Tab) { e.preventDefault(); const start input.selectionStart; const end input.selectionEnd; const value input.value; input.value value.slice(0, start) value.slice(end); input.setSelectionRange(start 2, start 2); triggerRender(); } if (e.key Enter) { e.preventDefault(); const start input.selectionStart; const end input.selectionEnd; const value input.value; const lineStart value.lastIndexOf(\n, start - 1) 1; const prefix value.slice(lineStart, start).match(/^\s*/)[0]; const insert \n prefix; input.value value.slice(0, start) insert value.slice(end); input.setSelectionRange(start insert.length, start insert.length); triggerRender(); } });这里有两个细节很关键。一是e.isComposing判断中文输入法选词过程中也会触发Enter如果这时候强行插入换行会破坏输入法的组合状态所以必须放行。二是回车缩进继承逻辑是读取光标所在行行首的空白字符在新行前自动补上相同缩进。这样写嵌套代码时不需要手动敲空格体验会顺滑很多。我第一次实现时遗漏了prefix这段逻辑结果写if语句时代码缩进全乱深层嵌套基本没法用。补上之后这个编辑器已经能用来写小段代码了。4. 把“编辑器”做成编辑器行号、当前行、括号匹配与主题切换4.1 行号侧边栏的正确打开方式行号是代码编辑器最常见的辅助元素。实现上并不难但涉及独立滚动和增量刷新容易踩坑。我的做法是在编辑器左侧加一个固定宽度的gutter区域内部放一个行号容器监听input和scroll事件同步行号。const gutterInner editor.querySelector(.gutter-inner); const gutters []; function renderGutter() { const lineCount input.value.split(\n).length; if (lineCount gutters.length) { while (gutters.length lineCount) { const div document.createElement(div); div.className gutter-line; gutterInner.appendChild(div); gutters.push(div); } } else { while (gutters.length lineCount) { gutterInner.removeChild(gutterInner.lastChild); gutters.pop(); } } gutters.forEach((div, i) { div.textContent i 1; }); }这里有个性能技巧行号不需要每次都重建DOM。如果行数只增不减就新增节点减少就删掉多余节点最后再批量改文本。这比每次innerHTML整体重绘更省事滚动和快速输入时不容易卡顿。gutter区域和textarea之间还需要做纵向滚动同步在scroll事件里同时设置gutterInner.scrollTop input.scrollTop这样代码滚动时行号会跟着走。注意不要水平移动gutter让它固定在左侧。4.2 当前行高亮和括号匹配当前行高亮能帮用户快速定位光标所在位置。实现思路是根据selectionStart计算光标在第几行然后在渲染层添加一条绝对定位的背景条。因为渲染层的行高是固定的每行的top就是行号乘行高定位很简单。function getCurrentLine() { const value input.value; let line 1; for (let i 0; i input.selectionStart; i) { if (value[i] \n) line; } return line; }计算出行号以后创建一条position: absolute的背景divtop设为(line - 1) * lineHeightheight设为lineHeight然后追加到渲染层或者单独的层。每次input、click、keyup事件触发时重新定位。注意渲染层的内容和背景div不能互相遮挡背景div的z-index要低于代码文本或者先插入背景div再插入代码内容让代码自然盖在背景上。括号匹配是另一个很能提升“专业感”的交互。思路不复杂光标位于某个括号字符上时在源码中找与它配对的另一个括号然后给两个字符加高亮样式。配对算法用栈来扫function matchBracket(code, index) { const pairs { (: ), [: ], {: } }; const open code[index]; const close pairs[open]; if (!open || !close) return null; let depth 0; for (let i index; i code.length; i) { const ch code[i]; if (ch open) depth; else if (ch close) { depth--; if (depth 0) return i; } } return null; }左括号向右扫描右括号就反向扫逻辑完全对称。这里有个我踩过的坑如果代码里有字符串和注释括号算法不能盲目遍历否则会匹配到字符串或注释里的括号。严谨做法是先把字符串和注释的Token范围传进来判断光标位置是否在区间内如果是就跳过匹配逻辑。4.3 用CSS变量做主题切换高亮编辑器的配色管理我强烈建议用CSS变量。主题切换无非是换一组色值用CSS变量后不需要写任何切换逻辑.editor[data-themelight] { --color-bg: #ffffff; --color-fg: #24292e; --color-comment: #6a737d; --color-string: #032f62; --color-keyword: #d73a49; --color-number: #005cc5; --color-func: #6f42c1; } .editor[data-themedark] { --color-bg: #1e1e1e; --color-fg: #d4d4d4; --color-comment: #6a9955; --color-string: #ce9178; --color-keyword: #569cd6; --color-number: #b5cea8; --color-func: #dcdcaa; } .tok-comment { color: var(--color-comment); } .tok-string { color: var(--color-string); } .tok-keyword { color: var(--color-keyword); } .tok-number { color: var(--color-number); } .tok-func { color: var(--color-func); }切换主题只需要设置editor.dataset.theme dark所有颜色更新都由CSS完成。这个方案也方便用户自定义配色只要覆盖CSS变量即可不需要改JS逻辑。我还会把背景色和前景色也做成变量这样整体视觉风格能一并切换。后面如果要做暗色模式跟随系统只需监听prefers-color-scheme给dataset.theme赋值成本极低。5. 性能优化与实测踩坑记录5.1 全量渲染的性能瓶颈与应对方式textarea方案最大的性能风险在render函数每次防抖到期都要对整段代码做tokenize、拼接HTML、整体赋值innerHTML。对几百行代码来说tokenize不到1msinnerHTML重写可能花2~5ms用户完全无感但到了几千行甚至上万行innerHTML重写会涨到几十毫秒滚动时还可能因为重复渲染出现卡顿。我在实际项目里踩到过这个坑某个页面的配置脚本被业务方堆到了2000多行输入时总感觉高亮慢半拍滚动重建时也能看到明显的延迟。解决办法是给渲染加双重保护一是保留input的防抖把高频输入期间的渲染收敛二是滚动事件里只更新scrollTop和行号不重渲染高亮高亮交给防抖完成。如果你的编辑器要应对大文件可以考虑把“全量渲染”改成“只渲染可视区”。思路是根据textarea的scrollTop和clientHeight算出当前可见的行范围只对这个区间做tokenize和拼接区间外用占位符。这样做的好处是渲染时间与文件总行数解耦代价是实现量会增加不少需要管理每行的Token缓存和高度一致属于进阶优化。5.2 我在实际项目中踩过的五个坑按踩坑顺序列一下这些坑通常不会出现在三方库文档里但自己实现时大概率会遇到。第一个是字体一致性问题。Windows下如果字体栈里没有等宽字体或者编辑器父元素字体与textarea/层的字体不一致字符宽度偏差会让下层高亮和实际光标位置错开。代码里的解决方式是把font设置放在编辑器容器上所有子元素都用font: inherit继承强制使用等宽字体栈。第二个是选区颜色遮挡问题。textarea透明层依旧会绘制选区背景默认的蓝色背景会盖住下层高亮选中代码时高亮颜色会全部消失。处理方式是给textarea::selection设置半透明背景让下层颜色还能透出来。这个方案不能彻底消除遮挡但对高亮编辑器来说是可接受的效果。第三个是输入法候选框与透明文字层的组合问题。部分浏览器在输入法组字过程中textarea内部会直接显示组字文本因为color: transparent用户会看不到正在输入的拼音或重组文字。兜底做法是在compositionstart时暂时关闭透明给textarea加一个class让它显示正常文字颜色compositionend后再恢复透明。这不是最优雅的方案但至少保证中文用户能正常输入。第四个是XSS与HTML结构破坏问题。如果highlight输入不对源码做转义一个简单的就能让渲染层结构错乱一段带onerror属性的img标签就能触发脚本。要在渲染函数里保证所有源码文本都经过escapeHtml包括Token的原文部分不能有遗漏。第五个是IME对Enter和Tab拦截的影响。前面提过要用e.isComposing判断否则中文输入法选词时按Enter会意外插入换行Tab也会干扰候选框。这个细节看起来小但对中文用户体验影响很大我见过好几个编辑器方案在这个问题上翻车。5.3 手写编辑器的能力边界正则驱动的Token化方案有一个绕不开的边界它对复杂语法无能为力。JavaScript里的模板字符串嵌套表达式、正则字面量与除法符号的歧义、JSX/TS类型标注这些光靠正则根本无法正确区分。如果项目需要完整支持某种主流语言我更建议直接用CodeMirror或Monaco它们的parser和长文档性能都经过大规模验证没必要自己重新发明一遍。手写编辑器的定位应当是零依赖、体积小、行为可控、满足业务特定的那一小撮语法高亮需求。比如内部系统的配置脚本、模板语言、领域专用语言这种场景反而比通用代码编辑器更合适。判断标准很简单如果编辑器只需要支持一种或几种自定义语法且被编辑的代码量在千行以内手写就是划算的如果要对标通用IDE体验还是收手去接成熟库更稳妥。这个编辑器我后来又迭代了几版把语言规则抽成了配置加了简单的自动补全建议。回头看最值钱的不是那几行高亮代码而是我理解了textarea方案下“数据侧与呈现侧分离”的核心思路——这比记住某个库的API有用得多。如果你也在做一个需要内嵌编辑器的内部系统希望你能少走我走过的这些弯路。
返回列表