+++
title = '让文章里的示例动起来：在 Hugo 博客中使用 MDX'
date = 2026-09-12T13:43:00+08:00
lastmod = 2026-09-12T14:53:00+08:00
description = '用一个可以调节参数的阅读时间估算器，介绍 MDX 的组件导入、属性、状态、组件包裹 Markdown、正文导出变量，以及 Stark 如何预渲染正文并接入 Hugo 的静态部署流程。'
slug = 'mdx-interactive-articles'
tags = ['MDX', 'React', 'Hugo']
images = ['images/share.png']
draft = false
+++

import ReadingTime from './ReadingTime.jsx'
import Callout from './Callout.jsx'
import ReaderNote from './ReaderNote.jsx'

写技术文章时，有些关系很适合让读者自己调参数。比如文章有多少字、每分钟读多少字、估算时间怎样变化。静态表格可以列出几组结果，但读者想试另一个数字时，还是得自己算。

这篇文章本身使用 MDX 编写。下面的估算器是直接导入正文的 React 组件，可以调节速度、增加字数，再恢复初始值。后面还会出现包住 Markdown 的容器组件、由数组生成的列表，以及把内容保存在浏览器本地的组件。

## 先试一个能改变状态的示例

<ReadingTime initialWords={1800} initialSpeed={300} />

这里的计算规则是「字数 ÷ 每分钟阅读字数，向上取整」。初始值是 1800 字、每分钟 300 字，因此显示 6 分钟。把速度调到每分钟 600 字，结果变成 3 分钟。

这只是演示用的估算模型，没有计入代码、图表和回读的时间。它也不会改变文章顶部由 Hugo 计算的阅读时长。这里值得观察的是：正文中的一个组件可以接收参数，并根据读者操作更新自己的显示结果。

## Markdown、JSX 和表达式写在同一个文件里

