jimyag's Blog

让文章里的示例动起来:在 Hugo 博客中使用 MDX

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

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

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

试着调整阅读参数

当前字数:1800

估算阅读时间:6 分钟

仅按字数估算,不计代码、图表和回读时间。

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

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

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

MDX 可以把 Markdown 内容编译成组件,并支持导入组件、传递属性和使用 JavaScript 表达式。本博客选择 React 作为运行时;MDX 本身并不限定只能使用 React。

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

import ReadingTime from './ReadingTime.jsx'

## 阅读时间估算

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

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

import 指向与文章放在一起的组件文件。initialWordsinitialSpeed 是组件的属性,也就是作者传给组件的初始参数。大括号中的 1800300 是 JavaScript 数值,而不是字符串。

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

1800 字 ÷ 每分钟 300 字 = 6 分钟。

对应的源码是:

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

组件也能包住 Markdown

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

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

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 是另一个属性,用字符串换一种外框样式:

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

一组结果可以由数据生成

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

  • 1800 字 ÷ 每分钟 200 字 = 9 分钟
  • 1800 字 ÷ 每分钟 300 字 = 6 分钟
  • 1800 字 ÷ 每分钟 600 字 = 3 分钟

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

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

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

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

阅读时间估算器使用 React 的 useState 保存当前字数和速度。读者修改速度时,事件处理函数更新状态,React 再根据新的状态更新结果。

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

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 分钟。两份组件的状态各自独立,修改这里不会改变上面的示例。

试着调整阅读参数

当前字数:600

估算阅读时间:2 分钟

仅按字数估算,不计代码、图表和回读时间。

正文在构建时已经生成

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

本博客使用的 Stark MDX 实现 在构建时先渲染文章。处理顺序如下:

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

因此,页面初始 HTML 已经包含正文、表格和估算器的初始结果。浏览器加载交互脚本后,调用 React 的 hydrateRoot,让这些已有内容具备交互能力。禁用 JavaScript 时仍能读文章,也能看到初始数字,但按钮不会更新状态。

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

需要浏览器能力时,用 effect

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

还没有内容

内容只写入浏览器本地存储,不会上传。

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

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 博客里怎么写和预览

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

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 或更高版本后,在博客根目录运行:

./preview

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

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

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

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。上面的估算器展示的就是这种用法:把可操作的部分放进组件,让正文解释它为何这样计算、哪些假设没有算进去。

#MDX #React #Hugo