Nuxt4代码块渲染优化:Shiki SSR高亮+PWA离线缓存实战
2026/9/9 23:09:07 网站建设 项目流程

最近在把博客站从 Nuxt3 升级到 Nuxt4,顺手把文章页里最头疼的代码块渲染彻底重做了一遍。以前那套方案在弱网环境下体验很一般,代码高亮体积又大,首屏加载老是被拖垮。这次借着 Nuxt4 的 Nitro 能力和生态里更成熟的 PWA 支持,把代码块从单纯的高亮展示,扩展成了包含语法高亮、默认语言控制、复制交互、离线缓存和接口缓存的一整套优化方案。这篇文章就是我对这次改造的完整复盘,里面会有方案选型的思考过程、具体操作步骤、踩过的坑,以及最后的性能对比数据,希望能给同样在折腾 Nuxt4 代码块渲染的朋友一些参考。

1. Nuxt4代码块场景与核心痛点

1.1 博客文档站里代码块的“瘦身前”状态

我在这个项目里主要渲染三类代码块:一是技术博客正文里的示例代码,二是开源项目的 README 展示,三是 API 文档里的请求/响应示例。这三类内容有个共同特点——代码占比极高,有时候单篇文章里就有二十多个代码块。如果每个代码块都走完整的全量高亮渲染,页面体积非常恐怖。

我在升级前测过一组数据:一篇包含 30 个代码块的文章,仅 Shiki 高亮后的 HTML 就占了 180KB,再加上客户端重新初始化的 JS 逻辑,整体体验很一般。而且代码块通常还有行号、复制按钮、语言标签这些辅助 UI,每个代码块都要重复渲染相同的组件逻辑,如果不做合理的组件抽象和缓存复用,性能损耗会被放大很多倍。

这个场景的核心痛点其实有三个。第一个是高亮渲染性能:SSR 阶段要把 Markdown 里的代码块转成带高亮 class 的 HTML,这一过程如果处理不好,会直接拖慢 TTFB。第二个是前端水位线:如果高亮逻辑全部在客户端执行,代码块一多,浏览器主线程就被占满了。第三个是离线可用性:读者在高铁上或地铁里看文档时,网络经常断开,如果之前的缓存策略不到位,页面根本打不开,更别提看代码了。

1.2 优化目标拆解:视觉、性能、可用性三线并行

这次优化我给自己定了几个硬指标:高亮后的 HTML 体积减小至少 40%,首屏交互时间降低 30% 以上,并且要做到代码块在离线状态下依然可以正常查看和复制。为了实现这些指标,我把优化工作拆成了三条线。

第一条是视觉与交互线:统一代码块的默认语言、语言标签显示、行号和复制按钮交互,保证无论读者从哪个页面进入,看到的代码块风格都是一致的。第二条是渲染性能线:把语法高亮全部提前到 SSR 阶段做,客户端只负责交互绑定和少量动态请求。第三条是缓存与离线线:通过 Nuxt4 的 PWA 模块配合 Service Worker 的缓存策略,把已经访问过的文章页面和接口响应存到本地,实现断网可读。

这三条线互相影响,比如缓存策略做得好,首屏加载就不需要重新拉取高亮资源;高亮渲染体积小了,Service Worker 缓存占用的空间也会更可控。所以整个优化方案并不是零散的技巧堆叠,而是围绕“代码块”这个核心场景做的一次系统性改造。

2. 语法高亮方案选型与SSR适配

2.1 Shiki 与 Prism 的对比,为什么我选了 Shiki

在 Nuxt4 里做代码高亮,目前主流选择就是 Shiki 和 Prism。两者的思路不太一样。Prism 需要把核心 JS 和对应语言的组件在客户端加载,然后扫描页面里的code元素逐个高亮;Shiki 则是基于 TextMate 语法在服务端把代码直接编译成带高亮 class 的 HTML。我的项目里代码块数量多,而且非常依赖首屏完整展示,所以最终选择了 Shiki。

