← 文章
tutorial·2026-07-04·19 min read

MDX 组件架构:行内代码检测、图片 Lightbox 与动态导入

一个 mdx-components.tsx 背后的三个不显眼但关键的设计决策

目录

给博客的 MDX 映射自定义样式,听起来就是把 h2 换个 className 的事。但真正写完 mdx-components.tsx 之后我发现,至少有三个问题比「改样式」复杂得多:行内代码和代码块共用 <code> 标签怎么区分?文章里十几张图片怎么共享一个 Lightbox?动态 import() MDX 文件时 TypeScript 怎么知道里面有什么导出?

这篇文章把这三个问题的思路和实现拆开讲。

mdx-components.tsx 全景

Next.js 的 @next/mdx 会在编译时把 Markdown 标签映射到 React 组件。项目根目录的 src/mdx-components.tsx 导出一个 useMDXComponents() 函数,返回的对象就是映射表:

src/mdx-components.tsx
export function useMDXComponents(): MDXComponents {
return {
// 自定义 JSX 组件——在 MDX 里直接用 <YouTube id="xxx" />
GithubAlert,
Mermaid,
YouTube,
Video,
Audio,
// HTML 标签覆盖——所有 ## 标题自动获得锚点
h2: ({ children, id }) => (
<h2 id={id} className="...">
<HeadingAnchor id={id} />
{children}
</h2>
),
// 行内 / 块级代码检测(下节展开)
code: ({ children, className, ...props }) => { /* ... */ },
// 图片 → 自定义 MdxImage(带 Lightbox)
img: (props) => <MdxImage {...props} />,
// ...p, blockquote, ul, ol, table 等样式覆盖
}
}

映射分两类:大写开头的是自定义 JSX 组件(<YouTube><GithubAlert>),可以在 .mdx 文件里直接当标签用;小写的是 HTML 标签覆盖,MDX 编译器遇到 #`![]() 等 Markdown 语法时会自动走对应的组件。

行内代码 vs 代码块:一个 <code> 两种身份

