>
post-detail

Vue 3 + Markdown-it 集成 Shiki 代码高亮

「技术」

10 分钟

22 阅读

0

2025-10-10

1 前言

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

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

特性Highlight.jsPrism.jsShiki
易用性⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
高亮精度⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
主题质量⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐ (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 这俩用法有点区别。

pnpm add shiki @shikijs/markdown-it 

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

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

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 的代码。

<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>

再写写样式美化一下。

/* 代码块 */
.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;  
}  

大概就是这样啦 ![[https://xgrblog.cn/xgrblogbucket/image/20251010/dfad149b-ccc4-4de4-8727-30e8ad5bef40/Pasted image 20250904140007.png]]

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

2.2 添加行号

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

也就是使用 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;  
}

![[https://xgrblog.cn/xgrblogbucket/image/20251010/28c78e23-f7e2-4845-b9a7-612a073aae34/Pasted image 20250904140828.png]]

2.3 主题切换

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

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

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

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

.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;  
}

哦吼,直接就成功了。

![[https://xgrblog.cn/xgrblogbucket/image/20251010/28c78e23-f7e2-4845-b9a7-612a073aae34/Pasted image 20250904140828.png]]

![[https://xgrblog.cn/xgrblogbucket/image/20251010/3715667d-d575-43b5-af22-23de34557856/Pasted image 20250904141504.png]]

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

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

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

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

新建一个 ts 文件,名为 transformerMetaHighlight.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; 隐藏。

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

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 美化一下。

.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/

![[https://xgrblog.cn/xgrblogbucket/image/20251010/71c49553-f539-483a-9cf8-e96b1b605fc2/Pasted image 20250904142909.png]]

![[https://xgrblog.cn/xgrblogbucket/image/20251010/1d62cb8d-9d01-4759-8a95-9b8f24f3532f/Pasted image 20250904142924.png]]

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 中的内容,添加以下代码

// 获取原始代码  
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 这个标签的节点信息,节点信息不方便展示,我用元素结构展示一下。

<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 ,用来展示高亮后的单词。

<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,找到它们的子节点

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

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

    // 如果有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('')  
      : '' // 若为空则返回空字符串

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

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

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

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

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

// 获取原始代码  
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 如下

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 文件。

点击处理事件

// 按钮元素类型  
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 添加,所以提供两个方法

/**  
 * 初始化代码复制功能,监听点击事件  
 */  
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  
      }  
    })  
}

完整代码如下:

// 按钮元素类型  
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),然后再监听点击事件。

<script setup lang="ts">
// 导入部分不写了,这里只是参考

const initData = async () => {  
  // 加载文章详情数据

  // 等待渲染完成
  await nextTick()  
  useCodeBlockCopyCode().setup()  // 启动监听
}  


onMounted(() => {  
  initData()  
})  

onBeforeUnmount(() => {  
  // 清除监听
  useCodeBlockCopyCode().cleanup()  
})
</script>

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

![[https://xgrblog.cn/xgrblogbucket/image/20251010/c65a6b07-f9a5-42cf-ace0-aa505983e4bf/代码复制.gif]]

至此,算是大功告成了

3 问题

3.1 发现问题

成了吗?算,也不算。

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

![[https://xgrblog.cn/xgrblogbucket/image/20251010/7c394b00-1639-437b-b9a6-cf20d6b16a12/GIF 2025-9-4-星期四 15-27-12.gif]]

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

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

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

.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-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 发现了这一段:

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 };

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

加载一些必要的语言。

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 的代码:

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,提供两个变量。

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 完成。

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  // 返回实例
}

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

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

  return mdInitPromise  
}

在 main.ts 中,执行 getMarkdownItInstance方法

// 导入样式  
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 实例的应用方式

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是没有生效的

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. 需要按需导入的话,就要自定义捆绑包

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

// 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)

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

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

创建 shiki-bundle.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 包来支持。

![[https://xgrblog.cn/xgrblogbucket/image/20251010/c0fd6a3e-427e-48c8-917a-7ac311ff72d5/Pasted image 20250914183608.png]]

可以看到有 600+kb

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

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 实例的方法

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  
}

可以看到这一行:


// 获取 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 所以才使用断言

![[https://xgrblog.cn/xgrblogbucket/image/20251010/55bc730a-2a8c-4668-972f-be329c72d0f7/Pasted image 20250914190450.png]]

![[https://xgrblog.cn/xgrblogbucket/image/20251010/e02c5024-dfeb-4dbd-927f-4ba2aee472b3/Pasted image 20250914190514.png]]

这样就搞定了。

试试效果 ![[https://xgrblog.cn/xgrblogbucket/image/20251010/408f96a9-b490-43a3-a059-b4b663bfec0b/Pasted image 20250914190647.png]]

5 总结

  1. 学会了 markdown-it 集成 shiki 的方法。
  2. 了解到了 shiki 的知识。
  3. 学会了编写 shiki 的转换器 transformer。
  4. 复习了 css 计数器知识。
  5. 学会了 Clipboard API 的使用方式。
  6. 学会了单例模式的实现方式。
  7. 学会了 shiki 细粒度捆绑,按需导入集成。

0 人赞过

评论

评论加载中…