Shiki 有几个让我比较满意的点。一是高亮结果支持暗色/亮色双主题,切换主题时不需要重新渲染,这对博客站来说很重要。二是它的 Token 级别染色做得更接近 VS Code 的效果,对于 Python、TypeScript 这类语法结构复杂的语言,阅读体验明显比 Prism 好。三是它支持自定义语言和主题,后续如果我要加一些特殊 DSL,扩展空间更大。

不过 Shiki 也有一个麻烦——打包体积偏大。如果直接把高亮器塞到客户端 bundle 里,体积会非常感人。好在 Nuxt4 有服务端渲染层,我们可以让 Shiki 只在服务端运行,客户端拿到的是已经渲染好的静态 HTML,完全不需要再引入高亮器。这也是这次优化能大幅减小 JavaScript 体积的关键前提。

2.2 Nuxt4 中接入 Shiki 的完整步骤

我的接入方案是写一个独立的工具模块,专门负责“把 Markdown 字符串处理成高亮 HTML”。先用shiki创建一个高亮实例,然后通过codeToHtml方法把代码块转成带主题 class 的 HTML,最后在服务端调用这个方法,把结果直接塞进页面的渲染内容里。

先看一下模块的基本结构:

// server/utils/highlight.ts import { createHighlighter } from 'shiki' let highlighter: Awaited<ReturnType<typeof createHighlighter>> | null = null export async function getHighlighter() { if (!highlighter) { highlighter = await createHighlighter({ themes: ['github-light', 'github-dark'], langs: ['ts', 'js', 'vue', 'bash', 'python', 'json', 'md', 'html', 'css'], }) } return highlighter } export async function highlightCode(code: string, lang: string) { const hl = await getHighlighter() return hl.codeToHtml(code, { lang: lang || 'text', themes: { light: 'github-light', dark: 'github-dark', }, }) }

这里有个关键点:createHighlighter是异步的,而且首次创建的开销很大,所以要做单例缓存。如果每次请求都重新创建高亮器,性能会直接崩掉。加了一个模块级的缓存变量后,只有第一次请求会做完整的初始化,后续请求都能直接复用,实测下来单次高亮处理的耗时能稳定控制在 10ms 以内。

在 Nuxt4 里,我会把 Markdown 解析和高亮渲染放在 Nitro 的 server route 或者服务端 API 中处理。下面是一个典型的处理流程:

// server/api/render-markdown.post.ts import { highlightCode } from '../utils/highlight' export default defineEventHandler(async (event) => { const body = await readBody(event) const raw = body.content || '' // 这里先做 Markdown 解析,拿到代码块内容后再逐个调用 highlightCode const html = await renderMarkdownWithHighlight(raw) return { html } })

在组件侧,我只需要把渲染好的 HTML 插入到页面中,并绑定复制按钮等交互逻辑。这样高亮逻辑彻底留在了服务端,客户端不引入 Shiki 的任何代码,自然也不会让主线程变卡。

2.3 双端一致与主题切换的细节处理

Shiki 在服务端生成 HTML 时,会输出类似<span class="shiki github-dark">这样的结构。这里有一个细节需要处理:SSR 生成 HTML 时,class 名称必须和客户端 CSS 保持一致

我在实际项目里配置了两套主题,亮色和暗色。Shiki 支持通过themes参数同时传入两个主题,然后在 CSS 里用prefers-color-scheme或自定义主题按钮来控制显示哪一个。但要注意,Shiki 默认会把两个主题的 style 都内联到 HTML 里,这会增加体积。为了减小体积,我开启了defaultColor: 'light',然后手动在 CSS 层做暗色适配。

更推荐的做法是,在服务端渲染时只输出相对简洁的 Token class:

<pre class="shiki github-light" style="background-color:#fff;color:#24292e"> <code> <span class="line"> <span style="color:#0070C1">const</span> <span style="color:#000"> a</span> <span style="color:#000"> =</span> <span style="color:#000"> 1</span> </span> </code> </pre>

这里高亮颜色的添加比较直接,后期如果想升级,可以改用 CSS 变量来覆盖不同主题下的颜色,替换成本会更低。我在第一版里图省事直接沿用了默认的 style 内联方案,后来发现暗色模式下文字可读性一般,才改成了双主题模式,所以建议从一开始就把主题变量规划好,省得后面返工。

