文章
努力加载图片中...
Vue 3 + Markdown-it 集成 Shiki 代码高亮
  • 7589 字

  • 10 分钟

  • 16 次

  • 2025-10-10
标签:

1 前言

Markdown-it 集成代码高亮功能是我写博客项目最头疼的地方。当然,如果仅仅只需要代码高亮,那倒是很省事。我的要求是:可以自定义代码块样式、可以切换浅深色主题、可以展示语言标签、需要有行号、需要有一键复制代码功能。

我在旧的博客项目使用 highlight.js 已经实现过了,但代码写的却是一坨。我问了下 AI (通义),AI 告诉我除了 highlight.js 外,还有 prism.jsshiki 这两种方案。其总结如下:

特性 Highlight.js Prism.js Shiki
易用性 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐
高亮精度 ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
主题质量 ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ (VS Code 一致)
体积 ⭐⭐⭐⭐⭐ (小) ⭐⭐⭐⭐ (中) ⭐⭐ (较大)
SSR/静态生成 ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ (原生支持)
插件生态 ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ (较少)
推荐场景 快速上线,简单需求 高度定制,功能丰富 高质量博客,追求完美视觉
给你的建议:

  • 如果你的博客是静态生成(如用 Vite、Next.js 等),且追求专业、美观、与 VS Code 一致的代码展示效果,强烈推荐 Shiki 它是目前高质量技术博客的首选。
  • 如果你需要丰富的交互功能(如行号、一键复制、语言标签等),且愿意花时间配置,Prism.js 是很好的选择。
    • 如果你希望最快上线,对高亮精度要求不高,Highlight.js 是最省心的方案。

因为我原先就是想将博客做好看,所以在新的博客项目,决定尝试集成 shiki

什么?体积大?易用性低?我听不懂。

2 集成

2.1 安装

我的博客项目是 Vue 3 + Vite 7 + TS 5 + TailwindCSS 4 的,使用的是 pnpm 10。 参照官网安装即可。 安装和使用 | Shiki 中文文档 @shikijs/markdown-it | Shiki 中文文档

需要注意的是,Shiki 的 markdown-it 插件要安装官网@shikijs/markdown-it 这个插件,而不是 markdown-it-shiki 这俩用法有点区别

bash
pnpm add shiki @shikijs/markdown-it

我不需要使用 shiki 提供的转换器 (transformer),所以转换器就没装,需要的可以去装一下。 @shikijs/transformers | Shiki 中文文档

找到你的 markdown-it 实例创建的地方,用 use 使用该插件。使用方式是参考官网的,我还自己选了两个感觉还不错的主题。

ts
import MarkdownIt from 'markdown-it' import Shiki from '@shikijs/markdown-it' const md = new MarkdownIt({ html: true, linkify: true, typographer: true, }) .use( await Shiki({ themes: { light: 'light-plus', dark: 'catppuccin-macchiato', }, }), ) export default md

先贴一下我在页面中使用 markdown-it 的代码。

vue
<script setup lang="ts"> import md from '@/utils/markdown/useMarkdown.ts' const renderedMarkdown = computed(() => { return md.render(currentArticleData.value?.content ?? '') }) </script> <template> <div class="markdown-content" v-html="renderedMarkdown" > </div> </template>

再写写样式美化一下。

css
/* 代码块 */ .markdown-content pre { @apply m-0 mb-2; @apply rounded-2xl; @apply overflow-x-auto overflow-y-hidden; @apply px-2 pb-6; @apply text-sm leading-6 tracking-wider; @apply transition-all duration-300 ease-in-out; box-shadow: rgba(0, 0, 0, 0.1) 0 2px 12px 0; } .dark .markdown-content pre { @apply m-0 mb-2; @apply rounded-2xl; @apply overflow-x-auto overflow-y-hidden; @apply px-2 pb-6; @apply text-sm leading-6 tracking-wider; @apply transition-all duration-300 ease-in-out; box-shadow: rgba(255, 255, 255, 0.1) 0 2px 12px 0; } .markdown-content pre code { @apply flex flex-col justify-center; @apply transition-all duration-300 ease-in-out; }

大概就是这样啦

先不要管语言标签和复制按钮,按照上面的安装,目前语言标签和复制按钮是还没加上的。我是写完了再写笔记的,有些地方可能有点多余或者遗漏,见谅。

2.2 添加行号

我是参照这里: Line numbers · Issue #3 · shikijs/shiki

也就是使用 CSS 的计数器实现。

实现起来也是相当简单

css
.markdown-content pre code { counter-reset: line; @apply flex flex-col justify-center; @apply transition-all duration-300 ease-in-out; } .markdown-content pre code .line::before { content: counter(line); counter-increment: line; @apply inline-flex w-8 mr-4 pl-8 pr-4; @apply items-center justify-end; @apply select-none; @apply border-r border-slate-200 dark:border-slate-700; @apply text-slate-500 dark:text-slate-400; @apply transition-all duration-300 ease-in-out; }

