表单里写代码,这事我一直觉得是前端开发里最被低估的痛点。你扔一个裸的 <textarea> 给用户,他就得在纯文本海洋里拿肉眼找花括号配不配对、引号有没有闭合——这跟用记事本写生产代码一个体验,效率低还容易埋坑。
前阵子做一个小型在线 SQL 编辑器,需求挺明确:不要替换成 Monaco 或 CodeMirror(太重了,而且跟现有表单组件冲突),但输入框里得有语法高亮,还得能敲几个字母就弹出代码片段补全。翻来覆去,最好的方案就是 Vue 3 自定义指令——它不侵入你的 v-model 体系,不破坏表单布局,直接在已有 <textarea> 上挂一个 v-syntax 就完事。
指令注册,我踩过的坑
Vue 3 的自定义指令跟 Vue 2 最大的区别,就是生命周期钩子对齐了组件的那一套——mounted、updated、unmounted,不再用 bind、inserted 那些老名字。初看觉得只是改名,写进去才发现,mounted 里拿到的 el 一定是挂载好的真实 DOM,这就省掉了 Vue 2 里经常要做的 nextTick 判断。
我习惯用 注册全局指令,而不是在组件里写 directives 选项——因为这类语法高亮指令通常要跨多个表单复用,全局注册之后,任何 <textarea v-syntax> 都能直接用,不用每个页面 import 一次。
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.directive('syntax', {
mounted(el, binding) {
// el 就是挂载指令的那个 textarea
// binding.value 可以传语言类型,比如 'sql' 或 'javascript'
},
updated(el, binding) {
// 当绑定值变化时触发
},
unmounted(el) {
// 清理工作:取消事件监听、释放 observer
}
})
骨架搭完了,得让指令真的干点事。最核心的就是监听 input 事件——注意不是 change,change 只在失焦时触发,而我们想要的是每一次按键都拿到最新值。这里有个很容易踩的坑:直接用 el.addEventListener('input', handler) 会在 updated 里重复绑定,所以要么在 mounted 绑一次然后存引用,要么用 el._cleanup 存卸载函数。我选了第二种方案,看着更干净。
mounted(el, binding) {
const highlight = () => {
const raw = el.value
// 把 raw 传给高亮渲染函数
// 生成带 span 标签的 HTML
}
el.addEventListener('input', highlight)
// 存卸载函数
el._cleanup = () => el.removeEventListener('input', highlight)
},
unmounted(el) {
el._cleanup?.()
}
你发现没有,mounted 里压根没碰 v-model,直接从 el.value 拿原始值。自定义指令的好处就在这里——它不劫持你的数据流,只在你需要的时候从 DOM 里捞内容,然后吐出视觉效果。如果说有什么出乎我意料,那就是 updated 钩子几乎用不上——因为 input 事件已经覆盖了所有变化场景,除非你外面有人通过 JavaScript 直接改 el.value,但那属于暴力操作,不在常规表单交互范围内。
高亮怎么不丢光标又不卡
好了,指令的架子搭完了,该解决那个最棘手的问题:怎么在用户敲键盘的瞬间,让灰蒙蒙的代码变成彩色。你得明白,<textarea> 本身只认纯文本,想在里面塞 <span> 标签做高亮,门儿都没有。所以通常的做法是 overlay 层——底下放一个带 的 <div>,或者更暴力一点,直接用 innerHTML 替换显示内容,但把真正的值藏在一个隐藏的 <input> 里。我选了后者,因为 的光标行为在不同浏览器里能把你逼疯。Chrome 里好好的,换到 Safari 就乱跳。
分词策略我试过好几个。一开始想用 Prism.js 的 tokenizer,但它返回的是嵌套对象,处理光标位置时特别麻烦。后来换了 highlight.js 的 —— 但这玩意儿直接操作 DOM,不适合我们这种 overlay 模式。最后回到最朴素的方法:自己写正则,按语言特征分词。拿 JavaScript 举例,关键的正则长这样:
const TOKEN_REGEX = /('[\s\S]*?'|"[\s\S]*?"|\/\*[\s\S]*?\*\/|\/\/[^\n]*|\b(function|const|let|var|if|else|return|import|export)\b|[{}()\[\];,.]|\d+\.?\d*|\w+)/g
这玩意儿会把输入的字符串拆成一个个 token:字符串、注释、关键字、花括号、数字、标识符。然后遍历结果,根据 match 的类型包上不同颜色的 <span>。比如 function 关键字是蓝色的,字符串是绿色的,注释是灰色的。语法解析的深度要控制。别想着把 async 和 await 的上下文关系分析清楚,那超出了即时高亮的需求。我们只做浅层着色,给用户一个视觉反馈就够了。
光标消失问题的解法
这是整个方案里最容易翻车的地方。你把 <textarea> 的 value 高亮成 HTML 之后,直接塞进一个 <pre><code> 的 innerHTML 里,用户的光标会瞬间消失。因为显示层和数据层脱钩了。解法倒是不复杂:用一个透明的 <textarea> 覆盖在显示层上面,用户打字全在 textarea 里,显示层只负责把 textarea.value 分词后渲染成彩色 HTML。这样光标永远在 textarea 里,不受高亮渲染影响。唯一的代价是要把 textarea 的背景设成透明,字体、字号、行高必须和显示层的 <pre> 完全一致,否则文字对不齐。我在 CSS 里这么写的:
.textarea-overlay {
position: relative;
font-family: 'Fira Code', monospace;
font-size: 14px;
line-height: 1.5;
}
.textarea-overlay textarea,
.textarea-overlay pre {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
padding: 12px;
margin: 0;
border: 1px solid #ddd;
white-space: pre-wrap;
word-wrap: break-word;
overflow: auto;
}
.textarea-overlay textarea {
color: transparent;
caret-color: black;
background: transparent;
resize: none;
z-index: 2;
}
.textarea-overlay pre {
z-index: 1;
pointer-events: none;
background: #fafafa;
}
注意 caret-color 这个属性——文字透明了,光标得露出来,不然用户不知道自己在哪儿打字。
踩坑记录里提醒一句:replace 回调里的 RegExp 索引得小心,如果正则里有 group,exec 的返回数组长度会变,我在这上头 debug 了半小时。
Vue 模板语法也得照顾
既然我们的指令要支持多种语言,Vue 模板(.vue 文件里的 <template> 部分)也得能高亮。这里多了个麻烦:Vue 模板里有 {{ }} 插值,有 v-if、v-for 这种指令属性,还有 @click 事件绑定。正则得把这些都纳入。我扩展了一下分词逻辑,检测到 vue 语言类型时,额外匹配 v- 前缀的指令和 {{ }} 插值语法。效果大概是这样:<div v-if="show" @click="handleClick">{{ message }}</div> —— v-if 和 @click 染成紫色,{{ message }} 里的变量名用橙色,双引号里的值用绿色。
就是个正则表达式驱动的小引擎,精度肯定比不上 VS Code 那种完整语义分析,但放到表单输入框这个场景里,用户盯着屏幕看到彩色字符一个个冒出来,那种即时视觉反馈已经足够支撑体验了。
智能补全:输入时自动弹出代码片段
语法高亮铺好了,但用户还得自己敲完整个 for 循环。我见过太多人在输入框里把 for 打成 fro,然后删掉重来。这事烦人,而且可以省掉。所以我把智能补全塞进了同一个指令里。
先定义一张映射表。key 是触发词,value 是插入的代码模板。比如 for 对应 for (let i = 0; i < length; i++) { \n // ...\n},if 对应 if (condition) {\n // ...\n}。模板里可以带占位符 ${variable},补全后光标能自动跳到那里。监听输入事件时,我做了件事:取光标所在行,从行首到光标位置提取最后一个单词。如果匹配到映射表里的 key,就在输入框下方渲染一个绝对定位的候选面板。样式用 配合 transform: translateY(100%),贴在输入框底部。
const snippetMap = {
for: `for (let i = 0; i < length; i++) {\n // ...\n}`,
if: `if (condition) {\n // ...\n}`,
log: `console.log(${variable});`,
fn: `function name(${params}) {\n // ...\n}`
}
function getCurrentWord(textarea) {
const pos = textarea.selectionStart
const text = textarea.value
const before = text.slice(0, pos)
const match = before.match(/(\w+)$/)
return match ? match[1] : ''
}
候选列表支持上下箭头导航和回车确认。选中后,替换掉当前单词并插入模板文本。注意一点:替换操作会改变 ,所以得先保存光标位置,补全后再手动调回去,否则高亮和光标会错位。这个坑我踩过,补全后高亮闪了一下就不动了,debug 发现是 input 事件在补全后又触发了一次高亮,但此时光标已经变了。补全完成后,手动触发 updateSyntaxHighlight() 重新跑一遍分词渲染。因为模板里可能有新的关键字或括号,高亮得跟上。键盘事件里有个小细节:用 event.preventDefault() 阻止回车默认换行,但只当候选面板可见时这么做。否则用户在候选列表消失后按回车,会莫名其妙打不出换行。
最后,模板里的 ${variable} 占位符我用 window.prompt() 简单处理了一下——补全前弹个对话框让用户输入,然后填入。真正产品级的做法是用 配合 Range API 做多光标编辑,但表单输入框场景下,弹窗已经够用了。这一套下来,用户输 for 按回车,整段循环代码就出来了,再按 Tab 跳到下一个占位符。
性能优化与边界处理
高亮和补全都跑通了,但我那个输入框在用户快速打字时,会肉眼可见地卡顿。尤其是代码块长到两百行,每次按键都重新跑一遍 highlightElement(),DOM 操作频繁到 Chrome 的 performance 面板直接标红。解决办法不新鲜——防抖。但我踩了个坑:防抖时间设太短(150ms)不管用,设太长(500ms)用户感觉高亮跟不上打字。试下来 300ms 是个平衡点,手指快的开发者每分钟 120 字,这个间隔不会让高亮闪烁,也不会让人觉得「我敲完了它才变色」。
import { debounce } from 'lodash-es'
const debouncedHighlight = debounce((el) => {
updateSyntaxHighlight(el)
}, 300)
// 在 input 事件里调 debouncedHighlight 而非直接调高亮函数
el.addEventListener('input', () => {
debouncedHighlight(el)
})
但光防抖还不够。中文输入法会触发 input 事件,而且拼音拼到一半的时候,输入框里是一串字母,高亮会把它当成普通变量染成蓝色。等用户选字确认,拼音消失、汉字出现,高亮又得重来一遍。这中间会有半秒的「花屏」——蓝色拼音和黑色汉字交替闪一下,很丑。我查了查,标准做法是监听 和 。 触发时设一个标志位 isComposing = true,告诉高亮函数「别动,用户在拼中文」; 触发后再把标志位改回 false,并且手动触发一次高亮更新。这样拼音阶段不渲染,只有最终文字确定后才变色,体验顺滑很多。
let isComposing = false
el.addEventListener('compositionstart', () => {
isComposing = true
})
el.addEventListener('compositionend', () => {
isComposing = false
debouncedHighlight(el)
})
// 在 input 事件里加判断
el.addEventListener('input', () => {
if (isComposing) return
debouncedHighlight(el)
})
然后就是输入框的选型。我一开始用的 <textarea>,因为它天然支持多行、光标定位准确,而且 和 是现成的。 也能做,但光标位置得靠 Range API 自己算,换行、粘贴纯文本、撤销重做一堆兼容要写。对于表单输入框这种场景,<textarea> 就够了。你如果想做那种带行号、缩进线、折叠功能的「编辑器」,那得上 CodeMirror 或 Monaco。但我们的场景只是「在表单里写一段代码,有高亮和补全」,<textarea> + 一个叠在它上面的 <pre> 或 <div> 做高亮层,是最轻量的方案。
指令的参数我用了一个 options 对象来扩展:
app.directive('syntax', {
mounted(el, binding) {
const options = binding.value || {}
const lang = options.language || 'javascript'
const theme = options.theme || 'dark'
const snippetList = options.snippets || []
// 剩下的初始化...
}
})
这样用户可以通过 v-syntax="{ language: 'python', theme: 'light', snippets: ['if', 'for'] }" 来切换语法类型和主题。补全列表也支持外部传入,不同表单可以挂不同的代码片段。还有个小坑:高亮层和输入框的滚动同步。如果 <textarea> 滚动而高亮层不跟着滚,会出现高亮和文字错位。我监听 scroll 事件,把 scrollTop 和 scrollLeft 同步给高亮层。注意 scroll 事件频率很高,得用 节流一下,不然滚动时 CPU 占用会拉到 20%。
性能优化做到这里,其实已经够用了。真正瓶颈不在前端,而在 Prism 那套分词逻辑——如果代码块大到上万行,任何纯前端方案都会崩。但表单输入场景谁会写上万行呢?防抖 + composition 处理 + 滚动同步,这三板斧砍下去,用户基本感觉不到高亮有延迟。
实战接入:在 Vue 3 表单中快速启用
东西写完了,关键还是得用起来顺不顺手。直接上一段最简用法,看完你大概就能判断这玩意儿到底有多轻量。
<template>
<div class="json-input">
<label>JSON 配置</label>
<textarea
v-model="jsonText"
v-syntax="{ language: 'json', theme: 'monokai', snippets: ['true', 'false', 'null'] }"
rows="12"
class="code-input"
></textarea>
<pre class="highlight-layer" aria-hidden="true"></pre>
</div>
</template>
<script setup>
import { ref } from 'vue'
const jsonText = ref('{\n "name": "vue-highlight",\n "version": "1.0.0"\n}')
</script>
就一行指令,没了。v-model 照常双向绑定,用户在输入框里打字,高亮层自动刷新。补全列表我传了三个 JSON 关键字,实际上你可以挂一整个 snippet 库,比如把公司内部 API 的调用模板塞进去。主题切换也简单。之前有人问我,dark 模式怎么跟系统偏好联动。我直接在外面包一层计算属性,根据 window.matchMedia('(prefers-color-scheme: dark)') 动态传 theme 值进去。指令内部只认传进来的字符串,不关心外面怎么变的。
滚动同步那部分,我额外加了个小优化:如果输入框高度没超过高亮层,干脆就不绑 scroll 事件了。省掉一次 throttle 初始化。最后提一嘴,v-syntax 指令的 mounted 阶段,我做了个防御:如果绑定的元素不是 <textarea> 或 <input>,直接 console.warn 并 return。
写这种东西,说穿了就是帮团队省点重复劳动——你不想每次表单里塞代码都手动复制粘贴再调样式吧?
评论