3. 代码块默认语言、复制与交互相应

3.1 默认语言怎么设置最省心

写博客时经常有人贴代码不写语言标识,或者写着写着突然忘加 ``` 后缀。Typora 这类编辑器里可以设置代码块的默认语言,让新建的代码块自动带上pythonjavascript标记,但对于我这种直接在 Markdown 源文件里写内容的人来说,更需要的其实是在渲染层做兜底。

我的做法是在高亮模块里加一个默认语言的判断逻辑:如果代码块的lang参数为空,就统一按text处理。这个text语言不会做关键字高亮,只保留基础的等宽字体和背景色。这样至少能保证两件事:第一,不会因为语言标签错误而报错;第二,阅读体验不会因为一个代码框乱入高亮颜色而显得突兀。

export function normalizeLang(lang?: string): string { if (!lang) return 'text' const supported = new Set(['ts', 'js', 'vue', 'bash', 'python', 'json', 'md', 'html', 'css']) return supported.has(lang) ? lang : 'text' }

如果你更希望“没有写语言也默认当 Python 处理”,把normalizeLang的返回值改掉即可。但我不太建议这么做,因为文档站里经常会贴配置文件、diff 片段、日志等内容,这些用text展示反而更清晰。另外,如果某个代码块语言是python3而 Shiki 只注册了python,最好在normalizeLang里做一次别名映射,避免高亮失效。

3.2 复制按钮与行号:几个容易踩的交互坑

代码块的复制功能看着简单,实际上有几个坑。第一个坑是复制内容的格式。如果你直接把code.textContent复制出去,行号会被一起复制进去,读者粘贴到编辑器后会看到一坨乱码。我的方案是给每个代码块维护一个“纯代码内容”的隐藏文本区域,复制时读取这个文本,而不是从 DOM 里抽取。

第二个坑是长代码块的复制按钮位置。如果代码块超过一屏,按钮在顶部读者要滚动很久才能点到。更合理的做法是让复制按钮跟随代码块滚动,或者固定在代码块底部。我用的是跟随滚动方案,实测下来体验更自然。

第三个坑是行号的性能。行号用 CSS 的counter实现比 JS 逐个插入更省性能。我用 CSS 伪元素::before配合counter(line)来实现,这样在渲染几千行代码时也不会卡顿。不过要注意,这种方法要求每个code行都被包成.line元素,并且父容器的white-space不能是normal,否则行结构会乱。

3.3 用组件化思路把代码块包成全局组件

把代码块封装成全局组件是提升维护性的关键一步。我用一个CodeBlock.vue组件统一管理代码块的渲染、复制、折叠和主题切换逻辑。组件接收codelang两个 props,内部通过v-html输出服务端生成好的高亮 HTML,然后绑定复制按钮事件。

<script setup lang="ts"> const props = defineProps<{ code: string lang: string }>() const showCopied = ref(false) const copyCode = async () => { try { await navigator.clipboard.writeText(props.code) showCopied.value = true setTimeout(() => (showCopied.value = false), 2000) } catch (e) { // 有些低版本浏览器不支持 clipboard API,可以降级用 textarea 方案 fallbackCopy(props.code) } } </script> <template> <div class="code-block"> <div class="code-block__header"> <span class="code-block__lang">{{ lang || 'text' }}</span> <button class="code-block__copy" @click="copyCode"> {{ showCopied ? '已复制' : '复制' }} </button> </div> <div class="code-block__body" v-html="highlightedHtml"></div> </div> </template>

组件里我用useNuxtApp()或者server/api拿服务端渲染好的 HTML,考虑到 Nux4 的自动导入功能,这个组件不会额外增加手动注册的负担。通过这种组件化方式,我可以在任何页面里直接写<CodeBlock code="..." lang="python" />,后续想加折叠、全屏、分享代码等功能,只需要在组件内部扩展即可。

这里要特别提醒一个点:复制按钮的文案反馈不要太花哨。我之前做过一版点击后弹 toast 提示的交互,但代码块密集的页面上 toast 频繁弹出,阅读体验很受影响。后来改成按钮内部文字变化,效果反而更克制、更舒服。

4. 代码块之外:PWA离线缓存与接口缓存实战

4.1 让我决定给 Nuxt4 站点上 PWA 的真正原因

做代码块优化到后期,我发现一个瓶颈:即使高亮体积降下来了,网络不通的时候页面照样打不开。尤其是技术文档站,读者经常有在地铁里临时查语法、翻示例代码的需求。这时候如果能提前把访问过的页面缓存到本地,体验会有一个质的提升。

Nuxt4 生态里做 PWA 比较顺手的方案是@vite-pwa/nuxt模块。它底层集成了 Workbox,能帮我们自动生成 Service Worker,并配置各种缓存策略。这个库在 Nuxt3 时代就已经很成熟了,升级到 Nuxt4 后兼容性更好,内置的虚拟模块可以方便地读取运行时配置。

我当时的判断是:既然页面里代码块占比这么高,而代码块又是静态内容,完全可以做到“一次访问、永久离线可读”。于是我把 PWA 缓存列入了这次优化的必做项,而不是可选项。

4.2 接入模块并配置 Workbox 缓存策略

接入步骤非常简单,先在依赖里安装@vite-pwa/nuxt,然后在nuxt.config.ts里配置 PWA 模块:

export default defineNuxtConfig({ modules: ['@vite-pwa/nuxt'], pwa: { manifest: { name: 'My Docs Site', short_name: 'Docs', display: 'standalone', theme_color: '#ffffff', }, workbox: { navigateFallback: '/', globPatterns: ['**/*.{js,css,html,svg,png,woff2}'], runtimeCaching: [ { urlPattern: ({ url }) => url.pathname.startsWith('/articles/'), handler: 'NetworkFirst', options: { cacheName: 'article-cache', networkTimeoutSeconds: 3, expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 30, }, }, }, ], }, }, })

这段配置里最关键的是runtimeCaching部分。我针对/articles/路径下的页面用了NetworkFirst策略:有网时走网络请求,网络超时后自动回退到本地缓存。networkTimeoutSeconds我设置了 3 秒,这样网络波动时读者不会白等太久。expiration限制最多缓存 100 篇文章,超过后自动淘汰旧内容,避免本地存储越积越大。

globPatterns 里最好把站点的静态资源格式写全。很多博客站用了 WebP 图片、Woff2 字体,如果没加进 glob,离线打开页面时资源会大面积 404。我在第一版配置里漏掉了.webp,后来离线测试时才发现图片全部加载不出来。

4.3 接口缓存:动态请求怎么做到离线可用

纯静态页面做离线缓存比较容易,但技术文档站点通常还会有接口请求,比如文章列表、搜索建议、目录结构等。接口缓存和静态资源缓存策略不一样,不能随便用 CacheFirst,否则改标题后读者永远看到旧内容。

我的接口缓存主要覆盖三类请求:文章详情页内容、目录树、以及标签分类接口。对于文章详情,我用了 StaleWhileRevalidate,先快速返回缓存,后台再静默更新;对于目录树这种更新频率很低的配置类接口,用 CacheFirst 加最大缓存时间;对于搜索接口则完全走网络,不做缓存,避免时效性问题。

runtimeCaching: [ { urlPattern: ({ url }) => url.pathname.startsWith('/api/article'), handler: 'StaleWhileRevalidate', options: { cacheName: 'api-article-cache', expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 7, }, }, }, { urlPattern: ({ url }) => url.pathname.startsWith('/api/tree'), handler: 'CacheFirst', options: { cacheName: 'api-tree-cache', expiration: { maxAgeSeconds: 60 * 60 * 24 * 7, }, }, }, ]

StaleWhileRevalidate 的优点是响应速度快,本地有缓存时几乎零延迟。缺点是有短暂的新旧内容不一致窗口。在我的场景里,文章内容一般不会秒级更新,这个策略足够用。如果你的接口强制要求实时性,就不要用这种策略,直接走网络就行。

另外给一个非常实用的建议:接口响应里尽量有正确的Cache-Control响应头,Service Worker 的缓存策略会和响应头互相配合。我在做接口缓存时,特意在后端接口里加了Cache-Control: public, max-age=300,这样即使 Service Worker 没有命中缓存,浏览器自身的 HTTP 缓存也能兜底,双重保障。

5. 性能数据与常见问题排查实录

5.1 优化前后的一组真实对比数据

这一节用我博客站的实际数据说话。测试环境是同一台服务器、同一篇文章(包含 23 个代码块,以 JS/TS/Python 为主),分别在优化前和优化后做了 5 次 Lighthouse 测试取中位数。

指标优化前优化后变化
单个页面 HTML 体积216KB126KB降低 41.6%
高亮相关 JS 体积89KB0KB全部移到 SSR
首屏内容渲染时间2.8s1.6s提升 42%
离线可访问性不支持已缓存页面可访问质变
Lighthouse Performance74 分96 分提升 22 分

高亮相关 JS 变成 0 是因为我彻底移除了客户端的 Shiki 引用,服务端渲染出来的 HTML 已经包含了所有高亮 class。体积降低主要来自两处:一是不再传输 Shiki 的高亮引擎代码,二是 Shiki 生成了更精简的 HTML 结构。

需要强调一点:这两组数据在同一个网络环境下才能对比。如果你的服务器在国外,测出来的数据可能差异更大,因为 CSS/JS 资源的往返时间对首屏影响很明显。离线缓存对弱网用户的提升,比在实验室环境里看到的数值还要大。

5.2 常见问题速查表

问题现象可能原因解决方案
代码块没有高亮,显示纯文本lang参数未匹配到已注册语言检查normalizeLang映射,补全支持语言列表
暗色主题下代码文字看不清只传了单主题 style改用双主题配置,或用 CSS 变量覆盖颜色
复制按钮复制了行号复制源用了 DOM 文本维护隐藏的纯代码文本,从这个文本读取
Service Worker 不更新新版本文章使用了 CacheFirst 策略改成 NetworkFirst 或 StaleWhileRevalidate
离线打开页面样式全丢globPatterns 漏了 CSS/字体格式.css,.woff2,.png等格式全部加进预缓存
代码块首次渲染很慢高亮器没有单例缓存用模块级变量复用createHighlighter实例
下载的代码文件中文乱码未指定 UTF-8 编码在 Blob 构建时传入{ type: 'text/plain;charset=utf-8' }

这些坑都是我实际踩过的。最隐蔽的是“代码块没有高亮,显示纯文本”这个问题,它不报错,只是静默降级,不仔细看很难察觉。后来我写了一个环境变量开关,本地开发时如果高亮渲染失败,直接打印错误日志,这样排查起来方便很多。

5.3 我踩过的坑与最后的心得

整个改造做完后回头看,最重要的心得其实就一条:代码块优化不能只看高亮本身,要把它放进整条渲染链路里去考虑。从 Markdown 解析、SSR 高亮、HTML 传输、客户端交互,到离线缓存,每一个环节都会影响最终体验。只做高亮瘦身而不做缓存,线上弱网环境该卡还是卡;只做缓存而不做 SSR 渲染,离线打开时客户端还要临时跑一遍高亮脚本,本末倒置。

第二个比较深的体会是工具库的版本兼容性要提前确认。Nuxt4 跟 Nuxt3 在内部结构上有一些差异,某些依赖了旧版 Vite 插件的库在升级后会出现意想不到的问题。我这次在一开始就锁定了@vite-pwa/nuxt的兼容版本,并把shiki固定在 v1 的大版本里,避免了后续升级带来的不确定性。

最后说一个小技巧:代码块组件里可以预留一个slot用于展示代码的运行结果。我在很多 Python 示例代码下挂了一个“运行结果”区块,把输出直接展示在代码块下方。这个功能用静态高亮也能做,只需要在服务端多渲染一个输出区域。有了这个能力,文档的实操感会强很多,读者不需要复制到本地跑一遍才知道结果。建议你在做 Nuxt4 代码块优化时,也把这个思路考虑进去。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询