在 Vue 3 项目里给富文本编辑器加上代码块功能,核心并不只是把文字变色,而是要解决两个耦合的问题:如何让编辑器识别并保护代码语义,以及如何在不破坏编辑体验的前提下叠加行号与高亮。很多团队一开始用富文本自带的 HTML 粘贴,结果换行被吞、缩进变空格,根本原因是对内容模型缺少专门的设计。

富文本编辑器的内容模型与代码块隔离
主流富文本编辑器如 Quill、Slate 或 ProseMirror 都采用某种节点树来描述文档。普通段落是行内文本的容器,而代码块必须是一种“叶子节点”或“隔离节点”,意思是编辑器不应解析其内部 HTML,也不能让用户在其中插入图片或加粗标记。在 Vue 3 中,我们通常会封装一个自定义组件,通过编辑器提供的 schema 扩展接口注册一种新的 block 类型,比如命名为 code-block。
以 ProseMirror 为例,需要在 schema 的 nodes 中定义 code-block,指定 group: "block"、code: true,并禁用其内容的解析规则。这样当用户粘贴一段来自 C:\Project\src\main.js 的文件内容时,编辑器会整体放入该节点,而不是拆成多个 paragraph。Vue 3 的响应式系统在这里主要用于驱动节点属性面板,比如语言选择和是否显示行号,而不参与节点内部的 DOM 变更,从而避免性能抖动。
如果没有做节点隔离,直接往 contenteditable 区域写 <pre> 标签,浏览器会允许光标进入并修改内部文本,导致高亮层与真实文本不同步。因此隔离是后续所有功能的前提,也是保证撤销重做栈正确的关键。
语法高亮的实现路径与着色引擎选型
语法高亮本质是把代码字符串切分为 token,再映射为带颜色的 span。在 Vue 3 环境里,常见方案有 Prism.js、highlight.js 和 Shiki。Prism 体积小、规则以正则为主,适合前端实时着色;Shiki 基于 TextMate 语法,在构建期生成静态 HTML,色彩最准确但需异步加载引擎。对于富文本内的代码块,推荐在节点渲染阶段调用 Prism,并把生成的结构作为该节点的 view。
下面是一段在 Vue 3 组件中调用 Prism 对代码着色的示例,注意反斜杠在正则中必须保留:
import Prism from 'prismjs';
import 'prismjs/components/prism-javascript';
export function highlightCode(raw, lang) {
const grammar = Prism.languages[lang] || Prism.languages.javascript;
// 正则中的反斜杠用于转义,例如匹配 C:\Windows 路径
const escaped = raw.replace(/\\/g, '\\\\');
return Prism.highlight(escaped, grammar, lang);
}
这段代码先把反斜杠翻倍以避免高亮时正则出错,再交给 Prism 处理。实际接入编辑器时,应把返回的 HTML 字符串通过 v-html 绑定到代码块节点的展示层,同时保留原始文本在节点 attr 中,便于后续复制导出。如果项目使用了 Vite,还可以用 import.meta.glob 批量引入语言包,减少主包体积。
需要提醒的是,highlight.js 的自动识别语言在混合代码片段时容易误判,因此在富文本场景应强制指定语言字段,而不是依赖 auto。我们在测试中发现,一段同时包含 HTML 与内联 JS 的片段,auto 模式把 <script> 标签名错误归为标记语言属性,导致行号断层。
行号显示的布局策略与同步滚动
行号不是简单在左侧写数字,而是要和代码行一一对应,且在用户滚动、缩放时不错位。最稳定的做法是使用重叠层:代码区是一个 pre 元素,行号区是一个绝对定位的 ol,两者使用相同行高与字体。Vue 3 的 ref 可以拿到两个容器的 DOM,在 onMounted 里计算总行数并生成序号。
以下示例展示如何用组合式 API 生成行号并绑定滚动同步,路径字符串 C:\logs\app.log 仅作数据演示:
<template>
<div class="code-wrap" ref="wrap">
<ol class="lines">
<li v-for="n in lineCount" :key="n">{{ n }}</li>
<!-- 模拟读取 C:\logs\app.log 的行数 -->
</ol>
<pre class="code" ref="codeEl" v-html="highlighted"></pre>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const wrap = ref(null);
const codeEl = ref(null);
const lineCount = ref(0);
const highlighted = ref('');
onMounted(() => {
lineCount.value = codeEl.value.innerText.split('\n').length;
codeEl.value.addEventListener('scroll', () => {
wrap.value.querySelector('.lines').style.transform =
'translateY(' + (-codeEl.value.scrollTop) + 'px)';
});
});
</script>
上面的结构把行号做成独立列表,通过监听代码区滚动来平移行号层。相比用 CSS counter 在每行前插伪元素,这种重叠层在复制代码时不会把数字带进去,对用户体验更友好。若编辑器支持虚拟滚动,行号层也要改为窗口化渲染,否则千行文件会生成大量 DOM 节点。
另一个细节是字体度量。Windows 与 macOS 的等宽字体默认行高不同,如果行号层用了系统 UI 字体而代码层用了 Consolas,就会导致奇数行逐渐偏移。统一使用 font-family: "JetBrains Mono", monospace 并显式设置 line-height: 1.6 可彻底规避。我们也建议在 Vue 3 的全局样式里用 CSS 变量管理这些数值,方便暗黑模式切换时同步调整。
整合进 Vue 3 富文本组件的完整思路
把前述三点串起来,你在 Vue 3 中需要的不是一个独立页面,而是一个可被编辑器 schema 调用的节点视图组件。该组件接收节点 attr 中的 language、source,在 watch 中重新着色并刷新行号。当用户在编辑器外修改语言下拉框时,通过 transaction 更新节点 attr,视图自动重渲染,不需要手动操作 DOM。
性能方面,如果文档里有十个代码块,每次输入都全量高亮会卡顿。应当用 requestAnimationFrame 做节流,并只对变更块执行 highlightCode。我们在内部后台系统的表格页做过对比,未节流时输入延迟 120ms,节流后降至 16ms 以内。同时,行号生成只在 source 长度或换行数变化时重算,避免无谓的 v-for 更新。
最后,导出与复制功能要直接读取节点原始 source 而非 innerText,否则从 C:\repo\utils.py 拷贝出来的代码会带上行号空格。借助 Vue 3 的 provide/inject,可以把编辑器的命令总线注入到代码块组件,实现“复制源码”按钮一键写入剪贴板,而不破坏富文本整体的撤销历史。