首页
/ Storybook:用 Markdown 块与 ?raw 原始导入在 Docs 页面渲染 .md 文件

Storybook:用 Markdown 块与 ?raw 原始导入在 Docs 页面渲染 .md 文件

2026-09-06 12:57:22作者:申梦珏Efrain

在 Storybook 的 Docs 页面(auto-docs 或 MDX 文档)中,经常会想把项目里的 README、CHANGELOG 等纯 .md 文件原样嵌入展示,而不是把内容复制到 MDX 里手动维护。本文以官方仓库中 @storybook/addon-docs 自带的示例文件 Markdown-content.md 为骨架,完整讲解 ?raw 导入的用法与原理、<Markdown> 块的 props 与底层实现(基于 markdown-to-jsx 的代码块/链接/标题覆盖渲染),以及为什么不能把 .md 文件直接导入 MDX 渲染,帮助你在自己的项目中正确复用这一文档能力。

Markdown 块渲染 README 内容的效果截图

示例文件本体: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:

  • childrenstring 类型,提供待解析和展示的 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,
      }}
    />
  );
};

这里有三个值得注意的实现细节:

  1. children 必须是字符串:如果传入的不是字符串,会直接抛出错误,错误信息本身还给出了正反示例——无效的写法是多行 JSX children,有效的写法是用模板字符串包裹({ # Some heading ... })。这解释了为什么在 MDX 中写 <Markdown>{content}</Markdown> 时,content 必须是一个 ?raw 导入来的字符串,而不能把 markdown 文本直接作为块级子节点书写。
  2. forceBlock: true:强制以块级模式解析 markdown,保证多行内容的渲染行为符合预期。
  3. 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-docspackage.json 依赖列表声明了渲染引擎:"markdown-to-jsx": "^7.7.2"<Markdown> 块并非用 MDX2 二次编译 markdown,而是用 markdown-to-jsx 将字符串解析为 React 元素,并通过 overrides 替换了三类元素,对应源码 mdx.tsx

代码:区分行内 code 与代码块。 CodeOrSourceMdxmdx.tsx)的判断逻辑是:没有 className 且内容不含换行的,渲染为行内 <Code>;否则解析 className(形如 lang-jsx)取语言标识,渲染为完整的 <Source> 块——即带上语言标识、语法高亮与复制按钮的 docs 代码块,无语言标识时回退为 text

链接:三种 href 三种处理。 AnchorMdxmdx.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 / HeadersMdxmdx.tsx)覆盖 h1h6:如果标题带了 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 存在细微但致命的语义差异

  1. MDX2 更严格,会把 markdown 合法内容当作 JSX 表达式求值
{ this is valid in a plain markdown file, but MDX2 will try to evaluate this as an expression }
  1. 类 JSX 标签会被 MDX2 当作组件
<This is also valid, but MDX2 thinks this is a JSX component />
  1. 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(纯文本段落),可作为你自定义用法时的回归参照。
登录后查看全文
热门项目推荐
相关项目推荐