在 Vue 中使用 remark 渲染 markdown

3 阅读1分钟

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