Storybook Monorepo 实战指南:共享 Preview、MSW 数据与可验证 Stories 的完整搭建规范
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
本文以 Storybook 官方仓库中的 Agent 评测任务书 scripts/eval/prompts/monorepo.md 为核心骨架,系统拆解在 Nx/Workspaces 类 monorepo 项目中从零让 Storybook「完全可用」的标准流程:先通过受控的 Glob/Grep/Read 探测运行时依赖,再一次性构建出足以覆盖绝大多数 story 的共享
.storybook/preview.tsx,接入 MSW 模拟数据、处理 portal 挂载点、批量编写带CssCheck校验的 stories,最后用@storybook/addon-vitest在浏览器环境中批量验证。读者读完将掌握一套可直接复制的「发现 → 基础设施 → 故事编写 → 批量验证」流水线,以及判断什么时候该写play、什么时候该修共享 preview 的工程决策准则。
一、这份文档是什么:monorepo 场景下的 Agent 任务规范
scripts/eval/prompts/monorepo.md不是一份面向普通用户的 Storybook 教程,而是 Storybook 官方仓库agent-eval评测体系中用于衡量 AI Agent(MCP 工具与 CLI 插件两种形态)能否独立完成「在 monorepo 项目中完整搭建 Storybook」能力的任务书。它规定了任务环境、交战规则、八步实施计划与明确的完成标准,其产出会经由 agent-eval/lib/experiment.ts 中的评测管线(如813-monorepo-leaf-create-component等核心场景)自动判定。
任务书对应的真实工程样板位于 agent-eval/templates/monorepo/,是一个标准的 npm workspaces monorepo:
packages/ ├── app/ # 消费方应用(@acme/app),依赖 @acme/ui │ ├── src/main.tsx │ └── index.html └── ui/ # 组件库(@acme/ui),Storybook 搭建的目标包 ├── src/ │ ├── components/Card.tsx │ └── index.ts ├── stories/Card.stories.tsx ├── package.json ├── tsconfig.json ├── vitest.config.ts └── vitest.storybook.config.ts其中@acme/app的入口 main.tsx 直接消费@acme/ui导出的Card组件,@acme/ui的 index.ts 负责统一导出——这正是任务书第 3 条「如果用到本地 monorepo 依赖,先构建所有发现的依赖」所针对的典型结构。@acme/ui的 package.json 中预置了@storybook/react-vite(next 版本)、@storybook/addon-vitest、@storybook/addon-a11y、@storybook/addon-docs等依赖以及test:stories脚本,说明该包就是 Storybook 要被「点亮」的叶子包。
任务环境速览
| Property | Value |
|---|---|
| Version | 10.4.0-alpha.10 |
| Renderer | @storybook/react |
| Framework | @storybook/react-vite |
| Builder | @storybook/builder-vite |
| Config Dir | .storybook |
| Language | TypeScript |
| Package Manager | unknown package manager(由项目 lockfile 检测) |
| Addons | @storybook/addon-onboarding, @storybook/addon-themes, @storybook/addon-docs, @storybook/addon-designs, @storybook/addon-vitest, @storybook/addon-a11y, storybook-addon-pseudo-states, @chromatic-com/storybook |
任务书的最终目标一句话概括:让 Storybook 在本项目中完全可用——正确配置.storybook/preview.tsx的 decorators、为数据接入 MSW、编写最多 10 个与源码同目录的*.stories.tsx文件,并且只在能证明非平凡行为的地方添加play函数。注意eval-template.json中amazonLinuxPackages: "playwright-chromium"的声明,说明该评测场景的验证环节依赖 Playwright 的 Chromium 浏览器环境。
二、交战规则(Rules of Engagement):把工具纪律当作时间预算
任务书开篇用「这些是时间预算,不是建议」来强调前九条交战规则。它们本质上是把「在陌生仓库中高效且不破坏性地完成任务」沉淀成可执行约束:
- 用 Glob/Grep/Read 工具发现,禁用 shell 探索。列目录用
Glob('src/components/*')(别名search_files、file_search),搜字符串用Grep('pattern', { path: 'src' })(别名grep_search、search_files),读文件用Read('path/to/file')(别名read_file),批量编辑用多次Edit或一次Edit+replace_all——而不是ls、find、cat、head、tail、shellgrep、sed、node -e。理由是这些 shell 命令单次调用更慢且破坏缓存。 - 绝不读或 grep
node_modules。任务书中给出的 import 路径是正确的,不要通过检查已安装包来验证;若感觉不对,重读任务书而不是去翻node_modules。 - Nx monorepo 局部优先。不要一开始就翻其他包里的配置或既有 Storybook 内容,从目标包本地的配置与工具链开始探索;若用到本地 monorepo 依赖,在写 stories 或跑测试前先构建全部发现的依赖。
- 读取预算约 12 个文件。写任何代码前最多读约 12 个文件(
index.html、入口、App、providers、路由、根 CSS、2–3 个代表性页面/组件、1–2 个 hooks、1 个测试);不够就概括后继续前进。 - Edit 优先于 Write。读过的文件用
Edit修改,Write只用于新文件;项目里已有storybook init生成的.storybook/preview.tsx,要Edit它而不是覆盖。 - 批量测试循环。先写完所有 stories,再统一跑一次 vitest;在首次批量运行暴露失败之前,不做单文件 vitest。
- 每次安装都使用
unknown package manager(从本项目 lockfile 检测出的包管理器)。 - 优先修共享的
.storybook/preview.tsx,当多个 stories 以同样方式失败时,不要在 story 局部打补丁。 - 达到成功标准就停,不要无休止打磨。
这套纪律对应到仓库中的验证设施:@acme/ui的 vitest.storybook.config.ts 通过@storybook/addon-vitest的storybookTest({ configDir: ... })插件定义storybook测试项目,并用@vitest/browser-playwright以 headless Chromium 实例运行——这就是规则 6「统一跑一次 vitest」所指的命令行npx vitest --project storybook run的底层配置来源。
三、Step 1 — 发现运行时(≤12 次读取)
第一步的目标是在预算内回答一个核心问题:"一个典型页面要渲染,preview 必须供应哪些 providers、CSS、浏览器状态和网络调用?"
按以下顺序,先用 Glob/Grep 再用针对性 Read:
index.html——<link rel="stylesheet">标签、内联<style>块、字体,以及非 JS 创建的<div id="...">挂载点或 portal 根节点;- 入口文件(
main.tsx/index.tsx)—— 包裹<App />的 providers、根 CSS import; App.tsx—— 顶层布局、路由使用、消费的 providers;- providers / context 文件 —— 它们暴露了什么;
- 根 CSS —— 全局样式、CSS 变量、主题 tokens(包括 JS import 的 CSS和
index.html里 link 的); - 数据 hooks ——
fetch(...)、useQuery、axios等(捕获渲染时实际调用的 base URL 与 endpoints); - 渲染时实际读取的浏览器状态 ——
localStorage/sessionStorage/ cookie 键名; - portal 目标 ——
createPortal(...)及其挂载的 DOM id(如#modal-root); - 1–2 个真实页面或功能组件(作为 story 的 JSX 模式真源)。
以评测模板为例:packages/app/index.html只有一个#root挂载点,main.tsx用StrictMode+createRoot渲染,且组件全部来自@acme/ui——这意味着本轮发现只需确认「没有自定义 providers、没有额外 CSS 文件、没有 portal、没有数据请求」,即可直接推进到 Step 2,而不是盲目堆砌基础设施。
四、Step 2 — 构建共享 Preview:一次性解决大多数 story 的准备工作
Step 2 的核心原则是:把 Storybook 一次性配置好,让绝大多数 story 无需逐文件设置。做法是Edit 已有的.storybook/preview.tsx(由storybook init创建),向既有 config 对象中合并新增内容,而不是整体替换。
完整的目标形态如下(将新片段合并进已有内容):
// .storybook/preview.tsx import type { Preview } from '@storybook/react-vite'; import '../src/index.css'; import MockDate from 'mockdate'; import { initialize, mswLoader } from 'msw-storybook-addon'; import { SessionProvider } from '../src/contexts/SessionContext'; import { mswHandlers } from './msw-handlers'; initialize({ onUnhandledRequest: 'bypass' }); const preview: Preview = { decorators: [ (Story) => ( <SessionProvider> <Story /> </SessionProvider> ), ], loaders: [mswLoader], parameters: { msw: { handlers: mswHandlers } }, async beforeEach() { localStorage.setItem('theme', 'dark'); MockDate.set('2024-04-01T12:00:00Z'); }, }; export default preview;Preview 的四条硬性规则:
- 使用真实的 provider 树与真实的根 CSS import,不要凭空发明 providers;
- 如果应用的 CSS 是通过
index.html中的<link>加载(而非 JS import),则从 preview 中 import 同一个文件,保证 story 渲染出相同的样式; - 只播种应用实际读取的浏览器状态键,不要清空全部
localStorage/sessionStorage/ cookies,也不要重置 Storybook 自身状态; mockdate仅在渲染输出依赖日期时使用;不要直接 mockwindow、document、navigator、observers 或fetch。
这四条规则与评测模板的组件实现相互印证:Card组件的样式全部内联在 JSX 中(见 Card.tsx),因此共享 preview 在此场景下无需导入额外 CSS;而模板的 vitest.storybook.config.ts 中setupFiles: ['.storybook/vitest.setup.ts']的存在,说明beforeEach这类全局生命周期钩子正是 Storybook 测试模式下被统一执行的地方。
五、Step 3 — Portals:写在 decorator 里,而不是 preview-body.html
如果在 Step 1 发现了createPortal(..., document.getElementById('foo'))的调用,在.storybook/preview.tsx中添加一个在 story 渲染前创建 portal 根节点的 decorator,不要使用preview-body.html:
// Add this entry to the `decorators` array of your preview config: (Story) => { for (const id of ['modal-root', 'drawer-root', 'toast-root']) { if (!document.getElementById(id)) { const el = document.createElement('div'); el.id = id; document.body.appendChild(el); } } return <Story />; }将该 decorator 加入 preview config 的decorators数组。若 portal 只指向document.body,则完全跳过此步。之所以用 decorator 而非preview-body.html,是因为 decorator 能确保每个 story 渲染前根节点必然存在,且逻辑与 preview 配置保持同源、可被 vitest 测试环境复用;而模板中的Card组件并不使用 portal,属于「跳过此步」的典型情况。
六、Step 4 — MSW handlers:只覆盖 stories 会命中的端点
使用msw-storybook-addon,安装命令为:
<your-package-manager> add -D msw msw-storybook-addon mockdate npx msw init ./public --savenpx msw init会在./public下生成 Service Worker 文件,因此需要确保.storybook/main.ts把./public作为静态目录提供服务:
// .storybook/main.ts import type { StorybookConfig } from '@storybook/react-vite'; const config: StorybookConfig = { staticDirs: ['../public'] }; export default config;handlers 放在.storybook/msw-handlers.ts,只覆盖你的 stories 会实际用到的端点,不写 catch-all:
// .storybook/msw-handlers.ts import { http, HttpResponse } from 'msw'; export const mswHandlers = { products: [ http.get('https://api.example.com/products', () => HttpResponse.json({ items: [{ id: 'p1', name: 'Example', price: 42 }] }) ), ], };与 Step 2 中 preview 的loaders: [mswLoader]与parameters.msw.handlers组合起来,就构成了「MSW 初始化 → 全局 handler 注入 → 每个 story 渲染前由 loader 激活 mock」的完整链路。initialize({ onUnhandledRequest: 'bypass' })表示未匹配的请求直接放行,避免无关网络调用干扰 story。
七、Step 5 — 批量编写最多 10 个 story 文件(含唯一的 CssCheck)
本步交付物是两样东西,缺一不可:① 最多 10 个与源码同目录的*.stories.tsx;② 恰好一个CssCheckstory(必须加入其中一个文件,见 Step 5b)。Step 5 未完成CssCheck就不算完成。
Step 5a — 挑选目标并编写文件
从真实代码库中挑选约 10 个有意义的目标(从底层可复用组件到页面组件)。跳过子组件、hooks、contexts、helpers,以及存在真实页面组件时的App本身。每个 story 文件:典型组件约 3 个 export,确有实际使用场景时可到约 10 个;JSX 模式从真实页面/路由/测试中复制。
每个新 story 文件从一开始就标记['ai-generated', 'needs-work'],只有在该文件通过 vitest 之后才移除'needs-work'——这样所有尚未验证的内容(包括来不及修复的 stories)都会保持正确标记:
import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect } from 'storybook/test'; import { Button } from './Button'; const meta = { component: Button, tags: ['ai-generated', 'needs-work'], // strip 'needs-work' once vitest passes } satisfies Meta<typeof Button>; export default meta; type Story = StoryObj<typeof meta>; // Smoke check — one is enough per file export const Primary: Story = { args: { children: 'Order now' }, play: async ({ canvas }) => { await expect(canvas.getByRole('button', { name: /order now/i })).toBeVisible(); }, }; // Variant-only stories: no play needed export const Clear: Story = { args: { children: 'Cancel', clear: true } }; export const Large: Story = { args: { children: 'Checkout', large: true } }; export const WithIcon: Story = { args: { icon: 'cart', 'aria-label': 'food cart' } };Story 编写规则:
- 每个 meta 都以
tags: ['ai-generated', 'needs-work']开头; - 显式写出所有 import;
- 不添加自定义
title(由 component 自动推导层级); - 不构建大型 story 专用 harness —— 优先修复 preview;
- 不创建新的应用组件。
评测模板中已有的 Card.stories.tsx 恰好是这一规范的反面示例——它带有title: 'UI/Card'且没有 tags,正是 Agent 需要按任务书「改造/重写」的对象。而Card组件源码中data-testid="card"与内联样式(border: '1px solid #e5e7eb'、padding: padded ? 16 : 0)则为CssCheck提供了真实的计算样式断言来源。
Step 5b — 添加唯一的 CssCheck story
在 Step 5 结束前,从刚写过的文件中挑一个视觉上独特的组件,为该文件添加CssCheckexport。整个项目恰好只有一个CssCheck,不是每文件一个。Step 5 直到它存在才算完成。
为什么它是强制项:toBeVisible对未加样式的组件也能通过。一个具体的getComputedStyle值才是「共享 preview 确实加载了应用 CSS」的唯一证明——否则你根本不知道 stories 是否真的渲染正确。
做法:从组件源码中读取一个真实样式值(如 styled-components 中的十六进制颜色、Tailwind 类bg-blue-600、主题中的 CSS 变量),并断言解析后的getComputedStyle值:
export const CssCheck: Story = { args: { children: 'Submit' }, play: async ({ canvas }) => { const button = canvas.getByRole('button', { name: /submit/i }); // PrimaryButton uses bg-blue-600 — fails if Tailwind / global CSS did not load. await expect(getComputedStyle(button).backgroundColor).toBe('rgb(37, 99, 235)'); }, };八、Step 6 — play 函数的取舍:只在能证明非平凡行为时添加
不要给每个 story 都加play。只有当play能断言「仅靠渲染输出本身无法证明」的内容时才值得写。宁可每文件一个好play,也不要五个冗余的。
值得写play的场景:
- 交互(表单填写 + 提交、点击 → 菜单展开、切换 tab 揭示面板);
- 异步数据确实从 MSW 到达(等待 mock 内容替换 spinner);
- portal 渲染进正确的根节点(通过
canvasElement.ownerDocument查询); - 具有语义意义的 CSS 驱动状态(如主题色、disabled 样式、能确认全局样式表已加载的布局);
- 组件负责的无障碍(正确的 role/label 暴露)。
完全跳过play的场景:story 只是同一组件的静态变体(不同args,无新行为)。在Clear、Large、WithIcon等上面重复getByRole(...).toBeVisible()是冗余的——组件抛出异常或无法挂载时渲染本身就会失败。
Smoke play 必须证明渲染本身证明不了的东西。只做await expect(canvas.getByRole('button')).toBeVisible()的 play 毫无价值。可接受的 smoke play 断言以下之一:
- 反映状态的 aria 属性(
aria-expanded、aria-disabled、aria-checked、aria-current); - 以文本或属性渲染的 prop 值(如
args.label出现在 DOM 中、href匹配args.to); - 异步内容到达(
findBy*、waitFor—— 证明 loader/MSW handler 真的解析了); - portal 挂载进正确的根(通过
canvasElement.ownerDocument.body查询)。
具体到包含Primary、Clear、Large、WithIcon的Button.stories.tsx:Primary保留一个 smokeplay(每文件一个就够);Clear、Large、WithIcon不加play。(项目唯一的CssCheck已在 Step 5 添加,此处不要再加。)
Imports 与 play 上下文——这里搞错会让 vitest 以微妙的方式失败:
expect和waitFor来自'storybook/test'—— 必须显式 import;canvas、userEvent、canvasElement来自play 参数:async ({ canvas, userEvent, canvasElement }) => { ... }。不要import { userEvent } from 'storybook/test',不要写const canvas = within(canvasElement)——两者都已提供;- 仅 portal 查询时,通过
canvasElement.ownerDocument.body查询,此时可以从'storybook/test'importwithin(如within(canvasElement.ownerDocument.body).findByTestId(...));其他场景不要用within。
export const FilledForm: Story = { play: async ({ canvas, userEvent }) => { await userEvent.type(canvas.getByLabelText('email'), 'a@b.com', { delay: 50 }); await userEvent.click(canvas.getByRole('button', { name: /submit/i })); await expect(await canvas.findByText(/welcome/i)).toBeVisible(); }, };九、Step 7 — 一次性批量验证,再按失败迭代
运行任何测试前先读这条规则:首次 vitest 调用必须一起运行所有新 stories,批量运行前禁止单文件运行:
npx vitest --project storybook run随后运行项目的 TypeScript 检查(使用package.json中的脚本,通常是tsc --noEmit或unknown package manager run typecheck)。直接读一次原始输出,不要反复用grep/head切片。
对每个失败的处理循环:
- 读错误;
- 若多个 stories 共享同一失败,修复共享的 preview 配置,而不是修 stories;
- 只对受影响文件重跑 vitest:
npx vitest --project storybook run path/to/Foo.stories.tsx; - 重复直到文件通过,然后继续下一个。每个文件重试上限约 2 次——仍然失败就保留
'needs-work'继续前进。
文件通过后,编辑其 meta 移除'needs-work',使 tags 变为['ai-generated'];修不好的文件保留['ai-generated', 'needs-work']——继续前进,不要无限循环。
该步骤在仓库中的执行载体是 vitest.storybook.config.ts:storybookTest({ configDir: path.join(dirname, '.storybook') })让 vitest 直接驱动 Storybook 渲染并执行play;browser: { enabled: true, headless: true, provider: playwright({}), instances: [{ browser: 'chromium' }] }表明这些 story 测试是在真实浏览器环境中运行的,这也解释了为什么任务书要求「不 mockwindow/document/navigator」——浏览器环境本身已真实存在。
十、Step 8 — 清理收尾
结束前,移除:调试代码、诊断期间添加的宽泛 mocks、未使用的依赖以及评测残留物(eval artifacts)。对应到规则 9「达到成功标准就停」——清理只是回到干净基线,不是继续打磨。
十一、完成标准(Done when)
任务书明确列出以下验收项,缺一不可:
- 恰好一个
CssCheckstory存在于新 stories 中,且断言的是从组件源码读取的某个具体计算样式值(在 Step 5 末尾添加); - 每个被 vitest 确认通过的 story 文件都已移除
'needs-work',tags 变为['ai-generated'];仍失败的保持['ai-generated', 'needs-work']; npx vitest --project storybook run对新文件通过;- 项目 TypeScript 检查对变更文件通过;
- 共享 preview 足够强,stories 无需逐文件的 fetch/provider 变通方案。
最后一条标准与规则 8 呼应:共享 preview 的强度是整套方案是否合格的终极判据——它意味着所有「每个 story 都要重复一次」的准备逻辑(provider、CSS、浏览器状态、MSW handler)都被正确收敛到了.storybook/preview.tsx这一处。
十二、仓库中的配套证据与延伸阅读
本文所述规范并非孤立的 prompt 文本,它在当前仓库中有完整的工程配套,可作为深入研究的入口:
- 评测模板本体:agent-eval/templates/monorepo/ 中的 workspaces 结构(
packages/app与packages/ui)、eval-template.json 的playwright-chromium环境声明; - 验证配置:vitest.storybook.config.ts 演示了
@storybook/addon-vitest+@vitest/browser-playwright的浏览器内 story 测试接线,这也是npx vitest --project storybook run得以工作的底层配置; - 组件与 story 真源:Card.tsx(内联样式 +
data-testid)与 Card.stories.tsx(带title的既有写法)——它们是 Step 5 改写练习的典型对象; - 评测编排:agent-eval/lib/experiment.ts 中的
813-monorepo-leaf-create-component等核心评测与 900s 超时配置,解释了 monorepo 场景在整体评测线中的位置; - Storybook 自身能力文档:仓库 docs/writing-stories/、docs/writing-tests/ 与 docs/api/ 目录提供了 decorators、play function 与 vitest 集成的完整用户文档,任务书中的
Reference小节指向的正是这些主题。
如需将本流程复用到自己的项目,可将「发现运行时 → 构建共享 preview → 处理 portal/MSW → 批量编写带验证的 stories → 统一跑vitest --project storybook」五段式作为模板,并把CssCheck思想移植过去:任何依赖全局样式的组件库,都应该至少有一个断言getComputedStyle具体值的 story,作为「共享基础设施真的生效了」的守门测试。
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考