7589 字
10 分钟
16 次
- 2025-10-10
1 前言
Markdown-it 集成代码高亮功能是我写博客项目最头疼的地方。当然,如果仅仅只需要代码高亮,那倒是很省事。我的要求是:可以自定义代码块样式、可以切换浅深色主题、可以展示语言标签、需要有行号、需要有一键复制代码功能。
我在旧的博客项目使用 highlight.js 已经实现过了,但代码写的却是一坨。我问了下 AI (通义),AI 告诉我除了 highlight.js 外,还有 prism.js 和 shiki 这两种方案。其总结如下:
| 特性 | 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 这俩用法有点区别。
bashpnpm add shiki @shikijs/markdown-it
我不需要使用 shiki 提供的转换器 (transformer),所以转换器就没装,需要的可以去装一下。 @shikijs/transformers | Shiki 中文文档
找到你的 markdown-it 实例创建的地方,用 use 使用该插件。使用方式是参考官网的,我还自己选了两个感觉还不错的主题。
tsimport 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
tsimport 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;隐藏。
然后应用这个转化器就行。
tsimport 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 如下
tsimport 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], }), )
我尝试在 文章详情页的 onBeforeMount 和 onMounted 生命周期钩子加入日志,或者在 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-ititself does NOT support async highlighting out of the box. To workaround this, you can usemarkdown-it-asyncby Anthony Fu. Where Shiki also provides an integration with it, you can importfromAsyncCodeToHtmlfrom@shikijs/markdown-it/async.
我疑惑了一会,异步?为什么 markdown 要异步?
翻了一下 Shiki 的有关资料。了解到了,Shiki 需要异步高亮,是由于纯前端的静态网站需要按需加载语言和主题。
等会,按需加载语言?
赶紧在 node_modules 翻了下 @shikijs/markdown 的源码,在 index.d.mts 发现了这一段:
tsimport 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 };
发现了居然可以按需导入语言,之前有点呆了(绷)。
加载一些必要的语言。
tsimport 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 的代码:
tsimport 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,提供两个变量。
tslet 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 完成。
tsasync 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 // 返回实例 }
以上是创建实例的方法,还需要获取实例的方法
tsexport 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 实例的应用方式
tsconst 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是没有生效的
tsawait 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中导入并传入你自己的高亮器:
找了下捆绑包的文章
大概总结一下:
- 使用
await Shiki是直接导入全部语言的 - 需要按需导入的话,就要自定义捆绑包
那么下一步就很明确了,怎么自定义捆绑包,在捆绑包的文章里,给出了示例
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)
在原来的基础上,再导入三个依赖
bashpnpm add @shikijs/core @shikijs/langs @shikijs/themes
创建 shiki-bundle.ts 文件,写入以下代码
tsimport { 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 引擎在浏览器中运行时表现最佳,尤其是在你想要控制包大小的情况下。,并且考虑到我要尽可能减少包的体积,我决定使用这个引擎。
tsimport { 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 实例的方法
tsimport { 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 总结
- 学会了 markdown-it 集成 shiki 的方法。
- 了解到了 shiki 的知识。
- 学会了编写 shiki 的转换器 transformer。
- 复习了 css 计数器知识。
- 学会了 Clipboard API 的使用方式。
- 学会了单例模式的实现方式。
- 学会了 shiki 细粒度捆绑,按需导入集成。