// Created At 2026-07-11
// Nuxt · VSCode · Theme · Performance · Hydration

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) // 主题变了要重新处理所有代码块

这种传统做法会带来三个极具毁灭性的痛点:

  1. 闪动:客户端 Hydration 后才能高亮,用户打开网页会看到“原始内容/黑色外壳 → 高亮内容”的明显跳变。
  2. 延迟:每次一键切换主题,客户端都需要重新执行 JS 高亮所有代码块,有明显的视觉等待时间。
  3. CPU 开销:在手机端低配设备上,每次切换主题都调用 WASM 引擎处理大量文本,会导致页面瞬间掉帧。

坑三:服务端高亮 + Dual Themes = 完美方案

真正的突破是把高亮工作移到服务端完成首屏渲染,同时利用 Shiki 的 Dual Themes(双主题) 特性。

⚠️ 先说明一个边界:“把高亮移到服务端”消灭的是首屏(SSR 直出页面)的闪动与客户端高亮开销。但站内 SPA 导航(NuxtLink 跳转、无 SSR payload 命中)时,useAsyncData/useLazyAsyncDatatransform 会在客户端再次执行——此时高亮仍需要 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(/&lt;/g, "<")
      .replace(/&gt;/g, ">")
      .replace(/&amp;/g, "&")
      .replace(/"/g, '"')
      .replace(/&#39;/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 在客户端按需可用,并把方案定位为“首屏直出 + 导航按需高亮”。

⚠️ 服务端高亮的代价与边界

没有免费的午餐——把高亮搬到服务端,代价也随之转移:

  1. 常驻内存:高亮器单例(WASM 引擎 + 十余种语法数据)一旦创建便常驻服务端进程,占用数十 MiB 内存。对 2G 的小服务器,这是需要计入预算的开销。
  2. SSR payload 变大transform 会把高亮后的完整 HTML(含每个 token 的双主题 CSS 变量)放进 SSR payload,文章越长 payload 越大,网络传输与反序列化成本上升。
  3. 与全局状态叠加的风险:若把高亮器或相关状态做成模块级可变全局变量,在 SSR 长驻进程下可能跨请求累积——这是服务端内存增长的常见来源,我在另一篇复盘里完整记录过一次真实事故:《Nuxt SSR 内存泄漏排查实录》。小内存服务器的取舍建议:按需控制加载的语法数量、避免把高亮结果无限缓存,必要时提供 dispose() 释放高亮器实例。

希望这篇实战记录能帮你在使用 Shiki + Nuxt 的路上少走弯路!

如果这篇文章对你有帮助,可以请我喝杯咖啡 ☕️
Ali PayWechat Pay