2.3 主题切换

当前的背景色、高亮色其实不是我自定义的,而是 Shiki 主题的效果。既然在配置插件时指定了浅色和深色主题,那么肯定也能进行切换。

查看官网: 深浅色模式 | Shiki 中文文档

官网描述,可以使用媒体查询和基于类名的方式切换主题。感觉有点熟悉,咦? TailwindCSS 不也是这样吗?

于是直接将官网的代码啪地贴了上来,删除了 html 前缀,并且增加了过渡时间(但貌似效果不是很明显,过渡还是有点生硬,不过无伤大雅)。

css
.shiki, .shiki span, .shiki code, .shiki pre { @apply transition-all duration-300 ease-in-out; } .dark .shiki, .dark .shiki span, .dark .shiki code, .dark .shiki pre { @apply transition-all duration-300 ease-in-out; color: var(--shiki-dark) !important; background-color: var(--shiki-dark-bg) !important; font-style: var(--shiki-dark-font-style) !important; font-weight: var(--shiki-dark-font-weight) !important; text-decoration: var(--shiki-dark-text-decoration) !important; } .dark pre.shiki { @apply transition-all duration-300 ease-in-out; background-color: var(--shiki-dark-bg) !important; }

哦吼,直接就成功了。

但是缺点是代码报错。 Cannot resolve '--shiki-dark' custom property 有关 shiki 的变量都找不到。虽然感觉有点不爽,但是由于我实在是找不到解决办法,而且这也能用,所以先不管了。

2.4 语言标签和复制按钮设置

语言标签和复制按钮是 Shiki 没有的,需要自己添加。我说的添加指的是,需要自己在代码块里添加元素来实现,也就是在 html 里添加标签,然后用 CSS 美化一下。

我查了下资料,Shiki 可以使用转化器 transformer 实现

新建一个 ts 文件,名为 transformerMetaHighlight.ts

