在 Markdown 中使用 React 组件
我想在我的 Markdown 博客文章中嵌入一些简单的 React 组件——想做到很容易,想做好却并不简单。
需求
最主要的诉求是可以在 markdown 渲染出的网页(比如现在这篇博客文章)中嵌入一些 React 组件,比如代码块的复制按钮、GitHub 仓库信息卡片等。
还有一些额外的需求:
- 尽可能少地增加 JavaScript 包大小(或者其他需要网络传输的数据大小)
- SSR/水合友好。
- 最好可以做到框架无关。
- 不要使用 MDX。这个需求比较个人,具体原因可以看文末的 为什么不用 MDX。
Markdown 如何包含 React 组件?
一个简单的方法是使用 HTML 的 custom element 作为中间表示,比如将 markdown 转换成 <github-card data-repo="yy4382/yfi.moe" />
,然后在最后渲染的时候通过一些办法用 React 组件替换这些 custom element(本文的重点就是通过什么办法做替换)。
这种方法的好处是不会限制使用的 markdown 处理器,更换起来比较简单。
我使用的是 remark / rehype 这个生态系统,这种情况下实际上不需要序列化成 HTML,在转换成 HTML 的 AST hast
后就可以使用 hast-util-to-jsx-runtime
库将这些自定义元素转换为 React 组件了。
同时,其实我们并不需要在 markdown 中手动写这些 custom element,可以使用 remark / rehype 插件。比如,如果想要给每个代码块添加一个 copy button,我就编写了 一个插件 自动添加;Markdown 还有一个不在 CommonMark 标准但社区广泛采用的 directives 提案,可以使用它实现更有 markdown 风格的 GitHub 卡片 ::github-repo{repo="yy4382/yfi.moe"}
。
欠优化的方法
先介绍两个我尝试过,但是因为缺点太明显,以至于没有实际上线过的方案:
- 直接用
react-markdown
库:我为什么要把整个 remark/rehype 工具链发到客户端?我只是想展示一篇博客文章而已。 - 使用
react-dom
的createPortal
来将组件插入到对应位置:在服务端createPortal
不会跑(因为没有真实 DOM),不能 SSR。
方法一:RSC 就是为此而生的
Note
优点:简单易实现
缺点:必须得有 RSC 才能用;RSC payload 可能过大
直接用 react-markdown
会导致超大的包大小,那我把它放进 RSC 里不就行了吗?
如果你有幸(或者不幸)使用了 Next.js1,那么这种方法简单易操作,不需要用到(后文会提到的)各种奇技淫巧。
然而这种方法也有一些小问题:
- 显然只能在支持 RSC 的框架中使用
- RSC Payload 会很大:每个
<p>
或者<div>
都会以 RSC 的格式存在于 payload 中;而 RSC 格式虽然相对于 React 组件代码来说很紧凑,但是和 HTML 相比还是大不少。在使用类似 shiki 这样的高亮库后,高亮部分更是会让 RSC payload 急速膨胀:代码块一多,RSC payload 甚至可能比直接把react-markdown
发到客户端还要大。一种缓解办法是把代码块部分直接用dangerouslySetInnerHTML
插入,就不用为其中每个 token 都生成 RSC 表示了(示例代码)。 本博客曾经有过一段时间使用的是 Next.js,当时就是使用的这种思路,只是稍有更改:没有直接使用react-markdown
,而是将 markdown 转换到 hast 和 hast 转换成 React 组件分了开来。当时的代码在博客开源仓库的nextjs-attemp
tag 下:yy4382/yfi.moe at nextjs-attempt。
方法二:手动 SSR + hydrateRoot
Important
这是本博客目前使用的方式,也是我目前最推荐的。
Note
优点:几乎不会额外增加包体积;可选的 SSR 与水合;适用于大部分框架
缺点:在 React 框架中会有一些小问题
该方法是针对 React 18+ 设计的,之前的版本我也没研究过。(hydrateRoot
API 都是在 18 的时候才加的)
这个方法的思路是:
- 服务端中,在上文提到的包含 custom element 的中间表示 AST 基础上,新增的一个插件,调用
react-dom
的renderToString
将所有 custom element 转换成真实 React 组件渲染出的结果,这步也就是「手动 SSR」; - 序列化为 HTML 后,使用框架提供的方法直接插入 DOM(如 Astro 的
set:html
prop)。这步也是在服务端完成的; - 在客户端,对每个需要变成 React 组件的元素使用对应的 React 组件调用
hydrateRoot
,这样组件就变得有交互性啦。 性能方面,经我测试,对于不太复杂的组件(比如复制按键、GitHub Card 等),数百个 Root 并不会对性能有什么影响。考虑到 Astro 就是用类似的方法实现的 islands 架构且运行良好,这种方法在大多数情况下应该不会有性能问题。
思路实现
一些目前我在 Astro 中使用的示例代码(链接中都固定使用了编写本文时的最新 commit,这样可以保证行号不会因为之后更改而变动;如果 commit 消失了可以去 main 分支里找一下):
关键文件:yfi.moe/app/blog-astro/src/components/markdown/markdown-article.astro at 304eb8 · yy4382/yfi.moe
- 文件 25-48 行向我的 markdown 渲染管线中添加了新插件,用于将两个 custom element 转换成对应的 React 组件渲染出的 HTML fragment;
- 54 行直接将转换出的 HTML 插入
- 66-84 行会在客户端运行,找到所有需要转换成组件的元素,对每个元素调用
hydrateRoot
;同时别忘了在页面卸载时 unmount 它们。
缺陷
这种方法在 React 框架(比如 Next.js)中会有一些问题,我也还没有完全弄清原因;不过对于「在 React 创建维护的 DOM 中创建新的 React Root」这种奇怪用法,出现一些问题也在情理之中。
当时我在 Next.js 中的设置是这样的:
- 一个 server component,它负责生成 markdown 转换出的 HTML,将它 setHtml 进 jsx 里;
- 一个返回
null
的 client component,它只有一个useEffect
,effect 中对所有元素进行水合,并且返回一个 unmount 它们的清理函数。它是上文 server component 的子组件。 遇到了如下问题: - 由于 Next.js 编译器的人为限制,在服务器组建中不让导入
react-dom
的 API,而我需要它们来手动 SSR。最后通过动态导入骗过了编译器; - unmount 时会报错。目前怀疑是因为是在 Effect 中调用的,React 18 后的 concurrent rendering 可能不允许在 effect 中 unmount 掉 root,即使是完全无关的其他 root。
方法三:将 HAST 传到客户端
Note
优点:适用于所有地方,新增的包大小并不大;
缺点:还是需要新增一些需要网络传输的数据。
如果我使用的是 React 框架,并且它还不支持 RSC 怎么办?我们还有第三种方法。
react-markdown
很大,但我们在客户端并不需要它们全部:我们可以在服务端提前将 markdown 转换为 HTML 的 AST 表示 hast,然后将它发送到客户端;在客户端用不太大的 hast-util-to-jsx-runtime
库再将 hast 转换成 React JSX。
这样,我们只付出了需要多传输 hast-util-to-jsx-runtime
库和文章 hast 的代价,就获得了一个几乎可以在任何地方运行的方案。
为什么要多传输 hast 而非直接从完整的 HTML 里提取出需要的部分转换成 hast 再传给库?因为 HTML 转 hast 的库太大了……而 hast 由于与预渲染的 HTML 相似度较高,gzip 传输时增加的大小并不大。
Tip
HAST 默认在节点中附带位置 map 信息,记得把它们去掉,不然 hast 会很大。
这个方法本网站也曾经使用过,效果也还不错。
效果展示
复制按钮
每个代码块都会自动添加一个复制按钮。
function Hello() {
return <div>Hello, world!</div>;
}
GitHub 仓库信息卡片
::github-repo{user="yy4382" repo="yfi.moe"}
注
为什么不用 MDX?
主要是需求原因。我的需求目前只有复制按钮和 GitHub Card,未来即使增加也只会是一些通用的组件;MDX 可以导入任意组件的优势并不成立;我也不想因为这些小需求将我已有的 md 都转换成 mdx。
同时,MDX 应该被视作「代码源文件」而非普通的纯文本文件,这很不「可移植,portable」。MDX 实际上会被编译成 JavaScript 脚本,它有「依赖」,是文件路径敏感的(而我的 markdown 文件甚至是在另一个 GitHub repo 中,是需要构建时从网络上拉下来的);而普通 markdown 一般会被转换成 HTML fragment,这也是可以当作一个字符串到处传来传去的。有了这样的可移植性,如果未来想完全重写网站,只需要保留好现有的、组件无关的、路径无关的 markdown 转换管线,就可以轻松迁移。
Footnotes
-
截止本文成稿,Next.js 仍然是唯一在生产级别支持 RSC 的 Meta Framework。 ↩
本文使用“署名-非商业性使用-相同方式共享 4.0 国际(CC BY-NC-SA 4.0)”进行许可。
商业转载请联系站长获得授权,非商业转载请注明本文出处及文章链接。 如果您再混合、转换或者基于本作品进行创作,您必须基于相同的协议分发您贡献的作品。