1. 为什么需要封装代码高亮组件在开发技术博客、文档系统或者在线代码编辑器时代码展示的体验直接影响用户的使用感受。想象一下当你阅读一篇技术文章时如果代码块是灰蒙蒙的一片没有任何高亮和行号想要复制代码还得手动选中这种体验有多糟糕。这就是为什么我们需要一个功能完善的代码高亮组件。我最近在重构个人博客时就遇到了这个问题。原本使用的是简单的pre标签包裹代码结果不仅阅读体验差还经常收到读者反馈说代码复制不方便。于是我开始寻找解决方案最终决定基于highlight.js和Vue3自己封装一个组件。highlight.js确实是个不错的选择它支持180多种编程语言的语法高亮而且体积小巧。但直接使用它的原生API会面临几个问题首先默认样式比较简陋其次缺少行号显示和复制功能最重要的是每次使用都要重复写一堆配置代码。这就是为什么我们需要把它封装成可复用的组件。2. 基础环境搭建与highlight.js集成2.1 安装必要的依赖首先我们需要安装两个核心依赖npm install --save highlight.js highlightjs/vue-pluginhighlight.js是语法高亮的核心库而highlightjs/vue-plugin则是专门为Vue3设计的插件提供了开箱即用的Vue组件。这里有个小技巧如果你确定项目只需要支持特定语言可以安装对应的语言包来减小体积。比如只需要JavaScript和TypeScript支持可以这样安装npm install --save highlight.js highlightjs/vue-plugin highlightjs/languages/javascript highlightjs/languages/typescript2.2 全局注册highlight组件在main.js或main.ts中我们需要进行全局注册import { createApp } from vue import App from ./App.vue import hljs from highlight.js/lib/core import javascript from highlight.js/lib/languages/javascript import hljsVuePlugin from highlightjs/vue-plugin // 注册需要的语言 hljs.registerLanguage(javascript, javascript) const app createApp(App) app.use(hljsVuePlugin) app.mount(#app)这里我特意只注册了JavaScript语言因为实际项目中按需加载语言可以显著减小打包体积。如果你需要更多语言支持可以继续注册其他语言。2.3 基础使用示例现在我们可以在任何组件中直接使用highlightjs组件了template highlightjs languagejavascript :codecodeString / /template script setup const codeString function greet(name) { return Hello, name ! } /script这个基础版本已经能实现语法高亮但离我们的目标还差得远。接下来我们要逐步添加行号、复制功能和主题切换。3. 实现行号显示功能3.1 分析实现思路给代码添加行号看似简单实则有几个技术难点需要考虑如何准确计算代码行数行号与代码行如何保持对齐滚动时如何保持行号与代码同步我最初尝试用CSS的counter-increment属性来实现发现它在处理换行和滚动时表现不佳。最终决定采用更可靠的JavaScript方案通过分析代码字符串中的换行符数量来生成对应的行号列表。3.2 使用指令实现行号Vue3的指令(Directive)非常适合这种DOM操作场景。下面是我实现的v-line-number指令// directives/lineNumber.js export const vLineNumber { mounted(el) { const codeBlock el.querySelector(code) if (!codeBlock) return const lines codeBlock.textContent.split(\n) const lineNumbers document.createElement(div) lineNumbers.className line-numbers lines.forEach((_, i) { const number document.createElement(span) number.className line-number number.textContent i 1 lineNumbers.appendChild(number) }) el.insertBefore(lineNumbers, codeBlock.parentElement) } }然后在组件中使用template div v-line-number classcode-container highlightjs languagejavascript :codecode / /div /template script setup import { vLineNumber } from ./directives/lineNumber /script3.3 完善行号样式为了让行号看起来更专业我们需要添加一些CSS样式.code-container { display: flex; position: relative; background: #f8f8f8; border-radius: 4px; overflow: hidden; .line-numbers { padding: 1em 0.5em; background: #f0f0f0; text-align: right; user-select: none; .line-number { display: block; color: #999; font-family: monospace; line-height: 1.5; } } pre { margin: 0; flex: 1; overflow-x: auto; } }这个方案有个小问题最后一行如果是空行也会显示行号。我们可以通过调整split后的数组长度来解决const lines codeBlock.textContent.split(\n) if (lines[lines.length - 1] ) { lines.pop() }4. 添加代码复制功能4.1 现代复制API的使用过去我们常用document.execCommand实现复制功能但这个API已经被废弃。现在推荐使用更现代的Clipboard API。下面是我实现的复制功能// utils/copy.js export async function copyToClipboard(text) { try { await navigator.clipboard.writeText(text) return true } catch (err) { // 降级方案 const textarea document.createElement(textarea) textarea.value text document.body.appendChild(textarea) textarea.select() const result document.execCommand(copy) document.body.removeChild(textarea) return result } }4.2 实现复制按钮组件为了更好的用户体验我们创建一个独立的CopyButton组件template button classcopy-button clickhandleCopy {{ copied ? ✓ 已复制 : 复制代码 }} /button /template script setup import { ref } from vue import { copyToClipboard } from ../utils/copy const props defineProps({ code: { type: String, required: true } }) const copied ref(false) const handleCopy async () { const success await copyToClipboard(props.code) if (success) { copied.value true setTimeout(() { copied.value false }, 2000) } } /script style scoped .copy-button { position: absolute; top: 0.5rem; right: 0.5rem; padding: 0.25rem 0.5rem; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 4px; color: #fff; cursor: pointer; font-size: 0.8rem; transition: all 0.2s; :hover { background: rgba(255, 255, 255, 0.2); } } /style4.3 集成到代码高亮组件现在我们可以把复制按钮集成到我们的代码高亮组件中template div classcode-container v-line-number highlightjs :languagelanguage :codecode / CopyButton :codecode / /div /template script setup import CopyButton from ./CopyButton.vue import { vLineNumber } from ../directives/lineNumber defineProps({ code: String, language: { type: String, default: javascript } }) /script5. 实现主题切换功能5.1 highlight.js主题系统highlight.js提供了丰富的主题选择所有主题都可以在highlight.js/styles目录下找到。默认情况下我们只需要引入对应的CSS文件即可import highlight.js/styles/github.css但为了实现动态切换我们需要更灵活的方案。5.2 动态主题加载我创建了一个themeManager.js来管理主题// utils/themeManager.js const themes [ github, atom-one-dark, monokai, solarized-light, vs2015 ] let currentTheme null export function getThemes() { return themes } export function setTheme(themeName) { if (!themes.includes(themeName)) { console.warn(Theme ${themeName} not found) return } if (currentTheme) { document.getElementById(hljs-theme)?.remove() } const link document.createElement(link) link.id hljs-theme link.rel stylesheet link.href https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/${themeName}.min.css document.head.appendChild(link) currentTheme themeName localStorage.setItem(hljs-theme, themeName) } // 初始化时读取保存的主题 export function initTheme() { const savedTheme localStorage.getItem(hljs-theme) if (savedTheme) { setTheme(savedTheme) } else { setTheme(github) // 默认主题 } }5.3 主题切换组件实现创建一个ThemeSelector组件template select v-modelselectedTheme changehandleThemeChange option v-fortheme in themes :keytheme :valuetheme {{ formatThemeName(theme) }} /option /select /template script setup import { ref, onMounted } from vue import { getThemes, setTheme } from ../utils/themeManager const themes getThemes() const selectedTheme ref(null) onMounted(() { selectedTheme.value localStorage.getItem(hljs-theme) || github }) const handleThemeChange () { setTheme(selectedTheme.value) } const formatThemeName (name) { return name.split(-).map(word word.charAt(0).toUpperCase() word.slice(1) ).join( ) } /script style scoped select { padding: 0.5rem; border-radius: 4px; border: 1px solid #ddd; background: white; color: #333; } /style5.4 集成主题切换到主组件最后我们把所有功能整合到一个完整的CodeBlock组件中template div classcode-block div classtoolbar ThemeSelector / CopyButton :codecode / /div div classcode-container v-line-number highlightjs :languagelanguage :codecode / /div /div /template script setup import ThemeSelector from ./ThemeSelector.vue import CopyButton from ./CopyButton.vue import { vLineNumber } from ../directives/lineNumber import { initTheme } from ../utils/themeManager defineProps({ code: String, language: { type: String, default: javascript } }) // 初始化主题 initTheme() /script style scoped .code-block { position: relative; margin: 1rem 0; border-radius: 6px; overflow: hidden; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); } .toolbar { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 1rem; background: #f5f5f5; border-bottom: 1px solid #e0e0e0; } .code-container { display: flex; background: white; } /style6. 性能优化与最佳实践6.1 按需加载语言highlight.js支持按需加载语言这对性能有很大提升。我们可以修改组件实现按需加载// utils/hljsLoader.js import hljs from highlight.js/lib/core const loadedLanguages new Set() export async function loadLanguage(lang) { if (loadedLanguages.has(lang)) return try { const module await import(highlight.js/lib/languages/${lang}) hljs.registerLanguage(lang, module.default) loadedLanguages.add(lang) } catch (e) { console.error(Failed to load language: ${lang}, e) } }然后在组件中使用script setup import { ref, watch } from vue import { loadLanguage } from ../utils/hljsLoader const props defineProps({ code: String, language: { type: String, default: javascript } }) watch(() props.language, async (lang) { await loadLanguage(lang) }, { immediate: true }) /script6.2 虚拟滚动优化长代码对于特别长的代码块渲染所有行号会导致性能问题。这时可以使用虚拟滚动技术// directives/virtualLineNumber.js export const vVirtualLineNumber { mounted(el, binding) { const codeBlock el.querySelector(code) if (!codeBlock) return const container el const lineNumbers document.createElement(div) lineNumbers.className line-numbers container.insertBefore(lineNumbers, codeBlock.parentElement) const lines codeBlock.textContent.split(\n) if (lines[lines.length - 1] ) lines.pop() const lineHeight 20 // 根据实际行高调整 const visibleCount Math.ceil(container.clientHeight / lineHeight) const renderLines (startIdx) { lineNumbers.innerHTML const endIdx Math.min(startIdx visibleCount * 2, lines.length) for (let i startIdx; i endIdx; i) { const number document.createElement(span) number.className line-number number.textContent i 1 number.style.position absolute number.style.top ${i * lineHeight}px lineNumbers.appendChild(number) } } container.addEventListener(scroll, () { const startIdx Math.floor(container.scrollTop / lineHeight) renderLines(Math.max(0, startIdx - 5)) }) renderLines(0) } }6.3 响应式设计考虑为了让组件在不同设备上都有良好表现我们需要添加一些响应式样式.code-block { max-width: 100%; media (max-width: 768px) { border-radius: 0; margin-left: -1rem; margin-right: -1rem; .toolbar { padding: 0.5rem; } } } .line-numbers { media (max-width: 480px) { display: none; } }7. 完整组件封装与API设计7.1 组件Props设计一个好的组件应该提供灵活的API。这是我们CodeHighlighter组件的props设计defineProps({ code: { type: String, required: true }, language: { type: String, default: javascript }, showLineNumbers: { type: Boolean, default: true }, showCopyButton: { type: Boolean, default: true }, theme: { type: String, default: null }, maxHeight: { type: [String, Number], default: null } })7.2 事件与插槽为了更好的扩展性我们还需要定义一些事件和插槽template div classcode-block :styleblockStyle div classtoolbar v-ifshowToolbar slot nametoolbar-left / ThemeSelector v-ifshowThemeSelector / slot nametoolbar-right CopyButton v-ifshowCopyButton :codecode / /slot /div div classcode-container :class{ with-numbers: showLineNumbers } v-line-numbershowLineNumbers highlightjs :languagelanguage :codecode / /div /div /template script setup // ...其他导入... const props defineProps({ // ...之前的props... showThemeSelector: { type: Boolean, default: true } }) const emit defineEmits([theme-change, copy]) const blockStyle computed(() { const styles {} if (props.maxHeight) { styles.maxHeight typeof props.maxHeight number ? ${props.maxHeight}px : props.maxHeight } return styles }) const showToolbar computed(() { return props.showCopyButton || props.showThemeSelector }) /script7.3 最终使用示例现在我们可以在项目中这样使用这个组件template CodeHighlighter :codecodeExample languagejavascript :max-height400 copyhandleCopy template #toolbar-left button clickfoldAll折叠全部/button /template /CodeHighlighter /template script setup import CodeHighlighter from ./components/CodeHighlighter.vue const codeExample // 这是一个示例代码 function calculate(a, b) { return a b } console.log(calculate(2, 3)) // 输出5 const handleCopy () { console.log(代码已复制) } const foldAll () { // 实现折叠逻辑 } /script8. 常见问题与解决方案在实际使用过程中我遇到了几个典型问题这里分享下解决方案8.1 高亮不生效的问题有时候代码高亮会失效通常有几个原因语言未正确注册 - 确保你调用了hljs.registerLanguage代码包含特殊字符 - 尝试对代码进行HTML转义自动检测冲突 - 设置autodetect为false解决方案组件script setup import { escapeHtml } from ../utils/helpers const props defineProps({ // ...其他props... escape: { type: Boolean, default: true } }) const processedCode computed(() { let result props.code if (props.escape) { result escapeHtml(result) } return result }) /script8.2 行号错位问题当代码中包含非常长的行时可能会出现行号与内容不对齐的情况。解决方案是.code-container { display: flex; pre { overflow-x: auto; white-space: pre; word-wrap: normal; } .line-numbers { flex-shrink: 0; } }8.3 服务端渲染(SSR)支持如果在Nuxt等SSR框架中使用需要做一些调整// plugins/highlight.client.js import hljs from highlight.js/lib/core import javascript from highlight.js/lib/languages/javascript import hljsVuePlugin from highlightjs/vue-plugin hljs.registerLanguage(javascript, javascript) export default defineNuxtPlugin(nuxtApp { nuxtApp.vueApp.use(hljsVuePlugin) })8.4 自定义语言支持如果需要支持highlight.js没有的语言可以这样扩展// languages/myLang.js export default function(hljs) { return { keywords: if else for while, contains: [ hljs.C_LINE_COMMENT_MODE, hljs.C_BLOCK_COMMENT_MODE, { className: string, begin: , end: } ] } } // 注册自定义语言 import myLang from ./languages/myLang hljs.registerLanguage(mylang, myLang)9. 测试与调试技巧9.1 单元测试策略为代码高亮组件编写测试时应该关注// CodeHighlighter.spec.js import { mount } from vue/test-utils import CodeHighlighter from ./CodeHighlighter.vue describe(CodeHighlighter, () { it(渲染代码高亮, () { const wrapper mount(CodeHighlighter, { props: { code: const a 1, language: javascript } }) expect(wrapper.find(code.hljs).exists()).toBe(true) }) it(显示正确的行号, async () { const wrapper mount(CodeHighlighter, { props: { code: line1\nline2\nline3, showLineNumbers: true } }) await wrapper.vm.$nextTick() const lineNumbers wrapper.findAll(.line-number) expect(lineNumbers.length).toBe(3) expect(lineNumbers[0].text()).toBe(1) }) })9.2 性能测试对于长代码需要测试渲染性能it(处理长代码性能, () { const longCode Array(1000).fill(console.log(test);).join(\n) const start performance.now() mount(CodeHighlighter, { props: { code: longCode, showLineNumbers: true } }) const duration performance.now() - start expect(duration).toBeLessThan(500) })9.3 视觉回归测试使用工具如Storybook或Chromatic来确保样式一致性// CodeHighlighter.stories.js export default { title: Components/CodeHighlighter, component: CodeHighlighter } const Template (args) ({ components: { CodeHighlighter }, setup() { return { args } }, template: CodeHighlighter v-bindargs / }) export const Default Template.bind({}) Default.args { code: function test() { return hello }, language: javascript }10. 扩展功能思路10.1 代码折叠功能实现代码块的折叠/展开功能template div classcode-block div classtoolbar button clicktoggleFold {{ isFolded ? 展开 : 折叠 }} /button !-- 其他工具栏元素 -- /div div classcode-container :stylecontainerStyle v-line-numbershowLineNumbers !isFolded highlightjs :languagelanguage :codecode / /div /div /template script setup import { ref, computed } from vue const props defineProps({ // ...其他props... defaultFolded: { type: Boolean, default: false } }) const isFolded ref(props.defaultFolded) const containerStyle computed(() { return isFolded.value ? { maxHeight: 3em, overflow: hidden } : {} }) const toggleFold () { isFolded.value !isFolded.value } /script10.2 错误定位高亮对于代码编辑器场景可以添加错误行高亮.code-container { :deep(.hljs-line.error) { background-color: rgba(255, 0, 0, 0.1); position: relative; ::after { content: ; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: red; } } }然后通过指令标记错误行// directives/markErrorLine.js export const vMarkErrorLine { mounted(el, binding) { const lines el.querySelectorAll(.hljs-line) if (!lines.length || !binding.value) return binding.value.forEach(lineNumber { if (lines[lineNumber - 1]) { lines[lineNumber - 1].classList.add(error) } }) } }10.3 代码差异对比扩展组件支持Git风格的差异显示.code-container { :deep(.hljs-line.added) { background-color: rgba(0, 255, 0, 0.1); } :deep(.hljs-line.removed) { background-color: rgba(255, 0, 0, 0.1); opacity: 0.6; } :deep(.hljs-line.modified) { background-color: rgba(255, 255, 0, 0.1); } }10.4 多文件代码块支持像VS Code那样显示多个文件标签template div classmulti-file-code div classfile-tabs button v-for(file, index) in files :keyindex :class{ active: activeFileIndex index } clickactiveFileIndex index {{ file.name }} /button /div CodeHighlighter :codeactiveFile.code :languageactiveFile.language / /div /template script setup import { ref, computed } from vue const props defineProps({ files: { type: Array, required: true, validator: value value.every(f f.name f.code) } }) const activeFileIndex ref(0) const activeFile computed(() props.files[activeFileIndex.value]) /script style scoped .multi-file-code { border-radius: 6px; overflow: hidden; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); } .file-tabs { display: flex; background: #f5f5f5; border-bottom: 1px solid #e0e0e0; button { padding: 0.5rem 1rem; background: none; border: none; cursor: pointer; .active { background: white; border-bottom: 2px solid #42b983; } } } /style11. 组件发布与复用11.1 打包为独立库如果你想把这个组件发布为npm包可以这样配置vite// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], build: { lib: { entry: src/components/CodeHighlighter.vue, name: CodeHighlighter, fileName: code-highlighter }, rollupOptions: { external: [vue, highlight.js], output: { globals: { vue: Vue, highlight.js: hljs } } } } })11.2 文档与示例使用Vitepress为组件库创建文档# CodeHighlighter 一个功能丰富的代码高亮Vue组件支持行号、复制和主题切换。 ## 安装 bash npm install yourname/code-highlighter基本使用template CodeHighlighter :codecode languagejavascript / /templateProps属性类型默认值说明codeString-要显示的代码languageStringjavascript代码语言### 11.3 版本更新策略 遵循语义化版本控制 - 补丁版本(1.0.x)bug修复 - 小版本(1.x.0)向后兼容的新功能 - 大版本(x.0.0)不兼容的API变更 ## 12. 替代方案比较 虽然highlight.js是个不错的选择但了解其他方案也很重要 ### 12.1 Prism.js Prism.js是另一个流行的代码高亮库与highlight.js相比 优点 - 更轻量 - 插件系统更灵活 - 主题设计更现代 缺点 - 语言支持略少 - Vue集成不如highlight.js方便 ### 12.2 Shiki Shiki使用VS Code的语法引擎优势在于 - 色彩准确度高 - 支持TextMate主题 - 不需要客户端JS 但需要Node.js环境更适合SSG场景。 ### 12.3 Monaco Editor 如果你需要完整的编辑器功能(如VS Code)可以考虑Monaco Editor - 功能极其丰富 - 支持智能提示 - 但体积非常大 ## 13. 实际项目集成案例 ### 13.1 博客系统集成 在我的个人博客中我是这样集成的 javascript // 在markdown解析器中转换代码块 import { remark } from remark import remarkHtml from remark-html import { unified } from unified const processor unified() .use(remark) .use(remarkHtml, { sanitize: false, handlers: { code(h, node) { return h(CodeHighlighter, { code: node.value, language: node.lang || text }) } } })13.2 文档系统应用在内部文档系统中我们添加了这些功能代码执行按钮(仅限安全代码)代码片段收藏分享功能template CodeHighlighter :codecode :languagelanguage template #toolbar-right button clickexecute v-ifisRunnable运行/button button clickfavorite收藏/button button clickshare分享/button /template /CodeHighlighter /template13.3 在线IDE集成在简易在线IDE中我们结合了代码高亮和编辑功能// 使用contenteditable实现简易编辑 watchEffect(() { if (props.editable) { const codeElement el.value.querySelector(code) if (codeElement) { codeElement.contentEditable true codeElement.addEventListener(input, handleCodeChange) } } })14. 维护与迭代建议14.1 更新策略保持依赖更新定期检查highlight.js版本测试新版本的语言支持关注安全公告14.2 性能监控添加性能追踪// 在组件中添加性能标记 onMounted(() { performance.mark(code-highlight-start) nextTick(() { performance.mark(code-highlight-end) performance.measure(code-highlight, code-highlight-start, code-highlight-end) }) })14.3 用户反馈收集添加反馈机制template div classcode-block !-- ...其他内容... -- div classfeedback span这段代码有帮助吗/span button clicksendFeedback(true)/button button clicksendFeedback(false)/button /div /div /template15. 总结与经验分享在开发这个组件的过程中我积累了一些有价值的经验。首先是关于性能优化的思考最初版本在处理长代码时会出现明显的卡顿通过虚拟滚动和按需加载语言的优化性能提升了近10倍。另一个收获是关于API设计的理解。第一版组件提供了太多配置选项导致使用复杂。后来我遵循约定优于配置的原则减少了不必要的选项同时通过插槽保持扩展性。样式处理上也踩过坑。最初直接在组件内写死了样式导致难以覆盖。后来改为使用CSS变量提供默认值同时允许通过props覆盖灵活性大大提升。最后是关于测试的重要性。在添加行号功能时没有考虑到行尾空行的情况导致生产环境出现bug。现在我会为每个功能都编写测试用例特别是边界条件的测试。