Vite 与 MDX 内容驱动站点:@mdx-js/rollup 集成、插件链与内容管线

在 Vite 项目中用 MDX 搭建内容驱动站点:@mdx-js/rollup 的接入与 enforce pre 顺序、remark 与 rehype 插件链的编排、代码高亮与标题锚点生成、frontmatter 解析与类型化校验、内容目录扫描与路由自动生成、图片与静态资源处理、MDX 组件的按需注册与作用域隔离、构建性能与缓存策略,以及常见陷阱与落地清单。

引言

内容驱动站点(文档、博客、产品手册)的核心矛盾是:内容用 Markdown 写才顺手,交互用组件实现才灵活。MDX 正好把两者缝合——在 Markdown 里直接写 JSX,构建期编译成组件,既保留纯文本的可维护性,又获得完整的组件能力。

在 Vite 项目里落地 MDX,关键不在于「能不能编译」,而在于把编译、插件链、路由、静态资源、组件注册与缓存串成一条可维护的内容管线。管线里每一环都有各自的坑:插件顺序错了锚点就丢了,作用域没配对组件就静默失效,依赖没预打包开发态就反复重启。

本文从技术选型讲起,逐步搭建 @mdx-js/rollup 的接入、remark 与 rehype 插件链、代码高亮与标题锚点、frontmatter 类型化、路由自动生成、图片处理、组件按需注册与构建缓存,最后给出常见陷阱与落地清单。

前置:插件机制与钩子、配置体系。框架集成见 Vite 框架集成:React、Vue、Svelte、Solid 与官方插件生态。


目录


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-gfmremark表格、删除线、任务列表
remark-frontmatterremark识别 frontmatter
remark-tocremark生成目录
rehype-slugrehype给标题加 id
rehype-autolink-headingsrehype给标题加锚点链接
rehype-pretty-coderehype代码高亮

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 规则必须唯一,元数据必须校验,路由必须自动生成。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「vite」更多文章

  1. Vite 中的 3D 与 WebGL 工程化:Three.js、模型纹理压缩与渲染性能治理
  2. Vite 项目的 GraphQL 数据层:Apollo、urql、codegen 与缓存失效实战
  3. Vite 项目部署平台适配实战:Vercel、Netlify、Cloudflare Pages 与自建方案