一切都始于我偶然间浏览的一个基于 Astro 构建的博客。
那个博客的代码块令我印象深刻:细腻的颜色、完美的 VS Code 主题还原,最重要的是,无论我怎么刷新,它都没有那种“先闪一下原始文本,然后再变色”的怪异感(Flash of Unstyled Content)。
我看了看自己当时还在用的 Highlight.js。它其实很尽职,作为一个老牌的前端高亮库,它配置简单,生态丰富。但它的工作原理决定了它的上限:它通常在浏览器端运行(或者虽然在服务端运行但只是简单的正则匹配),生成的 HTML 结构比较简单,而且对现代 VS Code 丰富的主题支持有限。
更让我难受的是,为了支持深色/浅色模式切换,我不得不引入两套庞大的 CSS 文件,或者用 JS 暴力切换 class。结果就是代码块的颜色总感觉和周围的环境格格不入。
“为什么不能像 VS Code 那样渲染代码?”
Astro 告诉我答案是 Shiki。一个基于 TextMate 语法(和 VS Code 用的一样)的高亮引擎。它不是去模拟高亮,它是真正地渲染高亮。
于是,我决定给我的 Next.js 博客也整一个。
我的博客是用 Next.js (App Router) + MDX 构建的。之前的配置是 rehype-highlight。
// 以前的配置(再见了,老伙计)
const mdxOptions = {
rehypePlugins: [rehypeHighlight, rehypeSlug],
}要迁移到 Shiki,最简单的路径是使用 rehype-pretty-code。这是一个基于 Shiki 封装的 rehype 插件,专门为现代 Web 框架设计,不仅支持构建时高亮,还支持代码行号、高亮特定行、显示文件名等高级功能。
首先,把旧的依赖扫地出门,请进新朋友:
bun remove highlight.js rehype-highlight
bun add shiki rehype-pretty-code这步是关键。Shiki 的强大之处在于它可以在构建阶段(Build Time)就生成带颜色的 HTML。这意味着用户下载的 HTML 已经是高亮好的,Zero Runtime JS!
我在 app/posts/[slug]/page.tsx 里重写了 MDX 配置。这里有个坑:我们需要同时支持深色和浅色模式。
Highlight.js 的做法是加载两个 CSS 文件。但 Shiki 的做法更优雅——它可以在生成的 HTML 里的 style 属性中同时写入两套颜色变量!
import rehypePrettyCode from 'rehype-pretty-code'
// ...
rehypePlugins: [
[
rehypePrettyCode,
{
theme: {
dark: 'one-dark-pro', // 我最爱的深色主题
light: 'github-light', // 经典的浅色主题
},
keepBackground: false, // 后面解释为什么要设为 false
defaultLang: {
block: 'plaintext',
inline: 'plaintext',
},
},
],
rehypeSlug,
],配置了双主题后,Shiki 会为每个代码 Token 生成类似这样的 CSS 变量(简化版):
<span style="--shiki-light:#24292e; --shiki-dark:#e5c07b">const</span>但这还不够。rehype-pretty-code 在处理代码块时,默认行为可能会生成两个代码块(一个给深色,一个给浅色),或者依赖我们自己写 CSS 来控制显隐。
为了达到最完美的切换效果(配合我的 Tailwind CSS 深色模式),我在 globals.css 里加了一点魔法:
/* 默认显示浅色模式的元素 */
div[data-theme='light'],
code[data-theme='light'],
span[data-theme='light'] {
display: block;
}
/* 隐藏深色模式的元素 */
div[data-theme='dark'],
code[data-theme='dark'],
span[data-theme='dark'] {
display: none;
}
/* 当 .dark 类存在时(深色模式激活),反转显隐 */
.dark div[data-theme='light'],
.dark code[data-theme='light'],
.dark span[data-theme='light'] {
display: none;
}
.dark div[data-theme='dark'],
.dark code[data-theme='dark'],
.dark span[data-theme='dark'] {
display: block;
}这样,无论是在服务端渲染阶段,还是客户端切换主题,代码块的颜色都是瞬间切换的,没有任何闪烁。
如果只是简单的替换,那也太无趣了。我之前的博客有一个我很喜欢的 Mac 窗口风格的代码块容器(就是左上角有红黄绿三个点的那种)。
迁移到 Shiki 后,我发现原来的 Pre 组件坏了。因为 rehype-pretty-code 输出的 HTML 结构变了。
旧的结构大概是 <pre><code class="hljs language-js">...</code></pre>。
新的结构则是更复杂的嵌套,而且它把主题信息放在了 data-theme 属性里。
为了保留我的 Mac 风格窗口,同时利用 Shiki 的高亮,我必须重写 Pre.tsx。
我遇到的最大的问题是背景色。
如果在 rehype-pretty-code 配置里开启 keepBackground: true,Shiki 会把背景色硬编码在 <pre> 标签的 style 里。这会导致我的 Mac 窗口外框和代码区域背景色脱节,或者在主题切换时出现背景色闪烁。
我的解决方案是:
keepBackground: false,让 Shiki 只管文字颜色。Pre 组件里,利用 Shiki 暴露的 CSS 变量,自己控制背景色。// components/mdx/Pre.tsx (节选)
// 获取当前代码块的主题
const theme = props['data-theme']
return (
<div
className="prose-pre ..."
data-theme={theme} // 把主题传递给外层容器,给 CSS 控制显隐
style={{
...props.style,
// 这里的魔法:根据主题动态使用 Shiki 提供的变量作为背景色!
backgroundColor: theme ? `var(--shiki-${theme}-bg)` : 'var(--bg-color2)',
}}
>
{/* Mac 风格的 Header */}
<div className="flex ...">...</div>
{/* 代码区域 */}
<pre {...props} className="!bg-transparent">
{children}
</pre>
</div>
)这样一来,外层的 Mac 窗口容器和内部的代码背景完美融合,而且能够响应主题切换!
切换完成后,我打开一篇包含大量代码的文章,那感觉只能用“赏心悦目”来形容。
const, let, import 等关键字的颜色终于和我在 VS Code 里看到的一模一样了。这次折腾让我再次意识到,现代前端开发中,Build Time(构建时) 的能力是多么重要。能把计算交给服务器和构建工具的,就绝不要留给用户的浏览器。
Astro 在这方面是先行者,但 Next.js 配合强大的生态(如 rehype-pretty-code),同样能达到极致的效果。
如果你也在写技术博客,别犹豫了,Shiki 值得你拥有。
P.S. 看看下面这段代码,是不是很漂亮?😉
function helloShiki() {
console.log('Goodbye Highlight.js, Hello Shiki!')
}