[MDX](https://mdxjs.com/docs/using-mdx/) 可以把 Markdown 内容编译成组件，并支持导入组件、传递属性和使用 JavaScript 表达式。本博客选择 React 作为运行时；MDX 本身并不限定只能使用 React。

普通段落、列表、链接和代码块仍然按 Markdown 编写。需要交互的地方，再放入组件：

```mdx
import ReadingTime from './ReadingTime.jsx'

## 阅读时间估算

调节下面的参数，看看结果怎样变化。

<ReadingTime initialWords={1800} initialSpeed={300} />
```

`import` 指向与文章放在一起的组件文件。`initialWords` 和 `initialSpeed` 是组件的属性，也就是作者传给组件的初始参数。大括号中的 `1800` 和 `300` 是 JavaScript 数值，而不是字符串。

表达式也可以直接出现在正文里。例如下面这一行的计算结果就是由 MDX 表达式生成的：

**1800 字 ÷ 每分钟 300 字 = {1800 / 300} 分钟。**

对应的源码是：

```mdx
**1800 字 ÷ 每分钟 300 字 = {1800 / 300} 分钟。**
```

## 组件也能包住 Markdown

前面的组件是正文里的一个叶子节点。反过来也可以：把 Markdown 写在组件标签内部，作为子元素传进去。下面这个提示框就是这种用法。

<Callout title="框里的内容仍然是 Markdown">

这里的 **粗体**、`行内代码`、[链接](https://mdxjs.com/) 和下面的列表，都是普通的 Markdown 写法。

- 列表项由 Markdown 生成
- 组件只负责外层的标题和边框

</Callout>

组件本身不解析 Markdown，它只渲染传进来的 `children`：

```jsx
import './callout.css';

export default function Callout({ title, tone = 'note', children }) {
  return (
    <aside className={`mdx-callout mdx-callout-${tone}`}>
      <p className="mdx-callout-title">{title}</p>
      <div className="mdx-callout-body">{children}</div>
    </aside>
  );
}
```

编译器先把标签内部的 Markdown 编译成 React 元素，再交给组件。因此组件不需要知道 Markdown 的存在，作者也可以在这个结构里继续用熟悉的写法。`tone` 是另一个属性，用字符串换一种外框样式：

<Callout title="同一组件，换一组属性" tone="warn">

`tone="warn"` 改的是外框，内容仍然由 Markdown 写成。

</Callout>

提示、警告、折叠说明这类在文章里反复出现的结构，适合做成这样的容器组件：样式集中在一个 CSS 文件里，正文只负责写内容。

## 一组结果可以由数据生成

前面演示的是单个表达式。需要一次列出多组结果时，不必把每一行都写出来——正文可以直接导出数据，再用表达式渲染：

export const speeds = [
  { speed: 200, words: 1800 },
  { speed: 300, words: 1800 },
  { speed: 600, words: 1800 },
]

<ul>
{speeds.map(({ speed, words }) => (
  <li key={speed}>{words} 字 ÷ 每分钟 {speed} 字 = {Math.ceil(words / speed)} 分钟</li>
))}
</ul>

`export const speeds = [...]` 是普通的 JavaScript 模块语法，MDX 允许在正文里直接使用。下面的 `<ul>` 用 `speeds.map(...)` 生成列表项，`key` 是 React 用来区分列表项的属性。

把数据写在正文里的好处是它离解释它的那段文字很近：改一处数字，列表就跟着变，不需要同时维护正文和表格两份内容。

表达式和列表都在构建时求值，写进 HTML 之后就不再变化。要响应点击、保存新的参数，需要组件里的状态和事件处理。

## 状态留在组件里，文章负责解释

阅读时间估算器使用 React 的 [`useState`](https://react.dev/reference/react/useState) 保存当前字数和速度。读者修改速度时，事件处理函数更新状态，React 再根据新的状态更新结果。

核心代码如下，完整示例另外包含增加字数和恢复初始值的按钮：

```jsx
import { useState } from 'react';

export default function ReadingTime({ initialWords = 1800, initialSpeed = 300 }) {
  const [words] = useState(initialWords);
  const [speed, setSpeed] = useState(initialSpeed);
  const minutes = Math.ceil(words / speed);

  return (
    <section>
      <label>
        阅读速度：每分钟 {speed} 字
        <input
          type="range"
          min="100"
          max="600"
          step="50"
          value={speed}
          onChange={event => setSpeed(Number(event.target.value))}
        />
      </label>
      <p>估算阅读时间：{minutes} 分钟</p>
    </section>
  );
}
```

这样拆开之后，文章继续解释公式和假设，组件只处理输入、计算和显示。以后另一篇文章需要相同的估算器，可以复用组件，再通过属性换一组初始值。

组件也可以在同一篇文章中使用多次。下面给它传入 600 字，每分钟仍然是 300 字，初始结果就是 2 分钟。两份组件的状态各自独立，修改这里不会改变上面的示例。

<ReadingTime initialWords={600} initialSpeed={300} />

## 正文在构建时已经生成

给静态博客加交互，一个容易混淆的问题是：正文是不是必须等浏览器运行 JavaScript 后才出现？

本博客使用的 [Stark MDX 实现](https://github.com/jimyag/stark/blob/e7cd5b0190ce6555f0f6798a674460dd4850d07d/scripts/mdx.mjs) 在构建时先渲染文章。处理顺序如下：

```text
index.mdx + ReadingTime.jsx
          │
          ├─ MDX 编译 + React 服务端渲染 → 文章 HTML
          │
          └─ 浏览器打包 → React 和文章的交互脚本
                                    │
文章 HTML + Hugo 主题布局 ────────────┤
                                    ↓
                              public/ 静态文件
```

因此，页面初始 HTML 已经包含正文、表格和估算器的初始结果。浏览器加载交互脚本后，调用 React 的 [`hydrateRoot`](https://react.dev/reference/react-dom/client/hydrateRoot)，让这些已有内容具备交互能力。禁用 JavaScript 时仍能读文章，也能看到初始数字，但按钮不会更新状态。

这要求组件在服务端和浏览器第一次渲染时产生一致的内容。这个示例只依赖固定的初始属性，不在渲染时读取 `window`、当前时间或随机数。确实需要访问浏览器能力的逻辑，应放在合适的事件处理或 effect 中，而不是直接写在组件的首次渲染路径上。

## 需要浏览器能力时，用 effect

上一节说首次渲染必须和服务端一致。只能由浏览器提供的能力——本地存储、窗口尺寸、`navigator`——就不该出现在首次渲染的路径上。下面这个组件把读者写的笔记存在浏览器本地：

<ReaderNote />

它的做法是：初始状态给一个固定的空值，等浏览器接管之后，再在 effect 里读取本地存储。

```jsx
import { useEffect, useId, useState } from 'react';

const STORAGE_KEY = 'stark-mdx-reader-note';

export default function ReaderNote() {
  const id = useId();
  const [note, setNote] = useState('');
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    const stored = window.localStorage.getItem(STORAGE_KEY);
    if (stored) setNote(stored);
    setLoaded(true);
  }, []);

  useEffect(() => {
    if (loaded) window.localStorage.setItem(STORAGE_KEY, note);
  }, [loaded, note]);

  return (
    <section>
      <label htmlFor={id}>写一句笔记，刷新页面后它还在</label>
      <textarea id={id} value={note} onChange={event => setNote(event.target.value)} />
    </section>
  );
}
```

如果把 `localStorage` 读进 `useState` 的初始值，服务端会渲染出空笔记，浏览器第一次渲染出保存过的笔记，两者不一致，React 只能丢掉服务端生成的 HTML 重新渲染——上一节说的「禁用 JavaScript 也能读文章」也就失去了意义。

`loaded` 这个状态解决的是另一个问题：effect 在首次渲染后立即执行，如果这时就把空的 `note` 写回存储，读者上次保存的内容会被清掉。等读取完成后再打开写入。

笔记只留在读者自己的浏览器里，不经过服务器，也不会上传。

## 在这个 Hugo 博客里怎么写和预览

这篇文章使用页面包组织文件：

```text
content/blog/294-mdx-interactive-articles/
├── index.mdx
├── ReadingTime.jsx
├── Callout.jsx
├── ReaderNote.jsx
├── reading-time.css
├── callout.css
└── reader-note.css
```

`index.mdx` 保存元数据和正文，`.jsx` 文件导入各自的 CSS 并实现交互。新建文章时仍可先使用仓库的 `./new` 生成模板，补全 front matter，再将这篇文章的 `index.md` 改名为 `index.mdx`。不要同时保留同名的 Markdown 和 MDX 页面，否则编译器会报告冲突。

已经克隆本博客仓库并安装 Node.js 22 或更高版本、Hugo 0.162 或更高版本后，在博客根目录运行：

```bash
./preview
```

脚本会先初始化缺失的主题子模块、安装依赖，然后预编译 MDX 并启动 Hugo。默认等同于 `hugo server -D`，包含草稿。修改 `.mdx`、组件或它引用的 CSS 后自动重新编译，Hugo 通过 LiveReload 刷新浏览器；连续保存会合并成一次编译。语法错误会在终端报错并保留上次成功的产物，改好后自动恢复。

编译器将产物放在 `.stark-mdx/` 中，`mdx.toml` 再把生成的文章和脚本挂载进 Hugo。原来的正文和组件文件不会被覆盖，生成目录也不需要提交到 Git。预览渲染到内存，不会写入 `public/`。

需要单独调用其中一层时：

```bash
node themes/stark/scripts/mdx.mjs --site . --drafts
hugo server -D --config hugo.toml,mdx.toml --renderToMemory --disableFastRender
```

远端部署使用仓库的 `bash build.sh`，依次安装依赖、预编译 MDX、运行 Hugo。Cloudflare 最后发布的仍是 `public/` 里的静态文件，不需要为这个估算器运行 Node.js 服务。

## 哪些能力需要另外处理

MDX 能使用 JSX，不代表原来的 Markdown 渲染功能会自动迁移。本博客的普通 Markdown 由 Hugo 渲染，MDX 则由独立编译器渲染，当前边界如下：

| 能力 | 这套 MDX 接入的行为 |
| --- | --- |
| 段落、列表、表格、代码块 | 支持；表格由 GFM 插件处理 |
| 导入 React 组件、传递属性、组件内部状态 | 支持 |
| 组件包裹 Markdown、正文 `export` 变量、effect 与浏览器 API | 支持 |
| 正文标题目录 | 从 MDX 标题生成，默认显示二至四级标题 |
| 搜索、RSS 摘要、字数 | Hugo 可以读取预渲染后的正文 |
| 原始文章链接 | 页面顶部提供 MDX 源文件，不包含导入的组件 |
| Hugo shortcode | 不在 MDX 中执行 |
| Chroma 语法高亮、Mermaid 代码块钩子 | 不会自动应用，需单独接入 |
| Hugo 图片自动优化 | 不会自动应用；页面包中的相对图片链接仍可使用 |

另外，MDX 会在构建时执行文章及其导入的代码。维护自己的博客时，应像检查普通源码一样检查这些文件，不应直接编译不可信来源的 MDX。

对于只包含文字、截图和代码片段的文章，我仍然可以使用普通 Markdown。需要读者改变参数、观察结果，或者复用一个带行为的示例时，再使用 MDX。上面的估算器展示的就是这种用法：把可操作的部分放进组件，让正文解释它为何这样计算、哪些假设没有算进去。
