Nuxt 实战:在个人博客中集成 Shiki 自定义 VSCode 主题
为什么要折腾 Shiki?
在个人博客中,代码高亮是阅读体验的核心。市面上主流的 Prism.js 和 Highlight.js 虽然普及度高,但高亮精度有限。相比之下,Shiki 使用与 VSCode 相同的 TextMate 语法引擎,能实现像素级精准的高亮,完全对标 VSCode 的代码着色体验。
我的需求很简单:个人博客使用我自己开发的 VSCode 主题插件 Moongate Theme 的深浅两套配色,代码块在 SSR 场景下正常渲染,且深浅主题能一键联动切换。 听起来不难?实际上坑比想象中多。
坑一:自定义主题怎么加载?
Shiki 官方文档提供了两种常规的加载方式:创建高亮器时直接传入主题对象,或使用 loadTheme 动态加载 JSON 文件。看起来很简单对吧?但当把它放到 Nuxt 项目中时,问题来了。
❌ 错误尝试:依赖 nuxt-shiki 模块
我一开始用了 nuxt-shiki 模块,想着它能帮我省事。配置如下:
import lightTheme from "./assets/themes/light.json"
import darkTheme from "./assets/themes/dark.json"
export default defineNuxtConfig({
modules: ["nuxt-shiki"],
shiki: {
bundledThemes: [lightTheme.name, darkTheme.name],
defaultTheme: lightTheme.name,
},
})
运行后直接报错:
Failed to resolve import "shiki/themes/Moongate Theme Light.mjs"
根本原因:nuxt-shiki 的 bundledThemes 只接受 Shiki 内置主题的名称(如 github-dark),它会自动去 shiki/themes/ 目录下查找对应的内置 .mjs 文件。当你传入自定义主题名称时,它找不到对应的文件,自然 404。
✅ 正确做法:直接用原生 Shiki API
放弃 nuxt-shiki 模块,直接在 Composable 中使用原生 createHighlighter,将其作为常驻内存的全局单例:
// composables/useShikiHighlighter.ts
import { createHighlighter, type Highlighter } from "shiki"
import lightTheme from "~/assets/themes/light.json"
import darkTheme from "~/assets/themes/dark.json"
let highlighterInstance: Highlighter | null = null
export async function getShikiHighlighter() {
if (!highlighterInstance) {
highlighterInstance = await createHighlighter({
themes: [lightTheme, darkTheme],
langs: [
"bash",
"css",
"docker",
"go",
"html",
"javascript",
"json",
"markdown",
"shell",
"sql",
"typescript",
"vue",
"xml",
"yaml",
],
})
}
return highlighterInstance
}
关键点:主题 JSON 对象直接传入 themes 数组,高亮时通过主题的 name 字段引用即可,不再依赖外部文件的动态寻址。
坑二:自定义主题的双主题联动
单主题方案的局限性
最初的方案是在客户端组件挂载后通过 DOMParser 解析 HTML,然后根据当前主题传入对应的主题名称重新高亮:
// ❌ 客户端高亮 + watch 主题变化
const theme = isDark ? "Moongate Theme Dark" : "Moongate Theme Light"
const result = highlighter.codeToHtml(code, { lang, theme })
watch(() => store.theme, highlight) // 主题变了要重新处理所有代码块
这种传统做法会带来三个极具毁灭性的痛点:
- 闪动:客户端 Hydration 后才能高亮,用户打开网页会看到“原始内容/黑色外壳 → 高亮内容”的明显跳变。
- 延迟:每次一键切换主题,客户端都需要重新执行 JS 高亮所有代码块,有明显的视觉等待时间。
- CPU 开销:在手机端低配设备上,每次切换主题都调用 WASM 引擎处理大量文本,会导致页面瞬间掉帧。
坑三:服务端高亮 + Dual Themes = 完美方案
真正的突破是把高亮工作移到服务端完成首屏渲染,同时利用 Shiki 的 Dual Themes(双主题) 特性。
⚠️ 先说明一个边界:“把高亮移到服务端”消灭的是首屏(SSR 直出页面)的闪动与客户端高亮开销。但站内 SPA 导航(NuxtLink 跳转、无 SSR payload 命中)时,
useAsyncData/useLazyAsyncData的transform会在客户端再次执行——此时高亮仍需要 Shiki 在客户端可用。若你的 Nuxt 访问路径全部走服务端渲染,客户端确实接近“零高亮开销”;若存在站内客户端跳转,请把下面的方案理解为“首屏零开销 + 导航时按需高亮”,而不是绝对零开销。
核心工具函数:服务端高亮处理器
创建 utils/shikiProcessor.ts。这里使用正则异步替换未高亮的 HTML 块,并加入一个健壮的兜底机制:即使代码块没有写 language-xxx,也能默认以 text 纯文本进行高亮渲染。
// utils/shikiProcessor.ts
import { getShikiHighlighter } from "~/composables/useShikiHighlighter"
export async function highlightHtmlContent(
htmlContent: string,
): Promise<string> {
if (!htmlContent) return ""
const highlighter = await getShikiHighlighter()
// 增强正则:允许匹配没有定义 language 类的标准 <code> 块
const preCodeRegex = /<pre>\s*<code([^>]*)>([\s\S]*?)<\/code>\s*<\/pre>/g
const matches = [...htmlContent.matchAll(preCodeRegex)]
let resultHtml = htmlContent
for (const match of matches) {
const [fullMatch, attributes, rawCode] = match
// 提取语言类型,若无则默认为 'text'
const langMatch = attributes.match(/class="[^"]*language-(\w+)"/)
const lang = langMatch ? langMatch[1] : "text"
// 解码 HTML 实体,防止 Shiki 二次转义
const code = rawCode
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/&/g, "&")
.replace(/"/g, '"')
.replace(/'/g, "'")
// 🎯 关键:使用 Dual Themes 一次生成包含两套颜色 Token 的 HTML
const highlighted = highlighter.codeToHtml(code, {
lang,
themes: {
light: "Moongate Theme Light",
dark: "Moongate Theme Dark",
},
defaultColor: false, // 核心配置:不生成默认内联 color,完全靠 CSS 变量驱动
})
resultHtml = resultHtml.replace(fullMatch, highlighted)
}
return resultHtml
}
在数据获取层拦截并转换
在组件内部,利用 useLazyAsyncData 的 transform 选项。这步是最高级的优化:SSR 首屏时,数据在服务端被 Node.js 抓取到后瞬间完成高亮替换,数据吐到前端时就已经套好了 Shiki 的外衣。再次提醒:transform 是 Nuxt useAsyncData 的通用机制,它在 SSR 与客户端导航(重新获取数据)时都会运行——”服务端完成高亮”针对的是首屏直出场景。
const { data: page, pending } = useLazyAsyncData<DocDetailResponse>(
`doc-${slug.value}`,
async () => {
const {
public: { apiUrl },
} = useRuntimeConfig()
return await $fetch(`${apiUrl}/api/docs/${slug.value}`)
},
{
watch: [slug],
// 🔥 关键:首屏数据在服务端获取后立即高亮(SSR 直出)
// 注意:SPA 客户端导航时此 transform 仍会在浏览器执行,需确保 Shiki 客户端可用
transform: async (data) => {
if (data && data.content) {
data.highlightedContent = await highlightHtmlContent(data.content)
}
return data
},
},
)
// 优先使用服务端已高亮的完全体 HTML
const contentRef = computed(
() => page.value?.highlightedContent || page.value?.content || "",
)
CSS 变量控制双主题切换
Dual Themes 生成的 HTML 中会精妙地包含 –shiki-light 和 –shiki-dark 两套 CSS 变量。配合 @nuxtjs/color-mode 切换时自动在 标记的 .dark 类,只需在全局样式表中写下几行映射,就能实现纯 CSS 级别的高性能切换:
/* 浅色模式默认映射 */
.shiki {
background-color: var(--shiki-light-bg) !important;
color: var(--shiki-light) !important;
}
.shiki span {
color: var(--shiki-light) !important;
}
/* 深色模式映射 - 纯 CSS 触发,不经过任何 JS 运行时 */
.dark .shiki {
background-color: var(--shiki-dark-bg) !important;
color: var(--shiki-dark) !important;
}
.dark .shiki span {
color: var(--shiki-dark) !important;
}
⚡ 为什么闪动消失了?
通过前后方案的对比,我们可以清晰地看到这个方案的效果(以下”现在”列均指 SSR 首屏场景;站内 SPA 导航仍需按需高亮,见坑三开篇的边界说明):
| 阶段 | 之前(有闪动、有延迟) | 现在(无闪动、首屏零 JS 高亮) |
|---|---|---|
| 服务端 (SSR) | 返回原始 HTML(未高亮的纯文本或暗色外壳) | 返回高亮后的 HTML(已包含全量双主题样式) |
| 客户端挂载 | 显示原始内容 → 加载 JS / WASM → 替换 DOM → 高亮变色 | 直接显示高亮后的 HTML,没有任何视觉时差 |
| 主题一键切换 | watch 状态变化 → 耗费 CPU 重新高亮渲染 | 纯 CSS 切换变量,无需重新高亮 |
核心突破:高亮工作在服务端首屏就位,客户端收到即用。由于两套变量早已直出,切换主题变成了浏览器的原生样式渲染,不再需要 Pinia 去跨组件追踪和重绘。
🎨 细节打磨:代码块边框与呼吸感
最后,给代码块加点微弱的边框和极浅的阴影,能在长文阅读中有效地为代码建立视觉锚点,提升整体的呼吸感与精致度:
.shiki-content pre.shiki {
padding: 1.25rem;
border-radius: 0.5rem;
overflow-x: auto;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
transition:
border-color 0.3s,
box-shadow 0.3s;
}
.dark .shiki-content pre.shiki {
border-color: #2d3748;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
📐 架构设计流向
┌─────────────────┐
│ 服务端 (SSR) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 获取原始内容 │
└────────┬────────┘
│
▼
┌──────────────────────────────────────────────┐
│ transform: highlightHtmlContent() │
│ ├── 正则匹配 <pre><code> │
│ ├── Shiki 原生 API 生成 Dual Themes HTML │
│ └── 替换原始代码块 │
└────────────────┬─────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ 返回高亮后的 HTML (含 --shiki-light / dark) │
└────────────────┬─────────────────────────────┘
│
▼
┌─────────────────┐
│ 客户端直接渲染 │
└────────┬────────┘
│
├─► 浅色模式 ──► 自动映射 --shiki-light (纯 CSS)
└─► 深色模式 ──► 自动映射 --shiki-dark (纯 CSS)
📝 总结
| 遇到的坑 | 解决方案 |
|---|---|
| nuxt-shiki 不支持自定义主题 | 弃用扩展模块,改用原生 createHighlighter 自定义导入 |
| 客户端高亮导致 SSR 闪动缺陷 | 利用 useLazyAsyncData 的 transform 在服务端完成首屏高亮 |
| 深浅主题一键切换存在明显的延迟 | 采用 Shiki Dual Themes 生成双主题 CSS 变量 |
| 主题联动需要复杂的 watch 重渲染 | 纯 CSS 变量控制主题切换,无需重新高亮 |
在前端实战中,面对长文章下的代码高亮需求,“在服务端多做一点,客户端首屏就能少做很多”。通过在服务端利用 Shiki 提取双主题直出,不仅消灭了视觉闪烁,还让博客客户端首屏免受庞大高亮引擎的加载负担。需要再次强调的是:这套收益针对的是 SSR 首屏;如果站点存在站内客户端导航,应确保 Shiki 在客户端按需可用,并把方案定位为“首屏直出 + 导航按需高亮”。
⚠️ 服务端高亮的代价与边界
没有免费的午餐——把高亮搬到服务端,代价也随之转移:
- 常驻内存:高亮器单例(WASM 引擎 + 十余种语法数据)一旦创建便常驻服务端进程,占用数十 MiB 内存。对 2G 的小服务器,这是需要计入预算的开销。
- SSR payload 变大:
transform会把高亮后的完整 HTML(含每个 token 的双主题 CSS 变量)放进 SSR payload,文章越长 payload 越大,网络传输与反序列化成本上升。 - 与全局状态叠加的风险:若把高亮器或相关状态做成模块级可变全局变量,在 SSR 长驻进程下可能跨请求累积——这是服务端内存增长的常见来源,我在另一篇复盘里完整记录过一次真实事故:《Nuxt SSR 内存泄漏排查实录》。小内存服务器的取舍建议:按需控制加载的语法数量、避免把高亮结果无限缓存,必要时提供
dispose()释放高亮器实例。
希望这篇实战记录能帮你在使用 Shiki + Nuxt 的路上少走弯路!

