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/mdx | MDX 核心编译器,提供运行时编译能力 |
| @mdx-js/react | React 集成,提供 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 渲染组件