
Vue 3 + Markdown-it 集成 Shiki 代码高亮
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 这俩用法有点区别。
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-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 发现了这一段:
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中导入并传入你自己的高亮器:
找了下捆绑包的文章
大概总结一下:
- 使用
await Shiki是直接导入全部语言的 - 需要按需导入的话,就要自定义捆绑包
那么下一步就很明确了,怎么自定义捆绑包,在捆绑包的文章里,给出了示例
// 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 总结
- 学会了 markdown-it 集成 shiki 的方法。
- 了解到了 shiki 的知识。
- 学会了编写 shiki 的转换器 transformer。
- 复习了 css 计数器知识。
- 学会了 Clipboard API 的使用方式。
- 学会了单例模式的实现方式。
- 学会了 shiki 细粒度捆绑,按需导入集成。
0 人赞过
评论
评论加载中…