ts
import type { ShikiTransformer } from 'shiki' /** * Transformer: 在代码块内部顶部添加语言标签和复制按钮 */ const transformerMetaHighlight: ShikiTransformer = { name: 'meta-highlight', // code标签节点 code(node) { // 获取语言标签 const lang = this.options.lang || 'text' // 在代码块内部顶部添加语言标签和复制按钮的 html node.children.unshift({ type: 'element', tagName: 'div', properties: { className: ['shiki-top-wrapper', 'top-wrapper'], }, children: [ { type: 'element', tagName: 'div', properties: { className: ['shiki-language-tag', 'language-tag'], }, children: [ { type: 'text', value: lang.toLowerCase(), // 添加语言 }, ], }, { type: 'element', tagName: 'div', properties: { className: ['shiki-copy-button', 'copy-button'], }, children: [ { type: 'element', tagName: 'i', properties: { className: ['fa-solid', 'fa-copy', 'fa-fw', 'icon-default'], }, children: [], }, { type: 'element', tagName: 'i', properties: { className: ['fa-solid', 'fa-check', 'fa-fw', 'icon-success'], style: 'display: none;', }, children: [], }, ], }, ], }) }, } export default transformerMetaHighlight

就是添加了一个 div 作为容器。容器 div 里边再添加连两个 div,一个用于展示语言标签,另一个里边装两个 i 标签展示图标,其中一个用 display: none; 隐藏。

然后应用这个转化器就行。

ts
import MarkdownIt from 'markdown-it' import Shiki from '@shikijs/markdown-it' import transformerMetaHighlight from '@/utils/markdown/transformer/transformerMetaHighlight.ts' // 导入 const md = new MarkdownIt({ html: true, linkify: true, typographer: true, }) .use( await Shiki({ themes: { light: 'light-plus', dark: 'catppuccin-macchiato', }, transformers: [transformerMetaHighlight], // 应用 }), ) export default md

写下 CSS 美化一下。

css
.markdown-content pre code .shiki-top-wrapper { @apply flex flex-row items-center justify-between; @apply px-2 pb-4; @apply transition-all duration-300 ease-in-out; } .markdown-content pre code .shiki-language-tag { @apply py-2 px-4 rounded-b-lg ml-4; @apply bg-rose-600 dark:bg-rose-400; @apply text-white font-bold; @apply transition-all duration-300 ease-in-out; } .markdown-content pre code .shiki-copy-button { @apply mt-2; @apply transition-all duration-300 ease-in-out; } .markdown-content pre code .shiki-copy-button .icon-default { @apply cursor-pointer; @apply text-sky-600 dark:text-sky-400; @apply hover:text-amber-500 dark:hover:text-amber-400; @apply transition-all duration-300 ease-in-out; } .markdown-content pre code .shiki-copy-button .icon-success { @apply cursor-default; @apply transition-all duration-300 ease-in-out; }

该代码块样式参考的是这位大大的博客:’ https://argvchs.github.io/

2.5 复制按钮功能实现

复制按钮的功能,我实在是找不到资料,所以问了下 AI ,照着 AI 给的方案实现了下。

大概的思路是:

  • 首先,在 transformerMetaHighlight.ts 里,通过遍历 code 标签中的内容,找到标签内所有的代码(基本都在 span 标签中),拼接成完整代码字符串,然后保存到类名为:shiki-copy-button 所在的 div 的自定义属性 data-code 中,方便 ts 读取;
  • 然后编写一个 ts 工具,通过 .shiki-copy-button 这个类名找到 div读取 data-code 中的内容,使用 Clipboard API navigator.clipboard 写入剪切板,让后切换一下图标就行。

思路挺清晰的,实现一下吧。

首先修改 transformerMetaHighlight.ts 中的内容,添加以下代码

ts
// 获取原始代码 const codeText = node.children // 过滤非 span 标签 .filter((n) => n.type === 'element' && n.tagName === 'span') // 遍历 span 的子节点 .map((line) => { // 如果有children属性,遍历 children return 'children' in line ? line.children // 过滤非 element 节点 .filter((n) => n.type === 'element') .map((n) => { // 提取每个 n 中的 text 节点的 value return ( n.children // 过滤非 text 节点 .filter((child) => child.type === 'text') // 获取 value .map((text) => text.value) // 拼接成字符串 .join('') ) }) // 将整句拼接成字符串 .join('') : '' }) // 使用 \n 拼接成字符串 .join('\n')

看着有点复杂,我详细说说我怎么写的

首先,code(node) 这个钩子中的 node 的内容是 code 这个标签的节点信息,节点信息不方便展示,我用元素结构展示一下。

html
<code class="language-javascript"> <div class="shiki-top-wrapper top-wrapper">...</div> <span class="line">...</span> <span class="line">...</span> <span class="line">...</span> <span class="line">...</span> <span class="line">...</span> </code>

shiki-top-wrapper 也就是语言标签和复制按钮所在的 div 容器,下面类名为 line 的 span 标签就是代码所在的 span 容器。

但是还没完,其实这个只是个父 span,其还有很多子 span ,是带有不同 style 样式(具体是字体颜色)的子 span ,用来展示高亮后的单词。

html
<code class="language-javascript"> <div class="shiki-top-wrapper top-wrapper">...</div> <span class="line"> <span style="color:#0000FF;--shiki-dark:#C6A0F6">function</span> <span style="color:#795E26;--shiki-light-font-style:inherit;--shiki-dark:#8AADF4;--shiki-dark-font-style:italic"> helloWorld</span> <span style="color:#000000;--shiki-dark:#939AB7">()</span> <span style="color:#000000;--shiki-dark:#939AB7"> {</span> </span> </code>

所以我们在遍历节点时,首先筛选出 code 标签下的 span ,再遍历这些 span,找到它们的子节点

ts
// 过滤非 span 标签 .filter((n) => n.type === 'element' && n.tagName === 'span') // 遍历 span 的子节点 .map((line) => { ...... }

然后,再遍历这些 span 的子节点,筛选出 span 标签,找到每个 text 节点所在位置,提取出 value,转化为字符串。

ts
// 如果有children属性,遍历 children return 'children' in line ? line.children // 过滤非 element 节点 .filter((n) => n.type === 'element') .map((n) => { // 提取每个 n 中的 text 节点的 value return ( n.children // 过滤非 text 节点 .filter((child) => child.type === 'text') // 获取 value .map((text) => text.value) // 拼接成字符串 .join('') ) }) // 将整句拼接成字符串 .join('') : '' // 若为空则返回空字符串

其中,这里的每一次遍历结果,都会得到代码的一小段,可能是符号,可能是单词。

ts
// 获取 value .map((text) => text.value) // 转化成字符串 .join('')

而这里,每一次遍历结果,就会将所有一小部分的代码,拼接成一行完整代码。

ts
.map((n) => { // 提取每个 n 中的 text 节点的 value return ( n.children // 过滤非 text 节点 .filter((child) => child.type === 'text') // 获取 value .map((text) => text.value) // 拼接成字符串 .join('') ) }) // 将整句拼接成字符串 .join('')

最后这以整段,就会通过 .join('\n') 将每一行代码,拼接成完整的代码。

ts
// 获取原始代码 const codeText = node.children // 过滤非 span 标签 .filter((n) => n.type === 'element' && n.tagName === 'span') // 遍历 span 的子节点 .map((line) => { // 如果有children属性,遍历 children return 'children' in line ? line.children // 过滤非 element 节点 .filter((n) => n.type === 'element') .map((n) => { // 提取每个 n 中的 text 节点的 value return ( n.children // 过滤非 text 节点 .filter((child) => child.type === 'text') // 获取 value .map((text) => text.value) // 拼接成字符串 .join('') ) }) // 将整句拼接成字符串 .join('') : '' }) // 使用 \n 拼接成字符串 .join('\n')

完整的 transformerMetaHighlight.ts 如下

ts
import type { ShikiTransformer } from 'shiki' /** * Transformer: 在代码块内部顶部添加语言标签和复制按钮 */ const transformerMetaHighlight: ShikiTransformer = { name: 'meta-highlight', // code标签节点 code(node) { // 获取语言标签 const lang = this.options.lang || 'text' // 获取原始代码 const codeText = node.children // 过滤非 span 标签 .filter((n) => n.type === 'element' && n.tagName === 'span') // 遍历 span 的子节点 .map((line) => { // 如果有children属性,遍历 children return 'children' in line ? line.children // 过滤非 element 节点 .filter((n) => n.type === 'element') .map((n) => { // 提取每个 n 中的 text 节点的 value return ( n.children // 过滤非 text 节点 .filter((child) => child.type === 'text') // 获取 value .map((text) => text.value) // 拼接成字符串 .join('') ) }) // 将整句拼接成字符串 .join('') : '' }) // 使用 \n 拼接成字符串 .join('\n') // 在代码块内部顶部添加语言标签和复制按钮的 html node.children.unshift({ type: 'element', tagName: 'div', properties: { className: ['shiki-top-wrapper', 'top-wrapper'], }, children: [ { type: 'element', tagName: 'div', properties: { className: ['shiki-language-tag', 'language-tag'], }, children: [ { type: 'text', value: lang.toLowerCase(), }, ], }, { type: 'element', tagName: 'div', properties: { className: ['shiki-copy-button', 'copy-button'], 'data-code': codeText, // 设置原始代码 'aria-label': 'Copy code', // 设置标签(没什么用) }, children: [ { type: 'element', tagName: 'i', properties: { className: ['fa-solid', 'fa-copy', 'fa-fw', 'icon-default'], }, children: [], }, { type: 'element', tagName: 'i', properties: { className: ['fa-solid', 'fa-check', 'fa-fw', 'icon-success'], style: 'display: none;', }, children: [], }, ], }, ], }) }, } export default transformerMetaHighlight

回顾一下实现思路:

  • 首先,在 transformerMetaHighlight.ts 里,通过遍历 code 标签中的内容,找到标签内所有的代码(基本都在 span 标签中),拼接成完整代码字符串,然后保存到类名为:shiki-copy-button 所在的 div 的自定义属性 data-code 中,方便 ts 读取;
  • 然后编写一个 ts 工具,通过 .shiki-copy-button 这个类名找到 div读取 data-code 中的内容,使用 Clipboard API navigator.clipboard 写入剪切板,让后切换一下图标就行。

步骤一完成了,现在就要写一个工具类,将 data-code 中的内容写入剪切板。

AI 给出的思路是使用事件监听。监听页面所有的点击事件,但是只处理 .shiki-copy-button 这个的点击事件。处理时,找到 data-code 属性,获取到代码后,写入剪切板。然后切换图标,设置一个定时器,2秒后将图标切换回来。

思路倒是清晰,新建一个 useCodeBlockCopyCode.ts 文件。

点击处理事件

ts
// 按钮元素类型 type CopyButtonElement = HTMLElement & { _timeoutId?: number } // 按钮选择器需要查找的类 const COPY_BUTTON_SELECTOR = '.shiki-copy-button' /** * 处理点击事件 * @param e */ async function handleCopyClick(e: MouseEvent) { // 获取按钮元素 const target = e.target as HTMLElement const button = target.closest<CopyButtonElement>(COPY_BUTTON_SELECTOR) // 如果不是代码块复制按钮,则返回 if (!button) return // 获取代码字符串 const code = button.getAttribute('data-code') if (!code) { console.warn('找不到 data-code 属性') return } // 获取两个图标元素 const defaultIcon = button.querySelector('.icon-default') as HTMLElement const successIcon = button.querySelector('.icon-success') as HTMLElement if (!defaultIcon || !successIcon) { console.warn('复制按钮图标未找到') return } // 使用 Clipboard API 复制 navigator.clipboard .writeText(code) .then(() => { // 切换图标 defaultIcon.style.display = 'none' successIcon.style.display = 'inline-block' // 清理旧的定时器(防止重复点击导致多个恢复) if (button._timeoutId) { clearTimeout(button._timeoutId) } // 2秒后恢复 button._timeoutId = window.setTimeout(() => { defaultIcon.style.display = 'inline-block' successIcon.style.display = 'none' button._timeoutId = undefined }, 2000) }) .catch((err) => { console.error('复制到剪切板失败: ', err) }) }

事件监听需要在页面中使用 document 添加,所以提供两个方法

ts
/** * 初始化代码复制功能,监听点击事件 */ function setup() { document.addEventListener('click', handleCopyClick) } /** * 销毁代码复制功能,取消监听点击事件 */ function cleanup() { document.removeEventListener('click', handleCopyClick) // 清理所有按钮的定时器 document .querySelectorAll<CopyButtonElement>(COPY_BUTTON_SELECTOR) .forEach((btn) => { if (btn._timeoutId) { clearTimeout(btn._timeoutId) btn._timeoutId = undefined } }) }

完整代码如下:

ts
// 按钮元素类型 type CopyButtonElement = HTMLElement & { _timeoutId?: number } // 按钮选择器需要查找的类 const COPY_BUTTON_SELECTOR = '.shiki-copy-button' /** * 代码块复制功能 */ function useCodeBlockCopyCode() { /** * 初始化代码复制功能,监听点击事件 */ function setup() { document.addEventListener('click', handleCopyClick) } /** * 销毁代码复制功能,取消监听点击事件 */ function cleanup() { document.removeEventListener('click', handleCopyClick) // 清理所有按钮的定时器 document .querySelectorAll<CopyButtonElement>(COPY_BUTTON_SELECTOR) .forEach((btn) => { if (btn._timeoutId) { clearTimeout(btn._timeoutId) btn._timeoutId = undefined } }) } /** * 处理点击事件 * @param e */ async function handleCopyClick(e: MouseEvent) { // 获取按钮元素 const target = e.target as HTMLElement const button = target.closest<CopyButtonElement>(COPY_BUTTON_SELECTOR) // 如果不是代码块复制按钮,则返回 if (!button) return // 获取代码字符串 const code = button.getAttribute('data-code') if (!code) { console.warn('找不到 data-code 属性') return } // 获取两个图标元素 const defaultIcon = button.querySelector('.icon-default') as HTMLElement const successIcon = button.querySelector('.icon-success') as HTMLElement if (!defaultIcon || !successIcon) { console.warn('复制按钮图标未找到') return } // 使用 Clipboard API 复制 navigator.clipboard .writeText(code) .then(() => { // 切换图标 defaultIcon.style.display = 'none' successIcon.style.display = 'inline-block' // 清理旧的定时器(防止重复点击导致多个恢复) if (button._timeoutId) { clearTimeout(button._timeoutId) } // 2秒后恢复 button._timeoutId = window.setTimeout(() => { defaultIcon.style.display = 'inline-block' successIcon.style.display = 'none' button._timeoutId = undefined }, 2000) }) .catch((err) => { console.error('复制到剪切板失败: ', err) }) } return { setup, cleanup, } } export default useCodeBlockCopyCode

使用时,需要等待页面(mardown数据请求,并高亮完成)渲染完成(nextTick),然后再监听点击事件。

ts
<script setup lang="ts"> // 导入部分不写了,这里只是参考 const initData = async () => { // 加载文章详情数据 // 等待渲染完成 await nextTick() useCodeBlockCopyCode().setup() // 启动监听 } onMounted(() => { initData() }) onBeforeUnmount(() => { // 清除监听 useCodeBlockCopyCode().cleanup() }) </script>

完成了,试一下(录制不好,见谅)

至此,算是大功告成了

3 问题

3.1 发现问题

成了吗?算,也不算。

现在出现了一个新的问题,当我在首页,并且第一次点击文章卡片的阅读全文时,页面会先卡住2秒左右,再进入页面。只有第一次会,后面再点击就不会了。为了减缓这种效果,我还加入了全屏加载效果,但是这样治标不治本。

这里表现为进度条卡住。如果没有全屏加载效果,实际效果是点击阅读全文后,整个页面卡住,啥也点不了,然后才进入页面。 而且在上面的 gif 中,可以看到刚结束全屏加载动画进入详情页时,数据都没加载好,要知道,我这个全屏加载是3秒钟啊。我设置全屏加载的目的,就是为了全屏加载结束后就能看到加载好的页面。延长全屏加载时间我更是做不到,3秒是我能接受的极限了,更别说页面还会卡住。 也就是说,他是先卡了2-3秒,才执行组件 onBeforeMount、onMounted 这些生命周期钩子。这非常影响用户体验。

这个问题只会在第一次进入网站,第一次进入文章详情页才发生。

经过我的 大调查 大排查后,最终确定了是 Shiki 插件的问题。因为我把下面这个代码注释掉以后,就不会出现这个问题了。

ts
.use( await Shiki({ themes: { light: 'light-plus', dark: 'catppuccin-macchiato', }, transformers: [transformerMetaHighlight], }), )

我尝试在 文章详情页的 onBeforeMountonMounted 生命周期钩子加入日志,或者在 transformerMetaHighlight.ts 的各中地方加入日志,无一例外,都是在卡顿结束以后,才打印的。说明是比 onBeforeMount 更早的阶段卡住,也说明和 transformerMetaHighlight.ts 那一段节点遍历没关系。

我自己感觉是 await Shiki 这里阻塞了。

目前有点解决不了,考虑使用 prism.js(大哭)。

3.2 按需引入语言

于是在最初集成 markdown-it 时提交的 git 记录上,新建了一个分支,并且进行了及其痛苦的prism.js集成工作后,我…升华了。

啊,不对,是我发现了一个关键点。

我在 Shiki 官网中翻到了这样一段。 @shikijs/markdown-it | Shiki 中文文档

Shiki 的 简写 提供按需加载主题和语言,但也使高亮过程变成异步。不幸的是,markdown-it 本身 不支持异步高亮 默认情况下。 为了解决这个问题,您可以使用 Anthony Fu 的 markdown-it-async。Shiki 也提供与它的集成,您可以从 @shikijs/markdown-it/async 导入 fromAsyncCodeToHtml

英文文档: @shikijs/markdown-it | Shiki

Shiki’s shorthands provides on-demand loading of themes and languages, but also makes the highlighting process asynchronous. Unfortunately, markdown-it itself does NOT support async highlighting out of the box. To workaround this, you can use markdown-it-async by Anthony Fu. Where Shiki also provides an integration with it, you can import fromAsyncCodeToHtml from @shikijs/markdown-it/async.

我疑惑了一会,异步?为什么 markdown 要异步?

翻了一下 Shiki 的有关资料。了解到了,Shiki 需要异步高亮,是由于纯前端的静态网站需要按需加载语言和主题

等会,按需加载语言?

赶紧在 node_modules 翻了下 @shikijs/markdown 的源码,在 index.d.mts 发现了这一段:

ts
import MarkdownIt from 'markdown-it'; import { LanguageInput, BuiltinLanguage } from 'shiki'; import { M as MarkdownItShikiSetupOptions } from './shared/markdown-it.DGIVodq2.mjs'; export { a as MarkdownItShikiExtraOptions } from './shared/markdown-it.DGIVodq2.mjs'; export { fromHighlighter, setupMarkdownIt } from './core.mjs'; type MarkdownItShikiOptions = MarkdownItShikiSetupOptions & { /** * Language names to include. * * @default Object.keys(bundledLanguages) */ langs?: Array<LanguageInput | BuiltinLanguage>; /** * Alias of languages * @example { 'my-lang': 'javascript' } */ langAlias?: Record<string, string>; }; declare function markdownItShiki(options: MarkdownItShikiOptions): Promise<(markdownit: MarkdownIt) => void>; export { MarkdownItShikiSetupOptions, markdownItShiki as default }; export type { MarkdownItShikiOptions };

发现了居然可以按需导入语言,之前有点呆了(绷)。

加载一些必要的语言。

ts
import MarkdownIt from 'markdown-it' import Shiki from '@shikijs/markdown-it' import transformerMetaHighlight from '@/utils/markdown/transformer/transformerMetaHighlight.ts' // 导入 const md = new MarkdownIt({ html: true, linkify: true, typographer: true, }) .use( await Shiki({ themes: { light: 'light-plus', dark: 'catppuccin-macchiato', }, langs: [ 'java', 'javascript', 'typescript', 'vue', 'html', 'css', 'sql', 'bash', 'dockerfile', 'nginx', 'json', 'yaml', 'markdown', 'scss', 'sass', 'cmd', 'json5', 'kotlin', ], transformers: [transformerMetaHighlight], // 应用 }), ) export default md

启动代码以后,发现居然不会卡顿了,看来是加载语言太多导致的卡顿。

3.3 预加载

先贴 useMarkdown.ts 的代码:

ts
import MarkdownIt from 'markdown-it' import Shiki from '@shikijs/markdown-it' import transformerMetaHighlight from '@/utils/markdown/transformer/transformerMetaHighlight.ts' // 导入 const md = new MarkdownIt({ html: true, linkify: true, typographer: true, }) .use( await Shiki({ themes: { light: 'light-plus', dark: 'catppuccin-macchiato', }, langs: [ 'java', 'javascript', 'typescript', 'vue', 'html', 'css', 'sql', 'bash', 'dockerfile', 'nginx', 'json', 'yaml', 'markdown', 'scss', 'sass', 'cmd', 'json5', 'kotlin', ], transformers: [transformerMetaHighlight], // 应用 }), ) export default md

回顾一下上面发现的问题:

当我在首页,并且第一次点击文章卡片的阅读全文时,页面会先卡住2秒左右,再进入页面。只有第一次会,后面再点击就不会了

这是由于,第一次执行 import 时,CommonJS / ESModule 就会将 md 实例缓存,由于卡顿是在 md 创建时,加载 Shiki 插件才出现,所以有了缓存,就不会再执行创建了,也就不会出现卡顿。也算是一种单例模式。

但秉持着一不做二不休,要做就做到底的原则。我决定手动更改为单例模式,并且将初始化时机,修改在 加载 ‘main.ts’ createApp 后,以此达到在进入页面就初始化 MarkdownIt 实例的作用

首先修改 useMarkdown,提供两个变量。

ts
let mdInstance: MarkdownIt | null = null let mdInitPromise: Promise<MarkdownIt> | null = null

将 md 初始化,放到异步函数中,为什么是异步?还记得 Shiki 插件是怎么加载的吗?是通过use(await Shiki)这种方式,Shiki 插件的加载是异步的,所以初始化也应该放在异步函数中。而异步函数返回的永远是 Promise ,所以需要 let mdInitPromise: Promise<MarkdownIt> | null = null,保证第一次初始化时,不会重复请求初始化 mdInstance 实例,而是都等待 mdInitPromise 完成。

ts
async function createMarkdownItInstance(): Promise<MarkdownIt> { if (mdInstance) return mdInstance // 若实例存在,返回 const md = new MarkdownIt({ html: true, linkify: true, typographer: true, }) .use( await Shiki({ themes: { light: 'light-plus', dark: 'catppuccin-macchiato', }, langs: [ 'java', 'javascript', 'typescript', 'vue', 'html', 'css', 'sql', 'bash', 'dockerfile', 'nginx', 'json', 'yaml', 'markdown', 'scss', 'sass', 'cmd', 'json5', 'kotlin', ], transformers: [transformerMetaHighlight], }), ) mdInstance = md // 缓存实例 return md // 返回实例 }

以上是创建实例的方法,还需要获取实例的方法

ts
export function getMarkdownItInstance(): Promise<MarkdownIt> { if (!mdInitPromise) { // 第一次初始化 mdInitPromise = createMarkdownItInstance() } return mdInitPromise }

main.ts 中,执行 getMarkdownItInstance方法

ts
// 导入样式 import './assets/main.css' // 导入全局库 import { createApp } from 'vue' import pinia from '@/stores' import router from './router' import App from './App.vue' // 导入自定义库 import { getMarkdownItInstance } from './utils/markdown/useMarkdown' const app = createApp(App) app.use(pinia) app.use(router) // 初始化 MarkdownItgetMarkdownItInstance().catch((err) => { console.error('初始化 MarkdownIt 实例失败', err) }) app.mount('#app')

修改 vue 组件中,md 实例的应用方式

ts
const renderedMarkdown = ref('') const initData = async () => { // 获取数据的方法 const md = await getMarkdownItInstance() // 获取实例 renderedMarkdown.value = md.render(currentArticleData.value.content ?? '') // 解析 await nextTick() useCodeBlockCopyCode().setup() }

大功告成。

4 细粒度捆绑(Fine-Grained Bundle)

4.1 前言

进行项目打包优化时,发现了 shiki 将语言全部导入了。

也就是说,下面这段的 lang s是没有生效的

ts
await Shiki({ themes: { light: 'light-plus', dark: 'catppuccin-macchiato', }, langs: [ 'java', 'javascript', 'typescript', 'vue', 'html', 'css', 'sql', 'bash', 'dockerfile', 'nginx', 'json', 'yaml', 'markdown', 'scss', 'sass', 'cmd', 'json5', 'kotlin', ], transformers: [transformerMetaHighlight], }),

查了下官网

其中有一段是这么说的:

默认情况下会导入完整的 shiki 捆绑包。如果你使用了细粒度捆绑,你可以从 @shikijs/markdown-it/core 中导入并传入你自己的高亮器:

找了下捆绑包的文章

大概总结一下:

  1. 使用 await Shiki 是直接导入全部语言的
  2. 需要按需导入的话,就要自定义捆绑包

那么下一步就很明确了,怎么自定义捆绑包,在捆绑包的文章里,给出了示例

js
// directly import the theme and language modules, only the ones you imported will be bundled. import nord from '@shikijs/themes/nord' // `shiki/core` entry does not include any themes or languages or the wasm binary. import { createHighlighterCore } from 'shiki/core' import { createOnigurumaEngine } from 'shiki/engine/oniguruma' const highlighter = await createHighlighterCore({ themes: [ // instead of strings, you need to pass the imported module nord, // or a dynamic import if you want to do chunk splitting import('@shikijs/themes/material-theme-ocean') ], langs: [ import('@shikijs/langs/javascript'), // shiki will try to interop the module with the default export () => import('@shikijs/langs/css'), // or a getter that returns custom grammar async () => JSON.parse(await fs.readFile('my-grammar.json', 'utf-8')) ], // `shiki/wasm` contains the wasm binary inlined as base64 string. engine: createOnigurumaEngine(import('shiki/wasm')) }) // optionally, load themes and languages after creation await highlighter.loadTheme(import('@shikijs/themes/vitesse-light')) const code = highlighter.codeToHtml('const a = 1', { lang: 'javascript', theme: 'material-theme-ocean' })

结合前面的集成到 markdown-it 的方法,实现起来是非常简单的。

4.2 自定义捆绑包(Bundle)

在原来的基础上,再导入三个依赖

bash
pnpm add @shikijs/core @shikijs/langs @shikijs/themes

创建 shiki-bundle.ts 文件,写入以下代码

ts
import { createHighlighterCore } from 'shiki/core' import { createOnigurumaEngine } from 'shiki/engine/oniguruma' import getWasm from 'shiki/wasm' export async function createShikiHighlighter() { return await createHighlighterCore({ themes: [ import('@shikijs/themes/light-plus'), import('@shikijs/themes/catppuccin-macchiato'), ], langs: [ import('@shikijs/langs/java'), import('@shikijs/langs/javascript'), import('@shikijs/langs/typescript'), import('@shikijs/langs/vue'), import('@shikijs/langs/html'), import('@shikijs/langs/css'), import('@shikijs/langs/sql'), import('@shikijs/langs/bash'), import('@shikijs/langs/dockerfile'), import('@shikijs/langs/nginx'), import('@shikijs/langs/json'), import('@shikijs/langs/yaml'), import('@shikijs/langs/markdown'), import('@shikijs/langs/scss'), import('@shikijs/langs/sass'), import('@shikijs/langs/cmd'), import('@shikijs/langs/json5'), import('@shikijs/langs/kotlin'), ], engine: createOnigurumaEngine(() => getWasm), }) }

要注意,createHighlighterCore 不要导错包,@shikijs 也有,不过我没试过那个有没有问题。

不多说了吧,相当简单,就是使用 createHighlighterCore 创建一个 Shiki 的 HighligherCore,配置也很简单,手动导入需要的语言和主题,最后指定一下 engine 引擎就行。

在官网中,提供了另一个引擎: RegExp Engines | Shiki 是以原生的 JavaScript 形式运行的引擎,我上面的 OnigurumaEngine 是使用 C 编写的,并且需要额外导入一个 wasm 包来支持

可以看到有 600+kb

官网上说 JavaScript 引擎在浏览器中运行时表现最佳,尤其是在你想要控制包大小的情况下。,并且考虑到我要尽可能减少包的体积,我决定使用这个引擎。

ts
import { createHighlighterCore } from 'shiki/core' import { createJavaScriptRegexEngine } from 'shiki' export async function createShikiHighlighter() { return await createHighlighterCore({ themes: [ import('@shikijs/themes/light-plus'), import('@shikijs/themes/catppuccin-macchiato'), ], langs: [ import('@shikijs/langs/java'), import('@shikijs/langs/javascript'), import('@shikijs/langs/typescript'), import('@shikijs/langs/vue'), import('@shikijs/langs/html'), import('@shikijs/langs/css'), import('@shikijs/langs/sql'), import('@shikijs/langs/bash'), import('@shikijs/langs/dockerfile'), import('@shikijs/langs/nginx'), import('@shikijs/langs/json'), import('@shikijs/langs/yaml'), import('@shikijs/langs/markdown'), import('@shikijs/langs/scss'), import('@shikijs/langs/sass'), import('@shikijs/langs/cmd'), import('@shikijs/langs/json5'), import('@shikijs/langs/kotlin'), ], engine: createJavaScriptRegexEngine({ forgiving: true }), }) }

forgiving 是宽容模式,可以防止解析不支持的语言时报错

4.3 在 markdown-it 中加载

修改创建 markdown-it 实例的方法

ts
import { createShikiHighlighter } from '@/utils/markdown/bundle/shiki-bundle.ts' import { fromHighlighter } from '@shikijs/markdown-it' async function createMarkdownItInstance(): Promise<MarkdownIt> { if (mdInstance) return mdInstance // 获取 highlighter const highlighter = // eslint-disable-next-line @typescript-eslint/no-explicit-any (await createShikiHighlighter()) as unknown as HighlighterGeneric<any, any> const md = new MarkdownIt({ html: true, linkify: true, typographer: true, }) // 使用 fromHighlighter 创建 .use( fromHighlighter(highlighter, { themes: { light: 'light-plus', dark: 'catppuccin-macchiato', }, transformers: [transformerMetaHighlight], }), ) mdInstance = md return md }

可以看到这一行:

ts
// 获取 highlighter const highlighter = // eslint-disable-next-line @typescript-eslint/no-explicit-any (await createShikiHighlighter()) as unknown as HighlighterGeneric<any, any>

我写 // eslint-disable-next-line @typescript-eslint/no-explicit-any 是为了忽略 any 报错。

使用 as unknown as HighlighterGeneric<any, any> 断言是因为 createHighlighterCore 方法返回的是 highlighterCore 类型,而 highlighterCore 实际上是 HighlighterGeneric<never, never> 类型。

但是 fromHighlighter 方法期望的类型是 HighlighterGeneric<any, any> 这里会报错:Type any is not assignable to type never 所以才使用断言

这样就搞定了。

试试效果

5 总结

  1. 学会了 markdown-it 集成 shiki 的方法。
  2. 了解到了 shiki 的知识。
  3. 学会了编写 shiki 的转换器 transformer。
  4. 复习了 css 计数器知识。
  5. 学会了 Clipboard API 的使用方式。
  6. 学会了单例模式的实现方式。
  7. 学会了 shiki 细粒度捆绑,按需导入集成。
作者: Xigrut发布时间: 2025-10-10 23:52:34上次编辑时间: 2026-06-18 19:23:45 许可协议: CC BY-NC-SA 4.0
留言区