Markdown 里,行内代码 `foo` 和代码块 ```js 最终都会渲染出 <code> 标签。但它们的样式完全不同——行内代码需要背景色和圆角,代码块由 Expressive Code 接管,已经有自己的 <pre> + <code> 结构。

问题是:mdx-components.tsx 只能给 code 配一个组件。怎么判断当前 <code> 是行内还是块级?

启发式检测

观察 Expressive Code 渲染后的 DOM 结构,代码块里的 <code> 有两个特征:

  1. 带有 className(例如 language-tsx
  2. children 是复杂结构——由多个 <span> 组成的语法高亮节点,不是纯字符串

而行内代码则是最简单的形态:无 classNamechildren 就是一个字符串。

基于这个观察写出的判断逻辑:

src/mdx-components.tsx
code: ({ children, className, ...props }) => {
const isInline =
!className &&
(typeof children === 'string' ||
(Array.isArray(children) &&
children.every(c => typeof c === 'string')))
if (isInline) {
return (
<code className="font-mono text-[13px] bg-card text-code px-1.5 py-0.5 rounded-[3px]">
{children}
</code>
)
}
// 代码块:原样透传,让 Expressive Code 处理
return (
<code className={className} {...props}>
{children}
</code>
)
},

为什么有效

核心逻辑是 !className + 纯文本检测:

  • 行内代码:MDX 编译 `foo` 时生成 <code>foo</code>,没有 classNamechildren 是字符串 "foo"
  • 代码块:Expressive Code 在编译阶段会给 <code> 加上 language-xxx 类名,且 children 是嵌套的 React 元素(<span> 包含高亮 token)

Array.isArray 分支处理的是行内代码包含 HTML 实体或换行被拆分成字符串数组的边缘情况。

可能失效的边缘场景

这是一个 heuristic,不是银弹。如果有插件在行内代码上添加 className,它会被误判为代码块而丢失行内样式。另外,如果某个代码块插件恰好不给 <code>className,行内检测也会误判。不过在 @next/mdx + Expressive Code 这套组合下,目前没有遇到问题。

图片 Lightbox:Context + Ref 注册

文章里可能有十几张图片,点击任何一张都应该打开同一个 Lightbox,并且可以左右切换。这意味着所有图片需要共享一个 Lightbox 实例和一份 slides 列表。

架构设计

整个方案分三层:

  1. ArticleImageProvider:包裹文章内容,创建 Context,维护 slides 列表和 Lightbox 状态
  2. MdxImage:每张图片在挂载时向 Provider 注册自己,点击时通知 Provider 打开
  3. PhotoLightbox:底层 Lightbox 组件(基于 yet-another-react-lightbox

Provider:用 useRef 而非 useState 存 slides

src/components/mdx/ArticleImageProvider.tsx
export function ArticleImageProvider({ children }: { children: React.ReactNode }) {
const slidesRef = useRef<LightboxSlide[]>([])
const [openIndex, setOpenIndex] = useState(-1)
const register = useCallback((slide: LightboxSlide) => {
const existing = slidesRef.current.findIndex(s => s.src === slide.src)
if (existing >= 0) return existing
slidesRef.current.push(slide)
return slidesRef.current.length - 1
}, [])
const open = useCallback((index: number) => {
setOpenIndex(index)
}, [])
return (
<ArticleImageContext.Provider value={{ register, open }}>
{children}
<PhotoLightbox
open={openIndex >= 0}
index={openIndex}
slides={slidesRef.current}
onClose={() => setOpenIndex(-1)}
/>
</ArticleImageContext.Provider>
)
}

这里有一个关键决策:slides 数组用 useRef 而不是 useState

原因是避免级联重渲染。文章加载后,十几张 MdxImage 在渲染阶段依次调用 register()。如果用 useState,每次 push 都会触发 Provider 重渲染,导致所有已注册的图片子组件也重渲染——形成 O(n²) 的渲染风暴。useRef 修改 .current 不触发渲染,slides 悄悄攒够之后,只在用户点击图片调用 open() 时才通过 setOpenIndex 触发一次渲染。

register() 还做了去重:如果同一个 src 已经注册过(比如 React Strict Mode 下 effect 执行两次),直接返回已有的 index。

MdxImage:双模式降级

src/components/mdx/MdxImage.tsx
export default function MdxImage({ src, alt }: { src?: string; alt?: string }) {
const articleImages = useArticleImage()
const [localOpen, setLocalOpen] = useState(false)
if (!src) return null
const slide = { src, alt: alt ?? '' }
const index = articleImages?.register(slide) ?? -1
function handleClick() {
if (articleImages && index >= 0) {
articleImages.open(index)
} else {
setLocalOpen(true)
}
}
return (
<>
<button type="button" onClick={handleClick} className="...">
<img src={src} alt={alt ?? ''} className="..." />
</button>
{!articleImages && (
<PhotoLightbox
open={localOpen}
index={0}
slides={[slide]}
onClose={() => setLocalOpen(false)}
/>
)}
</>
)
}

MdxImage 通过 useArticleImage() 尝试获取 Context。如果存在——说明外层有 ArticleImageProvider——就注册到共享 Lightbox;如果不存在(比如在非文章页面单独使用),则回退到本地独立的 PhotoLightbox 实例。

这种双模式设计让 MdxImage 在任何上下文里都能独立工作,不强依赖 Provider。

潜在问题:注册顺序

register() 返回的 index 取决于调用顺序。在 React 正常的同步渲染流程中,组件按 JSX 树从上到下渲染,所以图片的注册顺序和它们在文章中的出现顺序一致。

但在 concurrent rendering 或 Suspense 场景下,渲染顺序可能不确定。如果文章的某一段被 Suspense 包裹延迟渲染,后面的图片可能先于前面的图片注册,导致 Lightbox 中的翻页顺序与文章不一致。目前博客的文章页面没有这种场景,但如果将来引入分段加载,需要改成基于 DOM 位置排序。

动态导入 MDX 与 TypeScript 类型

文章页面需要根据 URL 里的 slug 动态加载对应的 .mdx 文件。不能用静态 import,因为 slug 是运行时参数。

动态 import 模式

src/lib/posts.ts
export async function loadPostMDX(locale: string, slug: string) {
return import(`@content/${locale}/posts/${slug}.mdx`)
}

这一行背后做了几件事:

  1. webpack 代码分割import() 里包含变量时,webpack 会把 content/${locale}/posts/ 目录下所有 .mdx 文件各自打包为独立 chunk。访问文章时只加载对应 chunk,不会把全站文章打包到一起
  2. 路径别名 @content:在 tsconfig.json 中配置了 "@content/*": ["./content/*"],让 TypeScript 和 webpack 都能解析这个路径
tsconfig.json
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@content/*": ["./content/*"]
}
}
}

类型声明:告诉 TS .mdx 里有什么

默认情况下 TypeScript 不认识 .mdx 文件。需要一个声明文件告诉它导入 .mdx 可以拿到什么:

mdx.d.ts
declare module '*.mdx' {
import type { MDXProps } from 'mdx/types'
import type { Toc } from '@stefanprobst/rehype-extract-toc'
export const tableOfContents: Toc
export default function MDXContent(props: MDXProps): JSX.Element
}

这样在页面里使用时就有完整类型支持:

const { default: MDXContent, tableOfContents } = await loadPostMDX(locale, slug)

default 是 MDX 编译生成的 React 组件,tableOfContentsrehype-extract-toc/mdx 插件注入的命名导出。

Heading id 穿透:一个容易忽略的细节

rehype-slug 在编译阶段为每个 heading 生成 id(比如 ## 技术选型id="技术选型")。但如果 mdx-components.tsx 里自定义了 h2 组件却没有id prop 传递到 <h2> 元素上,这个 id 就丢了——锚点链接和目录跳转全部失效。

关键就是解构时不要漏掉 id

// ✅ 正确:id 传递给 <h2>
h2: ({ children, id }) => (
<h2 id={id} className="group ... scroll-mt-6">
<HeadingAnchor id={id} />
{children}
</h2>
),

scroll-mt-6 是另一个细节:点击锚点跳转时,如果页面有固定导航栏,heading 会被遮住。scroll-margin-top 留出偏移量,让跳转后的 heading 不被导航栏挡住。

HeadingAnchor 组件本身很简单——渲染一个 # 链接,hover 时显示:

src/components/mdx/HeadingAnchor.tsx
export function HeadingAnchor({ id }: { id?: string }) {
if (!id) return null
return (
<a
href={`#${id}`}
className="heading-anchor mr-1.5 hidden ... group-hover:inline"
aria-label="Link to section"
>
#
</a>
)
}

这里用了 Tailwind 的 group / group-hover 模式:当鼠标悬停在 h2group)上时,内部的 HeadingAnchorhidden 变为 inline

总结

组件/文件职责关键设计
mdx-components.tsx全局 HTML 标签 → React 组件映射大写 = 自定义组件,小写 = 标签覆盖
code 映射区分行内代码和代码块!className + 纯文本 children 启发式
ArticleImageProvider共享 Lightbox ContextuseRef 存 slides 避免 O(n²) 渲染
MdxImage图片渲染 + 点击放大双模式:有 Context 用共享,无则降级本地
HeadingAnchorheading 锚点链接必须穿透 id prop,group-hover 显示
loadPostMDX动态加载 MDXimport() 变量触发 webpack 按文件拆包
mdx.d.tsTypeScript 类型声明声明 tableOfContents 命名导出