引言
内容驱动站点(文档、博客、产品手册)的核心矛盾是:内容用 Markdown 写才顺手,交互用组件实现才灵活。MDX 正好把两者缝合——在 Markdown 里直接写 JSX,构建期编译成组件,既保留纯文本的可维护性,又获得完整的组件能力。
在 Vite 项目里落地 MDX,关键不在于「能不能编译」,而在于把编译、插件链、路由、静态资源、组件注册与缓存串成一条可维护的内容管线。管线里每一环都有各自的坑:插件顺序错了锚点就丢了,作用域没配对组件就静默失效,依赖没预打包开发态就反复重启。
本文从技术选型讲起,逐步搭建 @mdx-js/rollup 的接入、remark 与 rehype 插件链、代码高亮与标题锚点、frontmatter 类型化、路由自动生成、图片处理、组件按需注册与构建缓存,最后给出常见陷阱与落地清单。
前置:插件机制与钩子、配置体系。框架集成见 Vite 框架集成:React、Vue、Svelte、Solid 与官方插件生态。
目录
- 1. 内容驱动站点的技术选型
- 2. @mdx-js/rollup 接入 Vite
- 3. remark 与 rehype 插件链
- 4. 代码高亮与标题锚点
- 5. frontmatter 解析与类型化内容
- 6. 内容目录扫描与路由自动生成
- 7. 图片与静态资源处理
- 8. MDX 组件的按需注册与作用域
- 9. 构建性能与缓存策略
- 10. 常见陷阱与落地清单
1. 内容驱动站点的技术选型
1.1 三条主流路线
内容站点的构建方式大致分三类,各自的取舍很清楚:
| 路线 | 内容形态 | 适合场景 |
|---|---|---|
| 纯 Markdown 加静态生成 | .md | 文档、博客,交互极少 |
| MDX 加 Vite | .mdx | 文档混排组件、可交互示例 |
| 组件即页面 | .tsx | 强交互应用,内容量小 |
选型判断只有一条:内容里是否天然需要嵌入组件。每篇都要插图表、可运行示例、提示框时选 MDX;内容几乎全是文字与图片时,纯 Markdown 加静态生成更轻更快,也更容易被其他工具消费。
1.3 管线全景
一条完整的 MDX 内容管线包含六环,任何一环缺失都会在后期变成维护成本:
源文件 .mdx
→ 编译(@mdx-js/rollup)
→ 插件链(remark 处理 Markdown,rehype 处理 HTML)
→ 元数据(frontmatter 解析与校验)
→ 路由(扫描目录生成路由表)
→ 资源(图片与静态文件处理)
→ 运行时(组件作用域与按需注册)
记忆:选型只看一条——内容是否需要嵌入组件;MDX 管线六环是编译、插件链、元数据、路由、资源、运行时,缺一环都会变成后期的维护债。
2. @mdx-js/rollup 接入 Vite
2.1 安装与最小接入
@mdx-js/rollup 是一个 Rollup 插件,而 Vite 的构建期就是 Rollup,装上 npm i -D @mdx-js/rollup @mdx-js/react 后可以直接放进 plugins:
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import mdx from '@mdx-js/rollup'
export default defineConfig({
plugins: [
{ enforce: 'pre', ...mdx() },
react(),
],
})
2.2 为什么必须 enforce pre
MDX 文件要先被编译成 JSX,再交给框架插件处理。如果顺序反了,框架插件会先看到 .mdx 的原始文本而无法解析——正确顺序是 mdx(enforce pre)再框架插件,反了就变成框架插件先跑、JSX 转换拿不到编译结果。
2.3 关键编译选项
mdx({
remarkPlugins: [],
rehypePlugins: [],
providerImportSource: '@mdx-js/react',
jsxImportSource: 'react',
})
providerImportSource 决定组件作用域从哪里注入,jsxImportSource 决定 JSX 运行时——两者配错会出现「组件未定义」或 hooks 报错。
记忆:
@mdx-js/rollup是 Rollup 插件、可直接进 Vite 的 plugins——必须用enforce: 'pre'排在框架插件之前,否则 JSX 转换拿不到编译结果。
3. remark 与 rehype 插件链
3.1 两段式管线
MDX 的编译分两段:remark 处理 Markdown(mdast),rehype 处理 HTML(hast)。理解这个分界是编排插件的前提:
.md → mdast →(remark 插件)→ mdast → hast →(rehype 插件)→ hast → JSX
想在语法层做事(比如支持脚注、表格、提示块)用 remark;想在 HTML 层做事(比如加锚点、改标签、包代码块)用 rehype。
3.2 常用插件清单
| 插件 | 阶段 | 作用 |
|---|---|---|
| remark-gfm | remark | 表格、删除线、任务列表 |
| remark-frontmatter | remark | 识别 frontmatter |
| remark-toc | remark | 生成目录 |
| rehype-slug | rehype | 给标题加 id |
| rehype-autolink-headings | rehype | 给标题加锚点链接 |
| rehype-pretty-code | rehype | 代码高亮 |
3.3 顺序决定结果
插件按数组顺序执行,顺序错了结果就错。典型例子:rehype-slug 必须排在 rehype-autolink-headings 之前,否则锚点链接会指向空 id——写成 [rehypeSlug, [rehypeAutolinkHeadings, { behavior: 'wrap' }]] 才是对的顺序。
记忆:remark 管 Markdown、rehype 管 HTML——语法层改动放 remark、结构层改动放 rehype,且插件顺序即执行顺序,slug 一定排在 autolink 之前。
4. 代码高亮与标题锚点
4.1 代码高亮
高亮的本质是在构建期把代码块替换成带 token 的 HTML。主流方案是 Shiki(VS Code 同款语法),在 rehype 阶段接入:
import rehypePrettyCode from 'rehype-pretty-code'
rehypePlugins: [
[rehypePrettyCode, {
theme: 'github-dark',
keepBackground: false,
}],
]
keepBackground: false 让背景交给 CSS 控制,便于做亮暗主题切换。
4.2 标题锚点
标题锚点由 rehype-slug 生成 id,规则是小写、空格转连字符、标点删除、中文保留,例如 ## 1. 快速上手 Vite 会得到 #1-快速上手-vite。这与绝大多数 TOC 生成器一致。
4.3 与目录保持一致
自建 TOC 时必须复用同一套 slug 规则,否则目录点击跳不过去——用 github-slugger 的 slugger.slug(h.text) 逐条生成 id,与 rehype-slug 的产物天然一致。
记忆:高亮在构建期完成(Shiki 优于运行时高亮),锚点由
rehype-slug生成——自建 TOC 必须复用同一套 slug 规则,否则目录点了不跳。
5. frontmatter 解析与类型化内容
5.1 解析与导出
remark-frontmatter 只负责识别,真正要把元数据拿出来,还需要在编译时导出。用 remark-mdx-frontmatter 可以把 frontmatter 变成具名导出:
import remarkFrontmatter from 'remark-frontmatter'
import remarkMdxFrontmatter from 'remark-mdx-frontmatter'
mdx({
remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter],
})
编译后即可在任意页面里 import { frontmatter } from './post.mdx',直接读取 frontmatter.title 等字段。
5.2 用 zod 做类型校验
frontmatter 是手写的,必然会有拼写错误与类型错误。在构建期用 zod 校验,错误会提前暴露:
import { z } from 'zod'
const schema = z.object({
title: z.string().min(1),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
tags: z.array(z.string()).default([]),
})
export function parseFrontmatter(data: unknown) {
return schema.parse(data)
}
5.3 字段设计建议
必填:title、date、slug
常用:description、tags、draft
避免:把大段内容塞进 frontmatter(它是元数据,不是正文)
记忆:
remark-mdx-frontmatter把元数据变成具名导出、zod 在构建期做校验——frontmatter 只放元数据,正文一律留在 Markdown 里。
6. 内容目录扫描与路由自动生成
6.1 为什么要自动生成
手写路由表在内容增长后必然失控:新增一篇就要改一次代码,漏改就直接 404。正确做法是扫描目录自动生成。
6.2 用 import.meta.glob 扫描
Vite 的 import.meta.glob 在构建期做静态分析,既支持懒加载,又能被 tree-shaking 正确处理:
const modules = import.meta.glob('./posts/**/*.mdx')
export const routes = Object.entries(modules).map(([path, loader]) => {
const slug = path.replace('./posts/', '').replace(/\.mdx$/, '')
return {
path: `/posts/${slug}`,
component: loader,
}
})
6.3 预取元数据
路由表通常还需要标题与日期。用 import.meta.glob('./posts/**/*.mdx', { eager: true, import: 'frontmatter' }) 只加载元数据入口,避免把正文全部打进主包。
记忆:路由表必须扫描生成而非手写——
import.meta.glob做静态扫描与懒加载,元数据用import: 'frontmatter'单独 eager 取,正文才不会被打进主包。
7. 图片与静态资源处理
7.1 public 与 import 的区别
| 方式 | 路径 | 是否参与构建优化 |
|---|---|---|
| public 目录 | 绝对路径 /img/a.png | 否,原样拷贝 |
| import 引入 | 相对路径 import | 是,带 hash 并可优化 |
内容站点里图片数量大,建议正文图片走 import、站点图标走 public。
7.2 构建期图片优化
用 vite-imagetools 在 import 时通过查询参数生成多尺寸与多格式:
import hero from './hero.jpg?w=800;1200&format=webp&as=srcset'
// 渲染:<img src={hero} sizes="(max-width: 800px) 100vw, 800px" alt="示例图" />
7.3 尺寸与布局抖动
图片必须带显式宽高(如 width={1200} height={630}),否则加载完成时会引发布局偏移。
记忆:正文图片走 import 才会被构建优化,站点图标放 public——图片一律带显式宽高,否则首屏会抖。
8. MDX 组件的按需注册与作用域
8.1 组件映射
MDX 允许把自定义组件映射到 Markdown 语法,例如把所有 a 标签换成站内路由:
import { MDXProvider } from '@mdx-js/react'
import { Link } from 'react-router-dom'
const components = {
a: (props) => <Link to={props.href} {...props} />,
img: ResponsiveImage,
pre: CodeBlock,
}
export function Layout({ children }) {
return <MDXProvider components={components}>{children}</MDXProvider>
}
8.2 作用域从哪来
providerImportSource: '@mdx-js/react' 会让编译产物从该模块导入 useMDXComponents。这一项必须与运行时提供的包一致,否则组件映射会静默失效——页面照常渲染,只是自定义组件全都没生效。
8.3 按需注册而非全局注册
重型组件(图表、3D)按需加载、随页面 chunk 走,主包更小;基础组件(提示框、代码块)全局注册共享一份,简单但体积固定。
记忆:组件映射靠
MDXProvider、作用域靠providerImportSource——两者必须配对;重型组件按需加载、基础组件全局注册。
9. 构建性能与缓存策略
9.1 插件链是瓶颈
MDX 构建慢,绝大多数时候不是 Vite 慢,而是插件链里有昂贵的 rehype 插件。高亮、语法树遍历、正则替换都是逐节点开销,内容越多越明显。
排查顺序:
1. 逐个注释 rehype 插件,看构建耗时变化
2. 高亮是否对每个代码块都跑了完整语法分析
3. 是否有插件对全文做了正则替换
9.2 缓存与增量
export default defineConfig({
cacheDir: 'node_modules/.vite',
optimizeDeps: {
include: ['@mdx-js/react', 'react', 'react-dom'],
},
})
把 MDX 运行时依赖加进 optimizeDeps.include,可以避免开发态反复预打包导致的服务重启。
9.3 产物体积
- MDX 运行时应是独立 chunk,与内容 chunk 分离
- 元数据 eager、正文 lazy,两者不要混在一个 import 里
- 高亮主题样式按需引入,别整包打进 CSS
记忆:MDX 构建慢先查 rehype 插件链而非 Vite——运行时依赖进
optimizeDeps.include,元数据 eager、正文 lazy,产物体积自然可控。
10. 常见陷阱与落地清单
10.1 高频陷阱表
| 现象 | 原因 | 处理 |
|---|---|---|
| 自定义组件不生效 | 作用域来源与运行时不一致 | 两侧统一同一套 MDX 运行时 |
| 目录点击不跳转 | TOC 的 slug 规则与 rehype-slug 不同 | 复用同一套 slugger |
| 代码高亮闪一下 | 用了运行时高亮 | 改为构建期 Shiki |
| 开发态频繁重启 | MDX 依赖未预打包 | 加进 optimizeDeps.include |
| 图片引发布局偏移 | 未写宽高 | 显式 width 与 height |
| 新增文章 404 | 路由表手写 | 改用 import.meta.glob |
10.2 落地清单
□ mdx 插件以 enforce pre 排在框架插件之前
□ providerImportSource 与运行时包一致
□ remark 与 rehype 插件顺序已核对
□ 标题锚点与 TOC 复用同一套 slug 规则
□ frontmatter 已用 schema 校验
□ 路由表由目录扫描生成
□ 正文图片走 import 且带显式宽高
□ 重型组件按需加载
□ MDX 运行时依赖已加入 optimizeDeps.include
10.3 一句话总结
编译要正(顺序与作用域配对)
插件要少(rehype 链是性能大头)
元数据要严(schema 校验)
路由要自动(扫描生成)
记忆:MDX 内容管线翻车集中在「顺序、作用域、slug、缓存」四类——插件顺序与作用域必须配对,slug 规则必须唯一,元数据必须校验,路由必须自动生成。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。