Storybook:用 Markdown 块与 ?raw 原始导入在 Docs 页面渲染 .md 文件
在 Storybook 的 Docs 页面(auto-docs 或 MDX 文档)中,经常会想把项目里的 README、CHANGELOG 等纯 .md 文件原样嵌入展示,而不是把内容复制到 MDX 里手动维护。本文以官方仓库中 @storybook/addon-docs 自带的示例文件 Markdown-content.md 为骨架,完整讲解 ?raw 导入的用法与原理、<Markdown> 块的 props 与底层实现(基于 markdown-to-jsx 的代码块/链接/标题覆盖渲染),以及为什么不能把 .md 文件直接导入 MDX 渲染,帮助你在自己的项目中正确复用这一文档能力。
示例文件本体:Markdown-content.md 与它的角色
先看 Markdown-content.md 的完整内容(全文仅十余行):
# This is an `.md` file
it has been imported using `import content from './Markdown-content.md?raw'`
Notice the `?raw` at the end above, it is necessary to work.
A full example:
```md
import { Markdown } from '@storybook/addon-docs/blocks';
import content from './Markdown-content.md?raw';
<Markdown>{content}</Markdown>
这个文件本身是一个“自指”示例:它的内容就是演示如何导入它自己。它在仓库中并非孤立存在,而是被 `@storybook/addon-docs` 官方的 Story 引用——[Markdown.stories.tsx](https://gitcode.com/GitHub_Trending/st/storybook/blob/e8044dbefad93f4fd48a5a378693428ab0e5dbc8/code/addons/docs/src/blocks/blocks/Markdown.stories.tsx?utm_source=gitcode_repo_files#L3) 第一行 import 就是:
```ts
import mdContent from '../examples/Markdown-content.md?raw';
并用于 ImportedMDFile 这个故事:
/**
* The Markdown component won't know the difference between getting a raw string and something
* imported from a .md file. So this story doesn't actually test the component, but rather the
* import at the top of the CSF file
*/
export const ImportedMDFile = {
name: 'Imported .md file',
args: { children: mdContent },
};
注意这段源码注释(Markdown.stories.tsx)点明了一个关键事实:<Markdown> 组件本身并不关心 children 是“手写字符串”还是“从 .md 文件导入的字符串”,真正需要验证的是 CSF 文件顶部的 ?raw 导入方式是否工作正常。也就是说,“能导入”和“能渲染”是两个环节,前者依赖打包器的 ?raw 支持,后者依赖 Markdown 块的解析实现。
核心用法:?raw 导入 + 块
继承示例文件给出的完整写法,在 MDX 文档页或 story 的 docs 中:
import { Markdown } from '@storybook/addon-docs/blocks';
import content from './Markdown-content.md?raw';
# A header
<Markdown>{content}</Markdown>
官方文档 doc-block-markdown.mdx 用 README 场景给出了 DO / DON'T 对照:
// DON'T do this, will error
import ReadMe from './README.md';
// DO this, will work
import ReadMe from './README.md?raw';
import { Markdown } from '@storybook/addon-docs/blocks';
<Markdown>{ReadMe}</Markdown>
?raw 后缀是整条链路能工作的关键。它把文件内容“原样”导入为一个字符串,而不经过编译求值。这一点在 Vite builder 的文档 vite.mdx 中有明确说明:
- import readme from './readme.md';
+ import readme from './readme.md?raw';
并且该文档指出 Storybook 的 Webpack builder 同样理解 ?raw,所以在 Vite 与 Webpack 之间迁移时这一写法可以通用;对于 Vite 本身不处理的其他文件类型,也可以同样追加 ?raw 导出字符串。
TypeScript 用户可能担心 *.md?raw 这种模块没有类型声明会报错。官方仓库内自带了模块声明作为佐证,见 typings.d.ts:
declare module '*.md?raw';
在实际项目中,Vite 的 client 类型(vite/client)已提供 *?raw 通配声明,因此通常无需自行声明;但在独立于 Vite 类型的工程配置中,可以参照上述声明补一条,保证类型检查通过。
除 MDX 文档页外,该模式也用于把 changelog 等文件嵌入自定义文档页,官方代码片段 storybook-custom-docs-markdown.md 展示了一个 Changelog.mdx 的完整页面写法:
import { Meta, Markdown } from '@storybook/addon-docs/blocks';
import Readme from '../../Changelog.md?raw';
<Meta title="Changelog" />
# Changelog
<Markdown>{Readme}</Markdown>
Markdown 块的 props 与实现细节
Markdown 块从 @storybook/addon-docs/blocks 导出(blocks 入口 中的 export * from './Markdown')。官方文档声明它接收两类 props:
children:string类型,提供待解析和展示的 markdown 字符串;options:透传给底层markdown-to-jsx库的选项。
从源码 Markdown.tsx 可以看到更完整的实现事实:
// mirror props from markdown-to-jsx
type MarkdownProps = typeof PureMarkdown extends React.ComponentType<infer Props> ? Props : never;
const MarkdownImpl = (props: MarkdownProps) => {
if (!props.children) {
return null;
}
if (typeof props.children !== 'string') {
throw new Error(/* 提示 children 必须是单个字符串…… */);
}
return (
<PureMarkdown
{...props}
options={{
forceBlock: true,
overrides: {
code: CodeOrSourceMdx,
a: AnchorMdx,
...HeadersMdx,
...props?.options?.overrides,
},
...props?.options,
}}
/>
);
};
这里有三个值得注意的实现细节:
children必须是字符串:如果传入的不是字符串,会直接抛出错误,错误信息本身还给出了正反示例——无效的写法是多行 JSX children,有效的写法是用模板字符串包裹({# Some heading ...})。这解释了为什么在 MDX 中写<Markdown>{content}</Markdown>时,content必须是一个?raw导入来的字符串,而不能把 markdown 文本直接作为块级子节点书写。forceBlock: true:强制以块级模式解析 markdown,保证多行内容的渲染行为符合预期。- props 的
options会覆盖默认行为:overrides的合并顺序是先内置覆盖(code/a/headers),再展开用户的props.options.overrides,最后展开props.options,即用户传入的选项具有最高优先级,可以对渲染行为做精细定制。
另外,MarkdownProps 类型通过 infer Props 直接从 markdown-to-jsx 的组件类型推断而来(源码注释写明这是“mirror props”模式),因此 Markdown 块实际支持的 props 面与 markdown-to-jsx 保持一致。
源码深潜:Markdown 内容如何被渲染
@storybook/addon-docs 的 package.json 依赖列表声明了渲染引擎:"markdown-to-jsx": "^7.7.2"。<Markdown> 块并非用 MDX2 二次编译 markdown,而是用 markdown-to-jsx 将字符串解析为 React 元素,并通过 overrides 替换了三类元素,对应源码 mdx.tsx:
代码:区分行内 code 与代码块。 CodeOrSourceMdx(mdx.tsx)的判断逻辑是:没有 className 且内容不含换行的,渲染为行内 <Code>;否则解析 className(形如 lang-jsx)取语言标识,渲染为完整的 <Source> 块——即带上语言标识、语法高亮与复制按钮的 docs 代码块,无语言标识时回退为 text。
链接:三种 href 三种处理。 AnchorMdx(mdx.tsx)按 href 分类处理:
- 空 href、
target="_blank"或http(s)://外链:原样渲染<a>; - 以
#开头的页内锚点:渲染AnchorInPage,点击时document.getElementById命中后通过context.channel.emit(NAVIGATE_URL, hash)在 Storybook 内部导航; - 其他相对路径(指向 Storybook 其他页面):拦截左键单击(Cmd/Ctrl/Shift/Alt + 点击及非左键保持浏览器默认行为),改走 manager 侧 iframe 的 base URL 导航,避免 preview iframe 的相对路径把链接解析错。
标题:自动生成可复制的锚点。 HeaderMdx / HeadersMdx(mdx.tsx)覆盖 h1~h6:如果标题带了 remark-slug 插件生成的 id,会渲染为带“复制标题 URL 到地址栏”按钮(LinkIcon)的标题组件;如果没有 id,则退化为普通标题元素,保证在没有 slug 插件时依然可用。
最后,整个实现被包在 withMdxComponentOverride('Markdown', MarkdownImpl) 中(Markdown.tsx),使 Markdown 块像其他 docs 块一样支持在文档上下文中做组件覆盖(参见 component-overrides.mdx)。
为什么不直接导入 .md 文件到 MDX 里渲染?
官方文档 doc-block-markdown.mdx 的 “Why not import markdown directly?” 一节解释了必须经过 <Markdown> 块(而非 {ReadMe} 直接插值)的原因,核心是纯 markdown 与 MDX2 存在细微但致命的语义差异:
- MDX2 更严格,会把 markdown 合法内容当作 JSX 表达式求值:
{ this is valid in a plain markdown file, but MDX2 will try to evaluate this as an expression }
- 类 JSX 标签会被 MDX2 当作组件:
<This is also valid, but MDX2 thinks this is a JSX component />
- MDX2 会把跨行文本包进
<p>等标签,导致渲染结构与纯 markdown 不一致:
<div>
Some text
</div>
在纯 markdown 中原样保留,在 MDX2 中会编译为 <div><p>Some text</p></div>。
这与官方 story 中的注释可以相互印证(Markdown.stories.tsx):
{ brackets, valid MD but invalid MDX - works here }
<Looks like a JSX tag/>
<!-- above is valid MD but invalid in markdown-to-jsx, so it will not be rendered -->
`<Looks like a JSX tag />`
The above is only visible because it is wrapped in backticks
即 { ... } 与类 JSX 标签在 MDX 编译期就会出问题,而经过 <Markdown> 块走 markdown-to-jsx 解析时,前者(作为 markdown 文本)能正常渲染,后者则不会被渲染成真实组件——这正是“字符串经由专用解析器渲染”与“字符串进入 MDX 编译管线”的行为差异。
适用前提与验证方法
- Builder 前提:
?raw需要打包器支持。Vite builder 原生支持(?raw即 Vite 的 import asset as string 能力),Webpack builder 同样理解?raw(见 vite.mdx 的说明),两种主流构建方案下该写法均可用; - 类型前提:确保 TS 能识别
*.md?raw模块(Vite 项目由vite/client类型提供,必要时参照 typings.d.ts 补充声明); - 运行时验证:导入后可断言
typeof content === 'string'。若渲染时报 “The Markdown block only accepts children as a single string”,说明 children 不是字符串——通常是忘记用模板字符串包裹,或导入时缺少?raw导致拿到的是模块对象; - 官方对照 story:仓库中 Markdown.stories.tsx 提供了三个 story 可直接参照——
Markdown(长文本文本 + 行内代码 + 代码块 + 各级标题 + 链接的全量覆盖用例)、Imported .md file(验证?raw导入环节)、Text(纯文本段落),可作为你自定义用法时的回归参照。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
