MDX 与课时渲染
这一课你将能做到
- 说清 Markdown 与 MDX 的关系:MDX = Markdown + 可嵌入的 React 组件
- 在 Next.js 里配置
@next/mdx,写出带自定义组件的 .mdx 页面
- 理解本站课时内容「服务端读源 → 渲染 → 消毒 → 输出」的完整管线
为什么要学
做内容型产品(文档站、课程平台、博客)绕不开一个问题:内容用什么格式写、怎么安全地渲染成页面?Markdown 是事实标准——写作友好、程序员都会;但它有个天花板:只能表达文字排版,表达不了交互。想在一篇文章里嵌一个可运行的代码沙盒、一个折叠面板、一道点击出答案的练习题,纯 Markdown 做不到。
MDX 补上这块:在 Markdown 里直接写 JSX,内容作者可以像用 HTML 标签一样用你封装好的 React 组件。本学习平台的课时内容就是按这条思路处理的——你此刻正在读的这篇课文的排版结构,最终都由 React 组件渲染出来;平台还支持课时里嵌练习与测验组件,这正是 MDX 的能力域。
面试场景虽不如 RSC 高频,但「Markdown 渲染的安全问题(XSS)」是个好的加分话题——任何用户可控内容进 HTML 都要消毒(sanitize),本站专门有 mdxSanitize 模块处理这件事,说明这不是纸上谈兵。
先建立图像
Markdown 与 MDX 的对比:
普通 Markdown(.md) MDX(.mdx)
# 标题 # 标题
- 列表 - 列表
**加粗** **加粗**
❌ 不能写组件 ✅ 可以写组件:
<Callout type="warn">注意!</Callout>
<Quiz question="1+1=?" answer="2" />
在 Next.js 生态里的位置:
写作源文件 Next.js 渲染管线 用户看到
.mdx 文件 ──→ @next/mdx 编译 ──→ 页面(编译期,构建时确定)
─────────────
数据库/API 里的 markdown 字符串 ──→ 动态渲染库 + sanitize ──→ 页面(运行时)
(如本站后端下发的 contentMd)
两种来源决定两种技术路线,本课两条都讲:
| 内容来源 | 技术路线 | 特点 |
|---|
| 仓库里的 .mdx 文件 | @next/mdx 官方方案 | 编译进构建,类型安全,改内容要重新部署 |
| 后端/API 下发的字符串 | 运行时渲染(如 next-mdx-remote 或自研管线) | 内容可后台随时更新,必须做安全消毒 |
本站属于第二种:Spring Boot 后端把课时 contentMd 下发给前端,Next.js 服务端渲染成 React 元素。
跟着做
步骤 1:安装并配置 @next/mdx
npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
修改 next.config.ts(或新建 next.config.mjs):
import type { NextConfig } from "next";
import createMDX from "@next/mdx";
const nextConfig: NextConfig = {
pageExtensions: ["ts", "tsx", "md", "mdx"], // 让 Next 把 .mdx 也当页面
};
const withMDX = createMDX({
// 可加 remark/rehype 插件,如 remarkGfm(表格、删除线)
// options: { remarkPlugins: [remarkGfm] },
});
export default withMDX(nextConfig);
关键点:pageExtensions 加上 mdx 后,app/about/page.mdx 就能直接当页面用——和 page.tsx 同一地位。
步骤 2:写第一个 MDX 页面
新建 src/app/mdx-demo/page.mdx:
# 我的第一个 MDX 页面
这是普通 **Markdown** 语法,一切照旧。
## 但是,可以插入组件
import Callout from "@/components/Callout";
<Callout type="warn">
这是 React 组件渲染出来的警告框,不是引用块。
</Callout>
配套组件 src/components/Callout.tsx:
const styles = {
info: "border-blue-500 bg-blue-50 text-blue-800",
warn: "border-amber-500 bg-amber-50 text-amber-800",
} as const;
export default function Callout({
type = "info",
children,
}: {
type?: "info" | "warn";
children: React.ReactNode;
}) {
return (
<div className={`my-4 rounded border-l-4 p-3 text-sm ${styles[type]}`}>
{children}
</div>
);
}
访问 /mdx-demo:标题、加粗是 Markdown 渲染的,警告框是 React 组件渲染的——两种内容在一页和谐共处。
步骤 3:覆盖默认元素映射(mdx-components)
MDX 里所有 Markdown 元素(h1、p、code…)默认渲染成原生标签。想全局换成自己的组件(比如统一代码块样式),在项目根目录建 mdx-components.tsx:
import type { MDXComponents } from "mdx/types";
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
h2: (props) => (
<h2 className="mt-8 mb-3 text-xl font-bold border-b pb-1" {...props} />
),
code: (props) => (
<code className="rounded bg-gray-100 px-1 py-0.5 text-sm" {...props} />
),
...components,
};
}
从此所有 .mdx 页面的 ## 标题 都自动带上统一样式——内容作者完全无感,样式收敛到组件层。这就是「内容与样式分离」的 MDX 版本。
步骤 4:运行时渲染字符串(本站路线)
后端下发的 markdown 是字符串,没法走编译路线,需要运行时渲染。以社区方案 next-mdx-remote-client 思路演示核心(重点看消毒环节):
npm install next-mdx-remote-client
// src/components/MdxRenderer.tsx(Server 组件)
import { MDXRemote } from "next-mdx-remote-client/rsc";
import rehypeSanitize from "rehype-sanitize";
import Callout from "@/components/Callout";
const components = { Callout }; // 开放给内容的组件白名单
export default async function MdxRenderer({ source }: { source: string }) {
return (
<MDXRemote
source={source}
components={components}
rehypePlugins={[[rehypeSanitize, { tagNames: ["p", "h2", "h3", "code", "pre"] }]]}
/>
);
}
// src/app/lesson/[slug]/page.tsx
import MdxRenderer from "@/components/MdxRenderer";
export default async function LessonPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// 真实项目:fetch 后端 /api/lessons/{slug} 拿 contentMd
const contentMd = `# 课时标题\n\n正文 **加粗** 与 <Callout>提示</Callout>`;
return (
<article className="prose max-w-none p-6">
<MdxRenderer source={contentMd} />
</article>
);
}
消毒是生死线:如果内容里有 <script>alert(1)</script> 或 <img onerror=...>,不消毒直接渲染就是存储型 XSS。方案二选一:渲染前用 rehype-sanitize 这类白名单过滤;或像本站一样先对源做 sanitize(mdxSanitize 模块)再进渲染管线。用户可控内容(评论、社区发帖)100% 需要这条防线。
步骤 5:给内容配「元数据」(frontmatter)
内容通常需要标题、作者、标签这类结构化信息,写在文件头:
---
title: Next.js 数据获取
order: 5
tags: [fetch, cache]
---
正文从这里开始……
@next/mdx 本身不解析 frontmatter,社区常用 @next/mdx + remark-frontmatter 导出,或最简单的方式:内容走 CMS/后端,元数据由后端字段提供(本站的做法:title、orderIndex、contentMd 都是 API 返回的独立字段,不做 frontmatter)。两条路线取舍:文件驱动选 frontmatter,数据库驱动选 API 字段。
讲清楚要点
- MDX 的价值 = 内容里长出交互:练习题组件、折叠答案、在线运行沙盒,这些「课程产品的差异化体验」全靠它。纯文档可以只用 Markdown,内容型平台几乎都会走到 MDX。
- 两条渲染路线按内容来源选:仓库内文件走
@next/mdx(编译期,快而稳);API/数据库字符串走运行时渲染库(灵活,可后台更新)。混用的判据就一条:内容改了要不要重新部署。
- 组件白名单思维:永远不把「任意组件」开放给内容。
components 映射表里放什么,内容里才能用什么——这是能力边界,也是安全边界。本站渲染课时内容时对可嵌组件有严格白名单。
- 消毒(sanitize)必须先于渲染:
rehype-sanitize 或等价白名单过滤,过滤对象是标签与属性(script、iframe、onerror 全拦)。记住判断口诀:内容来源是用户可控的(后端数据、UGC)→ 必消毒;来源是仓库里可信作者写的 .mdx → 可免。
- 样式注入点有三层:
mdx-components.tsx 全局覆盖默认元素;components 传参提供业务组件;外层容器(如 Tailwind 的 prose 类)控制排版基调。本站课时页的排版就由容器 + 映射共同完成。
你可能会懵的地方
-
MDX 和 Markdown-It / Marked 这类库什么关系?
那些是纯 Markdown→HTML 转换器,遇到 JSX 只会当文本或转义。MDX 用的是 MDX 编译器(@mdx-js),能把 JSX 编译成 React 组件调用。功能上是超集关系,选型看要不要组件。
-
课时内容放数据库还是放文件?
内容频繁更新、多角色管理(编辑/审核)→ 数据库 + 后台(本站模式);内容随代码走、版本控制天然合并 → 仓库 .mdx 文件(开源文档常见模式)。技术上渲染管线几乎一致。
-
为什么 MDX 组件不用再写 'use client'?
@next/mdx 页面默认按 Server 组件渲染;只有引入了状态组件(如带点击计数的练习)时,那一个组件文件自己标 'use client' 即可。内容主体保持服务端渲染,SEO 与首屏不受影响。
-
rehype 和 remark 插件有什么区别?
处理管线的两个阶段:remark 操作 Markdown 语法树( remarkGfm 加表格支持、remark-frontmatter 解析元数据),rehype 操作 HTML 语法树(rehypeSanitize 消毒、rehype-highlight 代码高亮)。记忆:remark 管「写什么」,rehype 管「出什么 HTML」。
-
消毒会把我的自定义组件也过滤掉吗?
顺序和层位是关键:sanitize 过滤的是「原始 HTML 标签与属性」,你的自定义组件在 MDX 编译层就已经变成组件调用了。但要小心内容里裸写的 HTML——这正是需要白名单的地方。把「组件白名单」和「HTML 白名单」分开管理。
错法对照
| 错法 | 问题 | 正解 |
|---|
忘记配 pageExtensions: ["mdx"] | .mdx 文件不被当页面,404 | next.config 里加上扩展名 |
mdx-components.tsx 放进 src/ 或 app/ 里 | 官方约定必须项目根目录 | 根目录(与 package.json 同级) |
直接 dangerouslySetInnerHTML 渲染后端 markdown | 存储型 XSS 高危入口 | 运行时 MDX 渲染 + rehype-sanitize |
| 对用户 UGC 内容免消毒渲染 | 脚本注入 | 无条件消毒,白名单收紧 |
在内容里放任意导入(import 外部包) | 编译期执行不可信代码,供应链风险 | 编译路线仅限可信仓库;运行时路线用组件白名单 |
卡点急救
/mdx-demo 404:检查 next.config 里 pageExtensions 是否含 mdx;改完配置要重启 dev;文件确实叫 page.mdx。
<Callout> 渲染成了普通文字:组件没进「映射」——编译路线检查页面里的 import 是否写在 MDX 内;运行时路线检查 components 传参。名字大小写也要完全一致。
- 改了 mdx-components.tsx 样式没生效:该文件只在编译路线(@next/mdx 页面)生效;运行时渲染的字符串要用渲染库自己的 components 参数传。两条路线的映射互不相通。
面试常问
-
Markdown 内容在 Next.js 里怎么渲染成页面?安全上注意什么?
按来源分两条路:仓库内 .mdx 用 @next/mdx 编译成页面,元素样式通过 mdx-components 映射统一接管;API 下发的字符串用运行时 MDX 渲染组件(如 next-mdx-remote)。安全核心是消毒:用户可控内容必须过 rehype-sanitize 白名单(拦 script/iframe/事件属性),同时只开放受信组件白名单,禁止内容侧任意 import。
-
MDX 相比纯 Markdown 的优势与代价?
优势:内容中可嵌入 React 组件实现交互(练习、演示、图表),样式可全局映射收敛。代价:内容耦合了组件 API(作者要懂可用组件清单)、编译链更重、运行时渲染需要更严的安全控制。内容型产品(课程、文档站)通常值得,纯博客未必。
-
你们的课时内容是怎么从数据库到页面的?
参考回答:Spring Boot 返回课时对象(title/orderIndex/contentMd 等字段),Next.js 课时页(Server Component)拿到 contentMd 后走「消毒 → MDX 运行时渲染 → 组件映射」管线输出 React 元素,渲染在服务端完成保证首屏与 SEO;练习、测验等交互块以白名单组件形式嵌入,客户端水合后可交互。
自测三问
- 仓库 .mdx 文件和后端下发的 markdown 字符串,分别走哪条渲染路线?
- 为什么运行时渲染必须消毒?白名单要管住哪两类东西?
mdx-components.tsx 和渲染时的 components 参数各管什么?
小结一句话
MDX 让内容长出交互:编译路线管仓库文件、运行时路线管接口字符串,共同的底线是组件白名单与 HTML 消毒。
延伸