使用 remark 将 markdown 转换为 vnode
不需要使用 v-html 进行渲染。先转换为 HAST 节点,随后遍历一次构造 Vue VNode。
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
import type { Root as HastRoot } from "hast";
/**
* 将 Markdown 内容转换为 HAST (Hypertext Abstract Syntax Tree)
*
* @param content - 原始 Markdown 文本
* @returns Promise,解析为 HAST Root 节点
*
* @remarks
* 处理流程:
* 1. 使用 unified 处理器将 Markdown 解析为 MDAST
* 2. 支持 GFM 和数学公式(KaTeX)
* 3. 将 MDAST 转换为 HAST
* 4. 使用 rehypeKatex 渲染数学公式为 KaTeX HAST 节点
* 5. 返回 HAST Root,可直接转换为 Vue VNode
*/
const buildMarkdownToHast = async (content: string): Promise<HastRoot> => {
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkMath) // 解析数学公式语法($...$ 和 $$...$$)
.use(remarkRehype)
.use(rehypeKatex); // 渲染数学公式为 KaTeX HAST 节点
// 先 parse 得到 MDAST,再 run 转换为 HAST
const mdastTree = processor.parse(content);
return await processor.run(mdastTree);
};
/**
* 将 Markdown 转换为 VNode 数组
*
* @param content - 原始 Markdown 文本
* @returns Promise,解析为 VNode 数组
*/
const convertMarkdownToVNodes = async (content: string) => {
// 将 Markdown 转换为 HAST
const hastRoot = await buildMarkdownToHast(content);
// 将 HAST Root 转换为 VNode 数组,应用插件
return await hastRootToVueVNodes(hastRoot, props.plugins);
};
/** 异步计算 VNode 数组 */
const content = computed(() => convertMarkdownToVNodes(props.content));
hastRootToVueVNodes 是一个将 HAST Root 节点转换为 Vue VNode 数组的工具函数。
import { toString } from "hast-util-to-string";
import { h, type FunctionalComponent, type VNode } from "vue";
import type { Element as HastElement, Root as HastRoot } from "hast";
/**
* 递归地将 HAST Element 节点转换为 Vue VNode
*
* @param element - HAST Element 节点
* @param plugins - 可选的插件数组,用于拦截和自定义特定元素的渲染
* @returns Promise,解析为 Vue VNode 对象
*
* @remarks
* 处理流程:
* 1. 如果提供了插件,先遍历插件尝试处理元素
* 2. 如果插件返回结果,直接使用插件返回的 VNode 或组件
* 3. 否则执行默认转换逻辑:
* - 递归处理所有子节点
* - 文本节点直接转换为字符串
* - 元素节点递归转换为 VNode
* - 其他类型节点(如注释)被过滤掉
* - 使用 HAST 的 properties 作为 VNode 的 props
*
* @example
* ```typescript
* const hastElement: HastElement = {
* type: 'element',
* tagName: 'div',
* properties: { className: ['container'] },
* children: [{ type: 'text', value: 'Hello' }]
* };
* const vnode = await hastElementToVueVNode(hastElement);
* // 结果: h('div', { className: ['container'] }, ['Hello'])
* ```
*/
export async function hastElementToVueVNode(
element: HastElement,
plugins: MarkedVuePlugin[] = [],
): Promise<VNode> {
// 插件拦截逻辑:遍历所有插件尝试处理当前元素
for (const plugin of plugins) {
const result = await plugin.handler(element);
// 如果插件返回函数式组件,使用 h 函数包装
// 如果返回 VNode,直接使用
if (!result) continue;
return typeof result === "function" ? h(result) : result;
}
const children: (VNode | string)[] = [];
for (const node of element.children) {
// 文本节点直接返回文本内容
if (node.type === "text") children.push(node.value);
// 元素节点递归转换为 VNode,并传递插件
else if (node.type === "element") children.push(await hastElementToVueVNode(node, plugins));
// 其他类型节点(如注释)忽略
}
// 使用 Vue 的 h 函数创建 VNode
// HAST 的 tagName 已经是小写,properties 可以直接作为 props 使用
return h(element.tagName, element.properties ?? {}, children);
}
/**
* 将 HAST Root 节点转换为 Vue VNode 数组(异步版本)
*
* @param root - HAST Root 节点
* @param plugins - 可选的插件数组,用于拦截和自定义特定元素的渲染
* @returns Promise,解析为 VNode 数组,每个 VNode 对应一个顶级元素
*
* @remarks
* 用于将 Markdown 解析后的 HAST 树转换为 Vue 可渲染的 VNode 列表。
* 只处理类型为 'element' 的节点,忽略文本、注释等其他类型节点。
*
* @example
* ```typescript
* const hastRoot: HastRoot = {
* type: 'root',
* children: [
* { type: 'element', tagName: 'h1', properties: {}, children: [...] },
* { type: 'element', tagName: 'p', properties: {}, children: [...] }
* ]
* };
* const vnodes = await hastRootToVueVNodes(hastRoot);
* // 结果: [h('h1', ...), h('p', ...)]
* ```
*/
export async function hastRootToVueVNodes(
root: HastRoot,
plugins?: MarkedVuePlugin[],
): Promise<VNode[]> {
const result: VNode[] = [];
const { children } = root;
for (const node of children) {
// 只处理元素节点,忽略文本、注释等其他类型
if (node.type !== "element") continue;
// 将元素节点转换为 VNode
const vnode = await hastElementToVueVNode(node, plugins);
result.push(vnode);
}
return result;
}
随后即可使用 vue 渲染得到的 vnodes 了。
异步渲染 vnodes
AsyncRenderer.vue
<script setup lang="ts">
import pLimit from "p-limit";
import type { FunctionalComponent, VNode } from "vue";
type MaybePromise<T> = T | Promise<T>;
type MaybeArray<T> = T | T[];
type ContentNodes = MaybeArray<VNode | null>;
const props = defineProps<{
content: MaybePromise<ContentNodes>;
}>();
/** 并发限制器,确保同一时间只处理一个高亮任务 */
const limit = pLimit(1);
/** 存储转换后的 VNode 数组 */
const vNodes = shallowRef<ContentNodes>([]);
watchEffect(() => {
const { content } = props;
limit(async () => (vNodes.value = await content));
});
const Child: FunctionalComponent = () => {
if (!vNodes.value) return null;
return vNodes.value;
};
</script>
<template>
<Child />
</template>
插件机制,自定义渲染代码块
构建 plugin 类型,定义一个组件用于渲染代码块。
import { toString } from "hast-util-to-string";
import { h, type FunctionalComponent, type VNode } from "vue";
import CodeBlock from "./CodeBlock.vue";
import type { Element as HastElement, Root as HastRoot } from "hast";
/** Promise 或普通值的联合类型 */
export type MaybePromise<T> = T | Promise<T>;
/** 可能为 null 或 undefined 的类型 */
export type Maybe<T> = T | null | undefined;
/**
* Markdown 插件接口
*
* @remarks
* 用于扩展 Markdown 渲染功能,可以拦截并自定义特定 HAST 元素的渲染逻辑
*/
export interface MarkedVuePlugin {
/**
* 插件处理函数
*
* @param element - HAST 元素节点
* @returns VNode、函数式组件,或 null/undefined(表示不处理该元素)
*/
handler: (element: HastElement) => MaybePromise<Maybe<VNode | FunctionalComponent>>;
}
/**
* 从 CSS 类名数组中查找代码块的语言标识
*
* 该函数遍历类名数组,查找以 "language-" 开头的类名,
* 并提取语言标识符(例如从 "language-typescript" 提取 "typescript")。
*
* @param classNames - CSS 类名数组,可能包含各种类型的元素
* @returns 语言标识符字符串,如果未找到则返回 undefined
*
* @remarks
* 只处理字符串类型的类名,忽略其他类型的元素。
* 返回第一个匹配的语言标识符。
*
* @example
* ```typescript
* const classes = ['hljs', 'language-typescript', 'code-block'];
* const lang = findCodeBlockLanguage(classes);
* // 结果: 'typescript'
*
* const noLang = findCodeBlockLanguage(['hljs', 'code-block']);
* // 结果: undefined
* ```
*/
const findCodeBlockLanguage = (classNames: unknown): string | undefined => {
const list = Array.isArray(classNames) ? classNames : [classNames];
for (const element of list) {
if (typeof element !== "string") continue;
if (!element.startsWith("language-")) continue;
return element.replace("language-", "");
}
};
/**
* 代码块语法高亮插件
*
* @remarks
* 拦截 <pre><code> 元素,使用 Shiki 进行语法高亮渲染。
* 支持 Markdown 代码块的语言标识(如 ```typescript)。
*
* @example
* ```typescript
* // 在 hastRootToVueVNodes 中使用
* const vnodes = await hastRootToVueVNodes(hastRoot, [CodeBlockPlugin]);
* ```
*/
export const CodeBlockPlugin: MarkedVuePlugin = {
handler: (element: HastElement) => {
// 检测是否为代码块:<pre><code class="language-xxx">
if (element.tagName !== "pre") return null;
// 查找 <code> 子元素
const codeElement = element.children.find(
(child) => child.type === "element" && child.tagName === "code",
);
if (!codeElement || codeElement.type !== "element") return null;
// 提取语言信息:从 className 中获取 language-xxx
const { className } = codeElement.properties || {};
if (!Array.isArray(className)) return null;
const language = findCodeBlockLanguage(className);
if (!language) return null;
// 使用 hast-util-to-string 提取代码内容
const code = toString(codeElement);
// 返回 CodeBlock 组件 VNode
return h(CodeBlock, { language, code });
},
};
CodeBlock.vue
<script setup lang="ts">
import { useClipboard } from "@vueuse/core";
import { CheckCheck, Copy } from "lucide-vue-next";
import { codeToHast } from "shiki";
import AsyncRenderer from "./AsyncRenderer.vue";
import { hastRootToVueVNodes } from "./plugin";
const props = defineProps<{
language: string;
code: string;
}>();
/**
* 使用 Shiki 进行代码语法高亮
*
* @param code - 源代码字符串
* @param language - 编程语言标识
* @returns VNode 数组
*/
const highlight = async (code: string, language: string) => {
// 使用 Shiki 将代码转换为 HAST
const hast = await codeToHast(code, {
lang: language,
theme: "catppuccin-latte",
});
// 将 HAST 转换为 VNode 数组
return hastRootToVueVNodes(hast);
};
/** 异步计算高亮后的 VNode 数组 */
const content = computed(() => highlight(props.code, props.language));
/** 使用 VueUse 的 useClipboard hook */
const { copy, copied } = useClipboard();
/**
* 复制代码到剪贴板
*/
const handleCopy = () => copy(props.code);
</script>
<template>
<div :class="$style.container">
<AsyncRenderer :content="content" />
<ElButton
text
bg
:class="$style.copyButton"
:title="copied ? '已复制' : '复制代码'"
@click="handleCopy"
>
<CheckCheck v-if="copied" :size="16" />
<Copy v-else :size="16" />
</ElButton>
</div>
</template>
<style module>
.container {
position: relative;
}
.copyButton {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: none;
height: 28px;
width: 28px;
padding: 0;
}
.container:hover .copyButton {
display: flex;
}
</style>
定义 markdown 样式
markdown.css
.markdown-article * {
margin: 0;
padding: 0;
box-sizing: border-box;
line-height: 1.5;
font-size: 1rem;
}
.markdown-article p {
margin: 0.5em 0;
}
.markdown-article p p,
.markdown-article ul p,
.markdown-article ol p,
.markdown-article table p {
margin: 0;
}
.markdown-article ul,
.markdown-article ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
.markdown-article ul li,
.markdown-article ol li {
margin: 0.25em 0;
}
.markdown-article ul {
list-style-type: disc;
}
.markdown-article ul ul {
list-style-type: circle;
}
.markdown-article ul ul ul {
list-style-type: square;
}
.markdown-article ol {
list-style-type: decimal;
}
.markdown-article ol ol {
list-style-type: lower-alpha;
}
.markdown-article ol ol ol {
list-style-type: lower-roman;
}
.markdown-article li::marker {
color: #666;
}
.markdown-article strong {
font-weight: 600;
}
.markdown-article em {
font-style: italic;
}
.markdown-article u,
.markdown-article ins {
text-decoration: underline;
}
.markdown-article s,
.markdown-article del {
text-decoration: line-through;
color: #6d6d6ec2;
}
.markdown-article mark {
background-color: #faf594;
}
.markdown-article a {
color: #3291ff;
text-decoration: none;
}
.markdown-article a:hover {
color: #3291ff;
text-decoration: underline;
cursor: pointer;
}
.markdown-article hr {
display: block;
border: 0;
border-top: 1px solid #ccc;
margin: 1em 0;
}
.markdown-article blockquote {
border-left: 0.25em solid #e7e7ea;
padding-left: 1em;
color: #666;
margin: 1em 0 1em 3px;
}
.markdown-article blockquote p {
margin: 0.25em 0;
}
.markdown-article h1 {
font-size: 1.3rem;
font-weight: 600;
margin: 1em 0 0.8em 0;
text-wrap: pretty;
}
.markdown-article h2 {
font-size: 1.2rem;
font-weight: 600;
margin: 1em 0 0.8em 0;
text-wrap: pretty;
}
.markdown-article h3 {
font-size: 1.1rem;
font-weight: 600;
margin: 1em 0 0.8em 0;
text-wrap: pretty;
}
.markdown-article h4,
.markdown-article h5,
.markdown-article h6 {
font-weight: 600;
margin: 1em 0 0.5em 0;
text-wrap: pretty;
}
.markdown-article code {
border-radius: 2px;
padding: 0 3px;
font-family:
ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", monospace, var(--el-font-family);
margin: 0 3px;
}
.markdown-article pre {
margin: 1em 0;
padding: 12px;
border-radius: 4px;
}
.markdown-article pre code {
display: block;
margin: 0;
white-space: pre-wrap;
}
.markdown-article img {
max-width: 100%;
}
.markdown-article table {
font-size: 1em;
border-collapse: collapse;
display: table;
width: max-content;
max-width: 100%;
overflow: hidden;
}
.markdown-article table th,
.markdown-article table td {
border: 1px solid #dcdce1;
padding: 8px 12px;
font-weight: 400;
text-wrap: pretty;
text-align: left;
}
.markdown-article table thead th {
background-color: #f3f3f5;
font-weight: 500;
}
分块渲染
需要先将长文本进行分块。当文本追加更新时,只会有一个分块变更,其他块不需要更新。
MarkdownContent.vue
<script setup lang="ts">
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import remarkParse from "remark-parse";
import remarkStringify from "remark-stringify";
import remend from "remend";
import { unified } from "unified";
import MarkdownBlock from "./MarkdownBlock.vue";
import type { MarkedVuePlugin } from "./plugin";
import "katex/dist/katex.min.css";
import "./markdown.css";
const props = defineProps<{
content: string;
plugins?: MarkedVuePlugin[];
}>();
/**
* 将 Markdown 按“首层块”分块
*
* @param markdown - 原始 Markdown 文本
* @returns Markdown 块的字符串数组,每个元素代表一个顶级块
*
* @remarks
* 每个顶级块(如段落、标题、代码块)作为一个独立 chunk
* 使用 unified 处理器解析 Markdown 并将每个块重新序列化为字符串
*/
function chunkMarkdownByTopLevelBlocks(markdown: string) {
// 解析 Markdown 为 AST,获取顶级子节点
const { children } = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkMath)
.parse(remend(markdown));
// 将每个顶级块节点重新序列化为 Markdown 字符串
return children.map((blockNode) => {
return unified()
.use(remarkGfm)
.use(remarkMath)
.use(remarkStringify)
.stringify({ type: "root", children: [blockNode] });
});
}
/** 计算属性,存储分块后的 Markdown 内容数组 */
const blocks = computed(() => chunkMarkdownByTopLevelBlocks(props.content));
</script>
<template>
<article class="markdown-article">
<MarkdownBlock
v-for="(block, index) in blocks"
:key="index"
:content="block"
:plugins="plugins"
/>
</article>
</template>
最后,直接使用 MarkdownContent.vue。
<script setup lang="ts">
import MarkdownContent from "@/components/Markdown/MarkdownContent.vue";
import { CodeBlockPlugin } from "@/components/Markdown/plugin";
const markdownText = `# Markdown 组件测试
这是一个用于测试 **MarkdownContent** 组件的页面。
`;
</script>
<template>
<div :class="$style.content">
<MarkdownContent :content="markdownText" :plugins="[CodeBlockPlugin]" />
</div>
</template>