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() 函数,返回的对象就是映射表:
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> 有两个特征:
- 带有
className(例如language-tsx) children是复杂结构——由多个<span>组成的语法高亮节点,不是纯字符串
而行内代码则是最简单的形态:无 className,children 就是一个字符串。
基于这个观察写出的判断逻辑:
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>,没有className,children是字符串"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 列表。
架构设计
整个方案分三层:
ArticleImageProvider:包裹文章内容,创建 Context,维护 slides 列表和 Lightbox 状态MdxImage:每张图片在挂载时向 Provider 注册自己,点击时通知 Provider 打开PhotoLightbox:底层 Lightbox 组件(基于yet-another-react-lightbox)
Provider:用 useRef 而非 useState 存 slides
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:双模式降级
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 模式
export async function loadPostMDX(locale: string, slug: string) { return import(`@content/${locale}/posts/${slug}.mdx`)}这一行背后做了几件事:
- webpack 代码分割:
import()里包含变量时,webpack 会把content/${locale}/posts/目录下所有.mdx文件各自打包为独立 chunk。访问文章时只加载对应 chunk,不会把全站文章打包到一起 - 路径别名
@content:在tsconfig.json中配置了"@content/*": ["./content/*"],让 TypeScript 和 webpack 都能解析这个路径
{ "compilerOptions": { "paths": { "@/*": ["./src/*"], "@content/*": ["./content/*"] } }}类型声明:告诉 TS .mdx 里有什么
默认情况下 TypeScript 不认识 .mdx 文件。需要一个声明文件告诉它导入 .mdx 可以拿到什么:
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 组件,tableOfContents 是 rehype-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 时显示:
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 模式:当鼠标悬停在 h2(group)上时,内部的 HeadingAnchor 从 hidden 变为 inline。
总结
| 组件/文件 | 职责 | 关键设计 |
|---|---|---|
mdx-components.tsx | 全局 HTML 标签 → React 组件映射 | 大写 = 自定义组件,小写 = 标签覆盖 |
code 映射 | 区分行内代码和代码块 | !className + 纯文本 children 启发式 |
ArticleImageProvider | 共享 Lightbox Context | useRef 存 slides 避免 O(n²) 渲染 |
MdxImage | 图片渲染 + 点击放大 | 双模式:有 Context 用共享,无则降级本地 |
HeadingAnchor | heading 锚点链接 | 必须穿透 id prop,group-hover 显示 |
loadPostMDX | 动态加载 MDX | import() 变量触发 webpack 按文件拆包 |
mdx.d.ts | TypeScript 类型声明 | 声明 tableOfContents 命名导出 |