1. 项目概述:Vue3里用marked渲染Markdown,为什么非得配代码高亮?
最近在给一个内部知识库系统做前端重构,技术栈明确是 Vue3 + TypeScript + Vite,后端返回的富文本内容全是 Markdown 格式——不是 HTML,也不是富文本编辑器生成的带 style 的 div 堆砌,就是干净的.md语法。一开始图省事,直接用v-html渲染marked.parse(content)的结果,页面跑起来确实快,但一打开控制台就报错:Uncaught ReferenceError: hljs is not defined。再点开某段 Python 代码块,发现连<pre><code class="language-python">这种基础 class 都没加进去,更别说语法着色了。这说明 marked 默认根本不处理代码高亮,它只负责把```python转成<pre><code>,至于怎么高亮、用什么库、怎么配主题,全得你自己兜底。
这就是标题里“Vue3 使用 marked【代码高亮,安装使用教程】”的真实场景:你不是在写一个玩具 demo,而是在一个真实业务系统里,需要把用户写的、带代码片段的 Markdown 文档,原样、可读、专业地展示出来。用户可能写的是 Vue 组件示例、SQL 查询语句、Shell 脚本,甚至是一段 TypeScript 类型定义。如果代码块灰扑扑一片,没有关键字变色、没有括号匹配、没有行号,那这个知识库的可信度和实用性就掉了一半。所以,“代码高亮”不是锦上添花的功能,而是 marked 在 Vue3 环境下落地的刚需门槛。
我试过三种主流方案:直接引入 highlight.js 全量包、用 Prism.js 替代、或者换用marked的扩展插件marked-highlight。最终选了highlight.js,不是因为它最好,而是它和 Vue3 的组合最稳、文档最全、社区问题最多——意味着踩坑的人多,解决方案也最成熟。它支持 190+ 种语言,主题丰富(比如github-dark和atom-one-dark在暗色模式下表现极佳),而且它的highlightAuto()方法能自动识别语言,对用户不写```js而只写```的情况非常友好。这比硬编码语言类型或依赖marked自带的langPrefix配置要实在得多。整个过程不需要动后端,不改数据结构,纯前端增强,对现有系统侵入性为零。适合所有正在用 Vue3 做文档中心、API 文档、内部 Wiki、甚至博客系统的开发者,尤其适合那些已经用marked解析 Markdown,但卡在高亮这一步的朋友。
2. 整体设计思路与方案选型:为什么不用 VuePress 或 VitePress?
很多人看到“Vue3 + Markdown + 高亮”,第一反应是:“直接上 VuePress 不就完了?”——这话没错,但前提是你的项目本身就是个静态文档站。而我们面对的,是一个基于 Vue3 的后台管理系统,核心功能是权限管理、流程审批、数据看板。知识库只是其中一个模块,嵌在左侧菜单里,路由是/docs/:id,数据来自 REST API,内容是动态加载的。VuePress 是构建时预渲染,VitePress 也是静态生成,它们无法在运行时动态解析一段字符串并立即渲染。你不能在setup()里import('vuepress')然后调render(),这违背了模块化和性能原则。
所以必须回到marked这个轻量级解析器。它的核心价值在于:小、快、可控。压缩后不到 20KB,解析速度远超markdown-it(实测 10KB Markdown 文本,marked平均耗时 8ms,markdown-it为 15ms),而且它的 AST 处理机制非常清晰,方便我们插入自定义逻辑。关键在于,marked本身不绑定任何高亮库,它只提供一个renderer.code()钩子,让你自己决定<code>标签里塞什么。这就给了我们最大的自由度:你可以用highlight.js,也可以用Prism.js,甚至可以自己写一个极简的正则高亮(仅限 demo)。我们选择highlight.js,是因为它和marked的协作模式最成熟——marked把代码块内容和语言名传给你,你调hljs.highlight(),再把返回的 HTML 字符串塞回去,整个链路干净利落。
另一个常见误区是“用v-html就完事了”。这是大忌。v-html会绕过 Vue 的响应式系统,导致你无法监听代码块的点击事件(比如复制按钮)、无法动态切换主题(light/dark)、也无法做懒加载(长文档里上百个代码块,一次性渲染会卡顿)。正确的做法是:把marked的输出封装成一个可复用的 Vue 组件,让高亮逻辑成为组件内部状态的一部分。这个组件接收contentprop,内部用onMounted触发解析,用ref缓存高亮后的 HTML,用watch监听 content 变化并重新渲染。这样,它既是响应式的,又是可测试的,还能轻松集成到 Pinia store 或全局指令中。
最后,关于marked-highlight这个官方插件,我实测过,它在 Vue3 下有兼容性问题:highlight.js的 ESM 导入方式和marked-highlight的 CJS 期望不匹配,会导致hljs对象为undefined。社区里大量 issue 都指向这个问题,解决方案要么是降级highlight.js到旧版,要么是手动 patch 插件源码。这显然增加了维护成本。不如直接手写几行逻辑,把控制权牢牢握在自己手里。毕竟,高亮的核心逻辑就三行:取语言、调高亮、返回 HTML。自己写,debug 起来也快,一行console.log(lang)就能定位问题。
3. 核心细节解析与实操要点:从零开始配置 highlight.js
3.1 安装依赖与版本锁定
第一步永远是安装。这里有两个关键点:版本必须匹配,且不能全量引入。
npm install marked highlight.js # 注意:不要装 highlight.js 的 -all 版本,那是为 Node.js 设计的highlight.js的 npm 包结构分得很细:
highlight.js:核心库,不含任何语言定义highlight.js/lib/languages/javascript:单个语言定义highlight.js/styles/github-dark.css:主题 CSS
如果你直接import hljs from 'highlight.js',得到的是一个空壳,因为默认不包含任何语言。必须显式导入你需要的语言。我们项目里主要用javascript,typescript,html,css,sql,bash,python,所以安装命令是:
npm install highlight.js # 不需要额外 install @types/highlight.js,TypeScript 4.9+ 已内置类型然后在代码里这样导入:
import hljs from 'highlight.js'; import javascript from 'highlight.js/lib/languages/javascript'; import typescript from 'highlight.js/lib/languages/typescript'; import html from 'highlight.js/lib/languages/xml'; // highlight.js 里 HTML 叫 xml import css from 'highlight.js/lib/languages/css'; import sql from 'highlight.js/lib/languages/sql'; import bash from 'highlight.js/lib/languages/bash'; import python from 'highlight.js/lib/languages/python'; // 注册语言,必须在使用前注册 hljs.registerLanguage('javascript', javascript); hljs.registerLanguage('typescript', typescript); hljs.registerLanguage('html', html); hljs.registerLanguage('css', css); hljs.registerLanguage('sql', sql); hljs.registerLanguage('bash', bash); hljs.registerLanguage('python', python); // 设置默认语言(当代码块没指定语言时) hljs.configure({ language: 'plaintext' });提示:
highlight.js的xml语言定义同时支持 HTML 和 XML,所以```html和```xml都能正确高亮。不要去装html语言包,它不存在。
3.2 主题 CSS 的引入与动态切换
highlight.js的主题是纯 CSS,没有 JS 逻辑。这意味着你可以用<link>标签引入,也可以用import。但为了支持暗色模式,推荐用import方式,并配合 CSS 变量。
首先,在src/assets/styles/highlight.scss里写:
// 引入两个主题 @import '~highlight.js/styles/github-dark.css'; @import '~highlight.js/styles/github.css'; // 创建一个 CSS 变量来控制主题 :root { --hl-theme: github; } .dark { --hl-theme: github-dark; } // 动态切换的 hack:用属性选择器 .hljs { &.github { @import '~highlight.js/styles/github.css'; } &.github-dark { @import '~highlight.js/styles/github-dark.css'; } }但这行不通,因为@import在 CSS 里是编译时行为,无法运行时切换。正确做法是:在mounted钩子里,根据当前主题动态创建<style>标签注入。不过更简单的是,直接在main.ts里一次性引入两个主题,并用 class 控制:
// main.ts import 'highlight.js/styles/github.css'; import 'highlight.js/styles/github-dark.css'; // 然后在 App.vue 的根元素上加 class // <div id="app" :class="{ 'dark': isDarkMode }">接着写 CSS:
/* 全局样式 */ .hljs { display: block; overflow-x: auto; padding: 0.5em; background: var(--bg-color); color: var(--text-color); } /* 暗色模式下,强制使用 github-dark 的规则 */ .dark .hljs { background: #0d1117; color: #e6edf3; } /* 但这样不够,因为 highlight.js 的 class 是固定的,比如 .hljs-keyword */ /* 所以我们得覆盖它的默认颜色 */ .dark .hljs-keyword { color: #ff7b72; } .dark .hljs-string { color: #a5d6ff; } /* ... 依此类推,但太麻烦 */最佳实践是:放弃手动覆盖,直接用highlight.js提供的setTheme()方法。但它没有这个方法。真相是:highlight.js的主题 CSS 是独立文件,你只能通过<link>切换。所以最终方案是:
// utils/highlight-theme.ts export function setHighlightTheme(theme: 'github' | 'github-dark') { const link = document.getElementById('highlight-theme') as HTMLLinkElement; if (!link) { const newLink = document.createElement('link'); newLink.id = 'highlight-theme'; newLink.rel = 'stylesheet'; document.head.appendChild(newLink); } const href = theme === 'github' ? 'https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github.css' : 'https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github-dark.css'; (document.getElementById('highlight-theme') as HTMLLinkElement).href = href; }然后在setup()里监听主题变化:
const { isDarkMode } = useTheme(); // 假设你有一个主题 store watch(isDarkMode, (newVal) => { setHighlightTheme(newVal ? 'github-dark' : 'github'); });注意:CDN 地址里的版本号
11.9.0必须和你npm install的版本一致,否则 CSS 和 JS 的 class 名可能不匹配,导致高亮失效。
3.3 marked 的 renderer 配置与安全过滤
marked的核心是renderer。默认的renderer.code()会把代码块转成<pre><code>...</code></pre>,但我们需要在里面插入高亮后的 HTML。关键代码如下:
import { marked } from 'marked'; const renderer = new marked.Renderer(); renderer.code = function(code: string, lang: string, escaped: boolean): string { if (lang && hljs.getLanguage(lang)) { try { const result = hljs.highlight(code, { language: lang, ignoreIllegals: true }); return `<pre><code class="hljs ${result.language}">${result.value}</code></pre>`; } catch (err) { // 语言不支持或代码有误,回退到纯文本 console.warn(`Highlight failed for language "${lang}":`, err); return `<pre><code>${escapeHtml(code)}</code></pre>`; } } else { // 无语言标识,尝试自动识别 const result = hljs.highlightAuto(code); return `<pre><code class="hljs ${result.language}">${result.value}</code></pre>`; } }; // escapeHtml 函数,防止 XSS function escapeHtml(unsafe: string) { return unsafe .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // 配置 marked marked.use({ renderer });这里有几个细节必须注意:
hljs.getLanguage(lang)是必须的校验,否则hljs.highlight()会抛错。ignoreIllegals: true是关键参数,它让highlight.js在遇到不合法语法(比如未闭合的引号)时,不中断整个高亮,而是跳过错误部分继续。hljs.highlightAuto()的返回值里language字段是识别出的语言名,比如javascript,它会作为class加到<code>上,这样 CSS 才能生效。escapeHtml()是安全底线。即使marked开启了sanitize: true,它也只能过滤 HTML 标签,对<script>内的 JS 无效。而escapeHtml()把所有特殊字符转义,彻底杜绝 XSS。
4. 实操过程与核心环节实现:一个可复用的 MarkdownRender 组件
4.1 组件结构与响应式设计
我们不写一个函数,而是一个完整的 Vue3 组件MarkdownRender.vue。它应该支持:
content:必传的 Markdown 字符串enableCopy:是否显示代码块右上角的“复制”按钮lineNumbers:是否显示行号(需额外 CSS 支持)theme:当前主题,用于触发高亮主题切换
组件结构如下:
<template> <div class="markdown-render" v-html="compiledHtml"></div> </template> <script setup lang="ts"> import { ref, onMounted, watch, computed } from 'vue'; import { marked } from 'marked'; import hljs from 'highlight.js'; import javascript from 'highlight.js/lib/languages/javascript'; import typescript from 'highlight.js/lib/languages/typescript'; import html from 'highlight.js/lib/languages/xml'; import css from 'highlight.js/lib/languages/css'; import sql from 'highlight.js/lib/languages/sql'; import bash from 'highlight.js/lib/languages/bash'; import python from 'highlight.js/lib/languages/python'; // 注册语言 hljs.registerLanguage('javascript', javascript); hljs.registerLanguage('typescript', typescript); hljs.registerLanguage('html', html); hljs.registerLanguage('css', css); hljs.registerLanguage('sql', sql); hljs.registerLanguage('bash', bash); hljs.registerLanguage('python', python); const props = defineProps<{ content: string; enableCopy?: boolean; lineNumbers?: boolean; theme?: 'light' | 'dark'; }>(); const emit = defineEmits<{ (e: 'copy', code: string): void; }>(); const compiledHtml = ref<string>(''); // 核心渲染函数 const renderMarkdown = () => { const renderer = new marked.Renderer(); renderer.code = function(code: string, lang: string, escaped: boolean): string { if (lang && hljs.getLanguage(lang)) { try { const result = hljs.highlight(code, { language: lang, ignoreIllegals: true }); let html = `<pre><code class="hljs ${result.language}">${result.value}</code></pre>`; if (props.enableCopy) { html = `<div class="code-block-wrapper">${html}<button class="copy-btn"><template> <div> <MarkdownRender :content="article.content" enable-copy line-numbers @copy="onCodeCopied" /> </div> </template> <script setup> import MarkdownRender from '@/components/MarkdownRender.vue'; const article = reactive({ content: `# Hello World \`\`\`ts const foo = (x: number): number => x * 2; console.log(foo(42)); \`\`\` 这是一个 TypeScript 示例。` }); const onCodeCopied = (code: string) => { ElMessage.success('代码已复制'); }; </script>但这样有个隐患:每次content变化,marked.parse()都会重新执行,对长文档(>10KB)来说,CPU 占用会飙升。实测一个 50KB 的 Markdown,解析耗时 40ms,如果用户快速切换文章,会明显卡顿。解决方案是加一层记忆化(memoization):
// composables/useMarkdown.ts import { ref, computed } from 'vue'; import { marked } from 'marked'; import hljs from 'highlight.js'; // ... 同上注册语言 const cache = new Map<string, string>(); export function useMarkdown(content: string) { const cached = cache.get(content); if (cached) { return ref(cached); } const renderer = new marked.Renderer(); // ... 同上 renderer.code 定义 marked.use({ renderer }); const result = marked.parse(content); cache.set(content, result); return ref(result); } // 在组件里 const compiledHtml = useMarkdown(props.content);但Map的 key 是字符串,对长内容内存占用大。更优解是用WeakMap,但WeakMap的 key 必须是对象。所以最终我们用content的 hash 值作 key:
// utils/hash.ts export function simpleHash(str: string): string { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; // 转为 32bit int } return String(hash); } // useMarkdown.ts const cache = new Map<string, string>(); export function useMarkdown(content: string) { const hash = simpleHash(content); const cached = cache.get(hash); if (cached) { return ref(cached); } // ... 解析逻辑 cache.set(hash, result); return ref(result); }这个 hash 函数足够快,碰撞率在业务场景下可忽略。实测 1000 篇不同文章,hash 冲突为 0。
5. 常见问题与排查技巧实录:那些让你抓狂的“明明代码都对,就是不生效”
5.1 问题速查表
| 现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
| 代码块完全没高亮,显示为纯灰底白字 | highlight.js未正确注册语言,或hljs.getLanguage(lang)返回false | 在renderer.code里console.log(lang, hljs.listLanguages()) | 确保hljs.registerLanguage()调用在marked.parse()之前;检查lang是否拼写错误(如ts应为typescript) |
控制台报hljs.highlight is not a function | highlight.js版本不匹配,或 ESM/CJS 混用 | console.log(hljs),看是否有highlight方法 | 降级到highlight.js@11.9.0,或改用hljs.highlightElement()(需 DOM 元素) |
| 高亮颜色和主题不符(比如暗色模式下还是亮色) | CSS 文件未加载,或 class 名不匹配 | 查看<head>里是否有<link>,检查<code>标签的 class | 确保highlight.js的 CSS 和 JS 版本一致;用浏览器开发者工具检查<code>的 class 是否为hljs javascript |
| 复制按钮点击无反应 | 事件委托失败,或>renderer.code = function(code: string, lang: string, escaped: boolean): string { const id = `hljs-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const html = `<pre><code id="${id}" class="hljs ${lang || ''}">${escapeHtml(code)}</code></pre>`; // 延迟执行,确保 DOM 已插入 setTimeout(() => { const el = document.getElementById(id); if (el && lang && hljs.getLanguage(lang)) { hljs.highlightElement(el); } }, 0); return html; };虽然性能略差(多了 DOM 插入和查找),但兼容性极好,几乎不会出错。 技巧三:行号的终极方案——用 上面的 CSS 行号方案在复杂嵌套里会错位。官方推荐用插件: 然后: 这个插件会自动计算行高、处理换行,比 CSS 方案可靠十倍。 技巧四:Vue3 的 Vue3 控制台会警告 警告只是提醒,不影响功能。强行屏蔽反而掩盖真正的问题。 最后分享一个真实案例:上周上线后,用户反馈“Python 代码高亮不对,关键字没变色”。我立刻用技巧一打印,发现 这种小 mapping,比让用户记住所有语言名要友好得多。这也是为什么我说, |