Storybook — 给 UI 组件的独立工作台
已复核Storybook 是一个把单个 UI 组件从完整应用里拆出来开发和验收的工作台。日常类比:装修样板间——不必从大门走完整套房才看一把椅子,而是单独搭一间白底房间,把椅子摆中间。
你写一份 CSF 文件:
const meta = { component: Button, title: 'Button' }export default meta
export const Primary = { args: { label: 'Click', primary: true } }export const Disabled = { args: { label: 'Click', disabled: true } }跑 Storybook 后,浏览器里是 Manager(侧栏/工具条)+ Preview(iframe.html 里的组件)。每个 named export 是侧栏一项。固定 10.5.10 不要求自造 DSL:processCSFFile 读的就是 ES Module 的 default + named export。
不理解固定版本的隔离合同,下面这些事都解释不通:
- 为什么 Controls 改 args 不必和组件 DOM 处在同一个 window
- 为什么同一份
play既能在 Preview 里自动跑,也能被@storybook/addon-vitest收成 Vitest 用例 - 为什么
play里用了mount却没从参数解构它,会直接抛错 - 为什么
storybook/test的canvas其实是 testing-library 的within(canvasElement)
固定 10.5.10 可以拆成四段:
-
双 window:Manager 在顶层窗口跑自己的 UI;Preview 默认挂在
iframe.html。Preview.tsx的baseUrl就是这个地址。组件 CSS、运行时和用户框架都停在 iframe 里。 -
通道:
createBrowserChannel默认挂PostMessageTransport;开发模式再加WebsocketTransport。消息用telejson序列化,信封是{ key: 'storybook-channel', event, refId },默认maxDepth: 25。Manager 按iframe[data-is-storybook][data-is-loaded]找目标窗,本地预览框 id 是#storybook-preview-iframe。 -
CSF → 可渲染 story:
processCSFFile走两条路——CSF3 的 default meta + named export,或 CSF factory(definePreview/isStory)。isExportStory会丢掉__esModule,并执行includeStories/excludeStories。prepareStory叠 loaders、beforeEach、decorators、play和mount。 -
play 相位:
StoryRender在autoplay && forceRemount时跑playFunction。默认先mount()(renderToCanvas),再进入playing。若play.toString()显示它解构了mount,渲染会推迟到 play 自己调用mount;未解构却再调context.mount,抛MountMustBeDestructuredError。
案例 1:CSF3 一份文件两种状态
Section titled “案例 1:CSF3 一份文件两种状态”const meta = { component: Button, title: 'Button' }export default meta
export const Primary = { args: { label: 'Click me', primary: true } }export const Disabled = { args: { label: 'No', disabled: true } }default 是组件级 meta,named export 是 story。没有自定义语法,TypeScript / lint 按普通模块工作。侧栏标题来自 export 名(storyNameFromExport 会转成可读形式)。
案例 2:play 用 canvas 查询,而不是 screen
Section titled “案例 2:play 用 canvas 查询,而不是 screen”import { expect } from 'storybook/test'
export const Clicked = { args: { label: 'Click' }, play: async ({ canvas, userEvent }) => { await userEvent.click(canvas.getByRole('button')) await expect(canvas.getByText('Clicked!')).toBeInTheDocument() },}storybook/test 的 enhanceContext loader 把 canvas 设成 within(canvasElement),并在存在 navigator.clipboard 时执行 userEvent.setup()。docs 模式里用全局 screen 会跨多个 story 找节点,源码对此有显式警告。
案例 3:play 里自挂 mount 必须解构
Section titled “案例 3:play 里自挂 mount 必须解构”export const DeferredMount = { play: async ({ mount, canvas }) => { await mount() await canvas.findByRole('button') },}mountDestructured 用函数源码判断参数里有没有 mount。解构了才会把 playing 相位推迟到 mount() 之后;没解构时,运行时会把 context.mount 换成抛错函数。
-
把通道想成只有 postMessage:开发模式还会挂 websocket。只拦 iframe message 时,热更新和部分服务同步仍可能走另一条运输。
-
play 里调用
mount却写成play: async (ctx) => ctx.mount():源码看的是解构列表,不是“函数体里有没有调用”。这样会触发MountMustBeDestructuredError。 -
在 docs 页用
screen:同一页会挂多个 canvas。storybook/test会警告改用 story context 的canvas。 -
以为 CSF factory 取代了 CSF3:
processCSFFile先找 factory story,找不到再走 default export。两种写法并存。 -
把 addon-vitest 当成核心包:Vitest 集成在
@storybook/addon-vitest,peer 是vitest/@vitest/runner^3 || ^4。核心包只提供 CSF、通道和storybook/test。
适用 vs 不适用场景
Section titled “适用 vs 不适用场景”适用:
- 需要把组件状态从整站路由/数据里拆出来给多人看
- 已有或准备写
play,并希望同一份故事进 Vitest(@storybook/addon-vitest) - 满足文档声明的 Node.js 20.19+ 或 22.12+(
require(esm))
不适用:
- 只要一个 Vite playground、不要 Manager / iframe / 通道——隔离成本不值得
- 以 RSC 服务端树为主、客户端 canvas 还不稳的项目——Preview 合同仍以客户端挂载为前提
- 把视觉回归托管服务当成开源核心——Chromatic 是互补产品,本仓库没有独立页面,本文未读它的源码
固定版本边界
Section titled “固定版本边界”- 本文绑定
storybookjs/storybook@a2db7526...,tag 与 npm 包均为10.5.10。 - 源码树里
code/core/package.json的gitHead不是合法 SHA,以 tag / npmgitHead为准。 - 核心运行时依赖
@testing-library/dom ^10.4.1与@testing-library/user-event ^14.6.1;storybook/test再导出它们。 MIGRATION.md要求 Node.js 20.19+ 或 22.12+。本文未安装依赖、未跑上游测试或视觉回归,状态保持UNVERIFIED。
- 物理隔离换来框架无关——Manager 和 Preview 不共享 DOM,组件才能带自己的运行时;代价是跨 frame 调试和消息序列化
- 一份 ESM 多种消费——同一 named export 被侧栏、docs、play 和 Vitest plugin 复用
- 相位必须写进 API——
mount能不能推迟渲染,取决于 play 的参数解构,而不是注释或文档习惯 - 测试查询应限定画布——
canvas = within(canvasElement)比全局screen更符合 iframe / docs 多实例现实
- 开发模式下,Manager 和 Preview 之间是否只有
postMessage一条运输? play: async (ctx) => { await ctx.mount() }在固定 10.5.10 会怎样?storybook/test的canvas是什么对象?
检查点:
- 不是。
createBrowserChannel在CONFIG_TYPE === 'DEVELOPMENT'时还会加WebsocketTransport。 - 会抛
MountMustBeDestructuredError。mountDestructured只认参数解构。 within(context.canvasElement),由enhanceContextloader 写入。
- 官方文档:storybook.js.org
- 固定源码:storybookjs/storybook —— 本文绑定提交
a2db7526e1538a48bfa0529a881822e8074b2009 - testing-library —— 核心
storybook/test直接包装的查询层 - CSF 与 Vitest addon 说明见官方 Writing tests
- testing-library ——
canvas/within/getByRole的实现来源 - vitest ——
@storybook/addon-vitest的 runner peer,本页未绑定其源码 - playwright —— addon-vitest 可选的
@vitest/browser-playwright浏览器后端 - shadcn-ui —— 常用 Storybook 展示组件状态的设计系统代表
- radix-ui —— 同样用 stories 展示 headless 状态
- apexcharts —— ApexCharts — 自带响应式与注解的 SVG 图表库
- echarts —— Apache ECharts — 给一个 JSON 就能画图的可视化库
- fabric-js —— Fabric.js — 给 Canvas 加一层”对象模型”,让画布图形可以拖
- ink —— ink — 用 React 组件树写终端 CLI
- jest —— Jest — 一个包就能跑 JS 测试的全家桶
- konva —— Konva — 给 HTML5 Canvas 装一棵会响应的节点树
- msw —— MSW — 让 mock 不改业务代码,在网络层透明拦截
- radix-ui —— Radix UI — unstyled accessible 的 React 组件原语库
- testing-library —— Testing Library — 像用户一样测前端,重构不再挂测试