ARTICLE DETAIL

资讯详情

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

原生JS手写代码高亮编辑器:textarea与pre双层实现

原生JS手写代码高亮编辑器:textarea与pre双层实现 前阵子给一个在线刷题平台做前端需要在网页里嵌入一个能实时高亮代码的编辑器。第一反应是上CodeMirror但看了看压缩后接近100KB的体积就为了一个输入框总觉得有点“杀鸡用牛刀”。于是干脆用原生JS自己撸了一个交互式代码高亮编辑器顺便把整个实现过程沉淀成一篇教程。这篇文章不是泛泛地讲“怎么用现成库”而是从零开始带着你手写一个轻量的代码高亮编辑器。核心思路就一句话底层放一个textarea负责输入和光标上层放一个pre层负责显示带颜色高亮的代码两者通过input事件同步内容。你会学到为什么textarea要透明、为什么必须做HTML转义、为什么高亮之后光标会乱跳以及怎么用正则做简单的词法分析。适合想彻底搞懂代码高亮原理的人、正在做轻量级Web编辑器的人还有准备前端面试时想讲清楚底层细节的朋友。1. 整体设计与思路拆解1.1 为什么不用现成的编辑器库做代码高亮市面上的方案其实不少。Prism.js和highlight.js适合做“静态展示”给博客文章或文档里的代码块上色CodeMirror和Monaco Editor才是真正的“编辑器”支持自动补全、代码折叠、错误提示这些重型能力。但我的场景比较特殊需要一个能输入的编辑器但同时只做语法高亮不需要IDE级别的功能。如果上CodeMirror学习成本不高但依赖体积、样式定制、以及后续升级维护都是负担。如果用highlight.js它只是单向渲染没法做到“用户输入时实时高亮”。原生JS的方案正好卡在中间体积小、零依赖、完全可控。更重要的是手写一遍能让你把“编辑器到底怎么工作”这件事彻底想明白。很多前端开发天天用CodeMirror但不知道背后其实是“隐藏输入层渲染层”的双层结构更不理解为什么需要手动恢复光标位置。自己实现一遍这些问题全通了。从后期维护角度看原生实现也最省心。不用等第三方库适配不用担心CDN挂掉哪天想加个自定义语法规则改一段正则就行。这种掌控感是所有框架都给不了的。1.2 核心技术textarea pre 双层叠加模型这是整个编辑器的地基。textarea是原生的文本输入控件天然支持键盘输入、鼠标选中、光标移动这些交互能力pre层则负责展示带HTML标签的高亮内容。两者叠放在同一个容器里位置完全重合通过CSS把textarea的文字设置为透明、背景设置为透明这样用户看到的是pre层的高亮内容但实际上光标和输入动作都发生在textarea上用户输入的却是透明文字——光学原理像“透明的纸叠在有颜色的纸上”。用生活化的例子来理解textarea像是记账的会计默默记录你每一次键盘敲击pre层像是贴在墙上的海报把内容重新排版、涂上颜色给所有人看。会计工作的时候海报也要同步更新这样账本和海报永远是一致的。CSS实现上容器设置position: relativetextarea和pre都设置position: absolutetop、left、width、height全部对齐。textarea的color设置为transparent但caret-color保留为正常颜色这样光标依然可见。pre层设置pointer-events: none让鼠标事件穿透到textarea上用户才能正常点击、选中、滑动。Edge case要特别注意两层必须使用完全相同的字体、字号、行高、内边距、边框连换行规则都得一致。否则会出现高亮内容和实际文字错位的问题用户输入三行pre层换了四行位置视觉上就全乱了。1.3 功能清单与最终效果预览动手之前先列清楚要做哪些功能不然写着写着容易跑偏。我把这个编辑器拆成以下几个点基础的语法高亮关键字、字符串、注释、数字、函数名分别用不同颜色实时交互用户输入时高亮效果跟着更新无延迟感滚动同步textarea滚动pre层跟着滚动光标保持高亮刷新后光标不跳到开头键盘增强支持Tab键插入缩进可选扩展行号显示、复制代码按钮实现完成后的效果就是你在左边输入代码右边或同一个框内立刻看到彩色高亮版本滚动长代码时颜色层和输入层严丝合缝地一起移动按Tab键不会失焦而是优雅地插入空格。这套效果做完基本就能覆盖绝大多数轻量编辑场景。2. 核心细节解析你最容易被卡住的地方2.1 词法拆分用正则给代码打上标签高亮的核心是“词法分析”也就是把一段代码拆成不同类型的单词分别上色。但在浏览器环境里我们不可能写一个完整的编译器词法分析器正则表达式就是最实用的工具。拆分的思路是按照优先级逐层匹配而不是一次匹配所有。我的顺序是这样的先匹配注释// 单行注释和 /* */ 多行注释再匹配字符串单引号、双引号、反引号然后匹配关键字const、let、var、function、return等接着匹配数字、布尔值最后匹配函数名和普通标识符为什么注释和字符串要排在前面因为在这两类内容里即使出现了const、function这样的词它们也只是字符串的一部分不该被当成关键字上色。比如const str function;如果先匹配关键字function在字符串里也会变蓝这就错了。我最终用的高亮函数大致长这样function highlightCode(code) { // React/Vue等现代框架需要额外处理原生JS直接操作即可 var escaped escapeHtml(code); // 处理逻辑按顺序叠加标签 // 先处理注释// 开头到行尾 escaped escaped.replace(/(\/\/[^\n]*)/g, span classtoken comment$1/span); // 再处理多行注释 escaped escaped.replace(/(\/\*[\s\S]*?\*\/)/g, span classtoken comment$1/span); // 处理字符串单引号、双引号、反引号 escaped escaped.replace(/(([^\\]|\\.)*|([^\\]|\\.)*|([^\\]|\\.)*)/g, span classtoken string$1/span); // 处理关键字 escaped escaped.replace(/\b(const|let|var|function|return|if|else|for|while|new|class|import|export|default|extends|super|this)\b/g, span classtoken keyword$1/span); // 处理数字 escaped escaped.replace(/\b(\d(\.\d)?)\b/g, span classtoken number$1/span); return escaped; }这段代码看着简单细节却很多。比如\b表示单词边界能避免把“functionality”里的“function”误判成关键字([^\\]|\\.)*能处理字符串里的转义字符比如a\b这样的字符串能完整匹配。这些坑不自己踩一遍是想不到的。这种正则匹配方案性能足够应付几百行的代码。如果要做几千行的大型文件就需要逐行处理、只在变化区域重新渲染那复杂度会高一个量级但对轻量场景没必要。2.2 HTML转义不做就会出大问题这是整个实现里最容易踩的坑也是最严重的坑。用户输入的代码要写进pre的innerHTML如果里面有script标签浏览器会把它当成真正的HTML解析执行轻则显示错乱重则XSS攻击。解决方案很暴力先转义再高亮。把替换成lt;替换成gt;替换成amp;这样浏览器就把它当作纯文本显示而不是HTML标签。转义函数长这样function escapeHtml(code) { return code .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;); }注意顺序很重要必须先转义再转义和。因为如果不先处理转义出来的lt;里的会被二次转义成amp;lt;渲染结果就错了。2.3 空格和换行的处理HTML的“透明墙”textarea里的内容换行是\n空格是真实的空格字符。但写入pre的innerHTML之后情况就变了HTML会把连续空格合并成一个换行符直接被忽略除非设置CSS属性。解决这个问题的正统方案有两个一个是在CSS里给pre设置white-space: pre-wrap这样浏览器会保留空格和换行。这是最推荐的做法简单可靠。另一个方案是把空格替换成nbsp;把换行替换成br。这种方法更暴力但要小心正则匹配因为nbsp;本身包含字符如果在转义之前替换后续转义会把nbsp;的转成amp;结果就全乱了。我实际项目里用的是white-space: pre-wrap方案再加上tab-size: 4控制Tab键的显示宽度。这样处理起来干净字体设置好之后对齐也完美。2.4 光标位置恢复高亮之后光标不乱跳的秘方这是新手最容易被吓退的一步。当你监听input事件把textarea的内容拿去高亮、写回pre层之后会发现光标嗖地一下跑到最前面去了根本没法正常输入。原因是这样的textarea本身的光标理论上不会被innerHTML影响但如果你在高亮过程中修改了textarea的value比如重新赋值光标就会复位到开头。所以正确的做法是不要碰textarea的value只读取它然后更新pre层的内容。但这里还有一个更深层的问题在某些浏览器里即使你不修改textarea的value频繁重绘pre层页面也会导致视觉上的光标闪烁。更麻烦的是如果你想做“自动补全”之类的功能就必须临时修改textarea的value这时候光标恢复就成了刚需。稳妥的做法是保存selectionStart和selectionEnd高亮完成后再用setSelectionRange恢复var start textarea.selectionStart; var end textarea.selectionEnd; pre.innerHTML highlightCode(textarea.value); // 用同步锁防止递归触发input事件 textarea.setSelectionRange(start, end);注意setSelectionRange要在textarea处于焦点状态时才有效所以要确保时序正确。至于为什么这个方法有效本质上是因为textarea的value和高亮渲染是完全独立的两条链路——value管输入数据innerHTML管视觉呈现只要不把两者混在一起改光标就不会乱跑。3. 实操过程与核心环节实现3.1 搭建基础HTML结构整个编辑器就一个容器、一个textarea、一个pre结构极简div classeditor-container textarea ideditor classeditor-input spellcheckfalse autocompleteoff placeholder在这里输入代码.../textarea pre idhighlight classeditor-output aria-hiddentruecode/code/pre /divtextarea是真正干活的pre只是用来显示的。spellcheckfalse是为了防止浏览器给代码做拼写检查下面画红波浪线会干扰视觉。autocompleteoff防止浏览器自作主张补全。3.2 编写CSS对齐、透明、同步滚动这一部分的关键词是“完全一致”。如果两层有任何细微的差异都会表现为文字错位。.editor-container { position: relative; width: 100%; height: 400px; border: 1px solid #ccc; border-radius: 8px; overflow: hidden; } .editor-input, .editor-output { position: absolute; top: 0; left: 0; width: 100%; height: 100%; margin: 0; padding: 16px; border: none; font-family: Consolas, Monaco, Courier New, monospace; font-size: 14px; line-height: 1.6; tab-size: 4; white-space: pre-wrap; word-wrap: break-word; box-sizing: border-box; } .editor-input { z-index: 1; background: transparent; color: transparent; caret-color: #333; outline: none; resize: none; } .editor-output { z-index: 0; pointer-events: none; background: #f8f9fa; color: #333; overflow: auto; }注意textarea设置了color: transparent但caret-color: #333这是光标可见的关键。pre层的overflow: auto是为了后续滚动同步预留的其实滚动动作发生在textarea上pre层设置了pointer-events: none后本身无法滚动。z-index方面我让textarea在上面z-index: 1pre在下面z-index: 0。这样即使pointer-events失效textarea也能挡住点击事件确保输入优先。高亮的颜色主题放在CSS里用类名区分.token.keyword { color: #d73a49; } .token.string { color: #032f62; } .token.comment { color: #6a737d; font-style: italic; } .token.number { color: #005cc5; }这就是GitHub的代码配色方案观感舒适适合默认主题。3.3 编写核心JavaScript高亮、渲染与事件绑定基础HTML和CSS就位后JavaScript是让整套系统动起来的大脑。代码分四块转义、高亮、渲染、事件。(function () { var textarea document.getElementById(editor); var pre document.getElementById(highlight); var codeEl pre.querySelector(code); var KEYWORDS \\b(const|let|var|function|return|if|else|for|while|new|class|import|export|default|extends|super|this)\\b; var STRING_PATTERN (([^\\\\]|\\\\.)*|\([^\\\\\]|\\\\.)*\|([^\\\\]|\\\\.)*); var COMMENT_PATTERN (\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/); var NUMBER_PATTERN \\b(\\d(\\.\\d)?)\\b; function escapeHtml(code) { return code .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;); } function highlightCode(code) { var escaped escapeHtml(code); // 按优先级逐个替换 escaped escaped.replace(new RegExp(COMMENT_PATTERN, g), span classtoken comment$1/span); escaped escaped.replace(new RegExp(STRING_PATTERN, g), span classtoken string$1/span); escaped escaped.replace(new RegExp(KEYWORDS, g), span classtoken keyword$1/span); escaped escaped.replace(new RegExp(NUMBER_PATTERN, g), span classtoken number$1/span); return escaped; } function render() { var value textarea.value; var start textarea.selectionStart; var end textarea.selectionEnd; codeEl.innerHTML highlightCode(value) \n; // 恢复光标如果焦点在textarea上 if (document.activeElement textarea) { textarea.setSelectionRange(start, end); } } // 事件绑定 textarea.addEventListener(input, render); textarea.addEventListener(scroll, function () { pre.scrollTop textarea.scrollTop; pre.scrollLeft textarea.scrollLeft; }); textarea.addEventListener(keydown, function (e) { if (e.key Tab) { e.preventDefault(); var start this.selectionStart; var end this.selectionEnd; // 使用 setRangeText 插入两个空格 this.setRangeText( , start, end, end); // 手动触发一次渲染 render(); } }); // 初始化 textarea.value const greeting Hello, world!;\n// 试试修改这里的代码; render(); textarea.focus(); })();这段代码有几个细节值得说setRangeText是浏览器原生方法能在光标位置替换文本比手工拼接字符串优雅得多。第四个参数传end表示插入后光标移动到插入内容的末尾这个细节很贴心。滚动同步的写法是监听textarea的scroll事件把scrollTop和scrollLeft同步给pre。pre层虽然pointer-events: none但它的overflow: auto仍然生效可以通过JS直接设置滚动位置。高亮函数里每行末尾用的 \n是为了防止最后一行没有换行符导致pre层底部被截断这个小细节能避免滚动条高度少一块的问题。3.4 事件绑定中容易忽略的细节input事件只在有输入变化时触发所以初次加载时要手动调用一次render。keydown里处理Tab键时因为setRangeText已经修改了textarea的值但input事件不会自动触发所以需要手动调用render。window.resize事件也要监听一下因为textarea和pre的尺寸需要保持一致。不过在我的CSS里两者都是absolute定位且宽高100%容器尺寸一改两层会自适应所以不需要额外处理。还有一个小地方渲染函数里的selectionStart和selectionEnd在textarea失去焦点时获取可能不准确所以我在恢复光标前判断了一下document.activeElement textarea。这是一个防御性编程的做法避免在某些极端场景下setSelectionRange抛错。3.5 作为一个组件如何与jQuery、Ajax、ECharts等结合使用标题相关的热搜词里提到“将原生js、jquery、ajax、echarts结合制作网页”这里顺便说一下我这套编辑器如何在完整网页项目里接工。原生JS写出来的模块天然不依赖任何框架可以有多种接入方式如果你想兼容老项目把它封装成一个jQuery插件也很简单$.fn.codeEditor function (options) { return this.each(function () { // 将当前元素替换成编辑器结构 new CodeEditor(originalElement, options); }); };再比如你要做一个“在线代码提交”的页面编辑器只是表单的一部分。用Ajax把文本框里的代码提交到服务器做语法检查或者在线运行$.ajax({ url: /api/check-code, method: POST, contentType: application/json, data: JSON.stringify({ code: textarea.value }), success: function (res) { // 把后端返回的错误信息显示在编辑器旁边 } });如果还需要附带“代码运行结果图表”可以将ECharts引入在用户运行代码后把返回的数据用图表展示。这套组合拳在“代码培训平台”“在线实验工具”这类项目里非常常见。核心要点是原生JS的模块最容易被嵌入到各种技术栈里不用考虑框架的兼容性这也是它最大的优势之一。4. 常见问题与排查技巧实录这个编辑器我前后踩了不少坑下面把最典型的几个问题和排查思路整理成表格方便查阅问题现象原因分析解决方案高亮后光标跳到文本开头渲染时修改了textarea的value或者没有保存/恢复selection保存selectionStart/End渲染完成后用setSelectionRange恢复连续空格被压缩成一个HTML对空格的处理机制给pre设置white-space: pre-wrap高亮大片错乱字符串里的关键字也被变色正则匹配顺序不对先处理注释和字符串再处理关键字和数字页面性能差输入卡顿每次都全量渲染引入防抖函数或只在内容变化区域渲染复制出来的代码带一堆span标签用户选中复制的是pre层内容给pre层设置user-select: none或提供“复制纯文本”按钮Tab键导致焦点跳出编辑器textarea默认行为keydown里preventDefault用setRangeText插入空格多行代码滚动不同步pre层没有同步scrollTop/scrollLeft监听textarea滚动把scrollTop和scrollLeft赋值给pre下面挑几个重点展开说说。4.1 为什么我的光标恢复无效很多朋友按教程写了setSelectionRange但发现光标还是跳来跳去。原因往往是在渲染函数里调用了textarea.value的读取导致浏览器重新计算了布局。我排查这个问题时发现只要在render函数里避免频繁操作textarea的focus状态问题就解决了大半。还有一种情况是你在render里先调用了pre.innerHTML然后setSelectionRange但React/Vue的响应式系统会接管你的血DOM更新时机不一致导致光标位置被覆盖。如果遇到这种问题可以尝试加一个requestAnimationFrame延迟恢复光标requestAnimationFrame(function () { textarea.setSelectionRange(start, end); });这样能把光标恢复操作放到下一帧等浏览器完成DOM渲染后再执行通常能解决问题。4.2 高亮性能优化从卡顿到流畅我的编辑器处理200行代码时已经很流畅了但如果粘贴一份1000行的代码进去每次输入都要全量正则匹配并重绘innerHTML必然会产生可感知的卡顿。优化手段主要有三招第一招是防抖。把input事件触发的高亮操作包进防抖函数只在用户停止输入100~200毫秒后执行一次。这样做不会减少总的计算量但能把密集计算“摊平”用户打字不会一卡一卡的。第二招是逐行处理。把代码按\n分割成数组只对发生变化的那一行做高亮然后重新拼合。这需要对第一次进入编辑器时做一次全量高亮后续每次只更新单行。配合防抖即使大文件也能保证流畅。第三招是使用DocumentFragment批量插入节点避免频繁触发浏览器重排。不过在现代浏览器里字符串拼接innerHTML的性能已经足够好这招适合对性能有极端要求的场景。我实际测试下来500行以内的代码用最朴素的innerHTML方案配合防抖完全没问题。超过500行才需要考虑进一步优化。如果你的编辑器主要用于教学演示、配置编辑这类轻量场景这点性能完全够用。4.3 复制带HTML标签的问题用户从pre层复制代码时复制出来的是带span标签的HTML源码这体验非常糟糕。我在测试时就碰上过——用户从编辑器里复制一行代码粘到别处变成了一堆带颜色的HTML标签。解决办法有两种。其一给pre层设置user-select: none让用户根本没法选中pre层内容只能选textarea的透明文本。但这样做的副作用是用户需要看到光标位置但无法通过鼠标选择大段文本操作体验受影响。所以这只是备选方案。更优雅的方案是放一个“复制纯文本”按钮。点击后用navigator.clipboard.writeText(textarea.value)把textarea里的原始文本写入剪贴板而不是让用户手动选。这个方案对用户更友好也完全规避了HTML标签混杂的问题。document.getElementById(copyBtn).addEventListener(click, function () { navigator.clipboard.writeText(textarea.value).then(function () { // 可以给按钮加一个“已复制”的反馈 }); });这里提一句navigator.clipboard需要HTTPS环境才可用如果是在本地调试就无所谓。4.4 中文输入法导致的高亮闪烁还有一个比较隐蔽的问题使用中文输入法时拼音组合过程会触发input事件导致每一帧都在重新高亮。如果代码行数较多输入中文时会出现明显的闪烁感非常影响体验。解法是加一个防抖让高亮操作在用户停止输入200毫秒后再执行。这样打字过程中不重绘停顿了才更新视觉上就稳定了。如果还想更精细可以监听compositionstart和compositionend事件在输入法组合期间暂停高亮渲染var isComposing false; textarea.addEventListener(compositionstart, function () { isComposing true; }); textarea.addEventListener(compositionend, function () { isComposing false; render(); });在render函数开头判断if (isComposing) return;这样能彻底避免输入法选词期间的高亮闪烁。4.5 隐藏在“看起来正常”里的主题扩展点最后分享一个我后来自行扩展的点主题色。最开始做的时候就想过把高亮颜色和编辑器底色做成可切换的实现方式也很简单——高亮颜色由CSS类控制只要准备几套CSS主题类切换时把容器上的类名替换掉即可。比如做“暗黑模式”只需要替换CSS变量。如果干脆用CSS变量管理所有颜色主题切换会变得极其优雅.editor-container { --bg-color: #ffffff; --text-color: #333333; --keyword-color: #d73a49; --string-color: #032f62; --comment-color: #6a737d; } .editor-container.dark-theme { --bg-color: #1e1e1e; --text-color: #d4d4d4; --keyword-color: #569cd6; --string-color: #ce9178; --comment-color: #6a9955; }这样不管整体页面的风格怎么变编辑器都能无缝适配。5. 我的一些体会和建议在上面这些实现和调优做完之后我最大的感受是原生JS实现的代码高亮编辑器没有想象中复杂但细节密度确实很高。整套方案的核心其实就一个双层结构模型其余所有技术点都围绕“如何让两层保持同步”和“如何让用户操作顺畅”展开。如果你也想在自己的项目里用这套思路我会建议按以下步骤推进先从textarea pre双层结构的最小可运行版本开始不做高亮只保证两者内容同步 再加入正则高亮函数哪怕只是给关键字和字符串上色视觉冲击力就已经很强了 然后逐项处理边角问题光标恢复、Tab键、滚动同步、性能防抖 最后根据你的实际场景决定要不要加行号、复制按钮、主题切换这些增强功能。这样一步步来每一步都有可见的成果也不容易卡在某个点上不敢往下走。我个人强烈建议不要一上来就抄大而全的代码因为很多代码片段互相耦合出问题了根本不知道是哪一环的锅。自己从零写一遍理清每个函数的目的和调用时机后面扩展和维护才有底气。我做完这套编辑器之后再去看CodeMirror的源码很多东西一眼就能对上号——那种“原来如此”的感觉比背一百遍API文档都值。
返回列表