AKR

MDX 前端渲染实现技术解析

MDX 前端渲染实现技术解析

本文分析一个基于 React + Vite 的项目中 MDX 的前端渲染实现方案。该方案采用运行时编译方式,支持自定义组件映射和代码块元数据处理。

一、核心依赖

{
  "imports": {
    "@mdx-js/mdx": "npm:@mdx-js/mdx@^3.1.0",
    "@mdx-js/react": "npm:@mdx-js/react@^3.1.0",
    "rehype-mdx-code-props": "npm:rehype-mdx-code-props@^3.0.1"
  }
}
依赖作用
@mdx-js/mdxMDX 核心编译器,提供运行时编译能力
@mdx-js/reactReact 集成,提供 MDXProvider 和组件映射功能
rehype-mdx-code-props代码块元数据插件,支持 filename 等属性

二、MDX 编译配置

文件位置: frontend/src/lib/mdx-config.ts

import { CompileOptions } from "@mdx-js/mdx";
import rehypeMdxCodeProps from "rehype-mdx-code-props";

export const mdxConfig: CompileOptions = {
  rehypePlugins: [
    [
      rehypeMdxCodeProps,
      {
        elementAttributeNameCase: "react",
        tagName: "pre",
      },
    ],
  ],
};

配置说明:

  • elementAttributeNameCase: "react": 使用 React 风格属性命名(className
  • tagName: "pre": 对 <pre> 标签启用元数据解析

三、核心渲染组件

文件位置: frontend/src/components/mdx/mdx-message.tsx

3.1 完整实现

import React, { FC, Fragment, useEffect, useRef } from "react";
import { MDXProvider } from "@mdx-js/react";
import { evaluate } from "@mdx-js/mdx";
import { jsx } from "react/jsx-runtime";
import { useQuery } from "@tanstack/react-query";
import { mdxConfig } from "@/lib/mdx-config.ts";

// 自定义组件映射
const components = {
  // 代码相关
  code: InlineCode,
  pre: CodeBlock,

  // 标准 Markdown 元素
  h1: (p: any) => <h1 className="my-4 text-2xl font-bold" {...p} />,
  h2: (p: any) => <h2 className="my-3 text-xl font-semibold" {...p} />,
  h3: (p: any) => <h3 className="my-2 text-lg font-medium" {...p} />,
  p: (p: any) => <p className="my-2" {...p} />,
  a: (p: any) => (
    <a
      className="text-primary underline"
      target="_blank"
      rel="noreferrer"
      {...p}
    />
  ),
  strong: (p: any) => <strong className="font-semibold" {...p} />,
  em: (p: any) => <em className="italic" {...p} />,
};

// 带复制按钮的代码块
function CodeBlock({ children, className }: any) {
  const [copied, setCopied] = React.useState(false);
  const language = className?.replace("language-", "") || "text";
  const text = typeof children === "string" ? children : "";

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(text);
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    } catch {}
  };

  return (
    <Card className="my-4 border">
      <CardHeader className="flex flex-row items-center justify-between py-2">
        <CardTitle className="font-mono text-xs lowercase">
          {language}
        </CardTitle>
        <Button
          size="icon"
          variant="ghost"
          onClick={copy}
          className="h-6 w-6"
        >
          <Copy className="h-3 w-3" />
        </Button>
      </CardHeader>
      <CardContent className="p-0">
        <pre className="overflow-x-auto p-4 text-sm">
          <code className={className}>{children}</code>
        </pre>
      </CardContent>
    </Card>
  );
}

// 内联代码样式
function InlineCode(props: any) {
  return <code className="bg-muted rounded px-1 text-sm" {...props} />;
}

// 主渲染组件
export default function MdxMessage({ mdx, className = "" }: Props) {
  // 使用 React Query 缓存编译结果
  const render = useQuery({
    queryKey: ["mdx", mdx],
    queryFn: () =>
      evaluate(mdx, {
        ...mdxConfig,
        Fragment,
        jsx,
        jsxs: jsx,
        useMDXComponents: () => components,
      }).then((result) => result.default),
  });

  // 保持最新渲染结果,避免编译过程中的闪烁
  const latestMDX = useRef<FC | null>(null);
  useEffect(() => {
    if (render.data && !render.isError) {
      latestMDX.current = render.data;
    }
  }, [render.data, render.isError]);

  return (
    <MDXProvider components={components}>
      <div className={`prose prose-sm dark:prose-invert max-w-none ${className}`}>
        {latestMDX.current && <latestMDX.current />}
      </div>
    </MDXProvider>
  );
}

3.2 关键实现逻辑

技术实现方式作用
运行时编译evaluate() 函数在浏览器端动态编译 MDX 字符串
缓存机制React Query queryKey相同内容只编译一次
防闪烁useRef 保存结果编译期间保持旧内容显示
组件注入useMDXComponents提供自定义组件映射

四、组件映射配置

const components = {
  // 代码相关
  code: InlineCode,
  pre: CodeBlock,

  // 标题
  h1: (p: any) => <h1 className="my-4 text-2xl font-bold" {...p} />,
  h2: (p: any) => <h2 className="my-3 text-xl font-semibold" {...p} />,
  h3: (p: any) => <h3 className="my-2 text-lg font-medium" {...p} />,

  // 文本
  p: (p: any) => <p className="my-2" {...p} />,
  strong: (p: any) => <strong className="font-semibold" {...p} />,
  em: (p: any) => <em className="italic" {...p} />,
  code: InlineCode,

  // 链接
  a: (p: any) => (
    <a
      className="text-primary underline"
      target="_blank"
      rel="noreferrer"
      {...p}
    />
  ),
};

五、渲染流程

MDX 字符串

evaluate() 编译为 React 组件

MDXProvider 注入自定义组件映射

React 渲染最终 UI

六、支持的 MDX 语法

标准 Markdown

# 一级标题
## 二级标题
### 三级标题

**加粗文本** *斜体文本* `内联代码`

[链接文字](https://example.com)

代码块(带元数据)

```javascript {filename="app.js" copy}
const greeting = "Hello, MDX!";
console.log(greeting);

通过 `rehype-mdx-code-props` 插件,可以在代码块中添加元数据属性:
- `filename`: 文件名
- `copy`: 显示复制按钮

## 七、性能优化

1. **React Query 缓存**: 相同 MDX 内容只编译一次
2. **防闪烁设计**: 使用 `useRef` 保持最新渲染结果
3. **按需渲染**: 只在 `render.data` 存在时更新显示

## 八、文件位置

frontend/src/ ├── lib/ │ └── mdx-config.ts # MDX 编译配置 └── components/ └── mdx/ └── mdx-message.tsx # 核心 MDX 渲染组件