Storybook args 实战:不改组件源码给组件传参的完整指南
Storybook args 是用一个普通的 JavaScript 对象动态驱动组件 props、插槽、样式与输入的统一机制,全程无需修改组件源码。下面按实战路径走一遍给组件传参写故事的完整流程:CSF 3 写下第一个故事、一张表对照八大框架差异、搞清三层作用域的合并优先级,再掌握 URL 覆盖、Controls 实时编辑与 useArgs 这些进阶技巧。
同一个模板你写了三遍,参数还得重新填一遍
假设你给 Button 组件写了 primary、secondary、disabled 三个故事,用纯模板写法意味着每个故事里都要手工重复一遍全部 props,按钮文案一改就得改三处。args 就是为这个问题设计的。
它的本质是一个普通 JS 对象:故事里写args: { primary: true, label: 'Button' },Storybook 会把这组值翻译成各框架对应的 props、@Input()或插槽输入,让组件按这组值渲染。更关键的一点:任一 arg 的值变化,组件就会重新渲染——这正是 Controls 等 addon 能在面板里"实时编辑组件"的底层原因。
截图左侧选中 Button 的 Primary 故事,预览区按 args 渲染出 primary 态按钮,底部 Controls 面板里 primary 开关、label 文本等参数一改就立即重新渲染。whats-a-story.mdx 对同一示例有完整的交互演示。
⏱️ 30 秒跑通第一个 args 故事
先以 React + TypeScript(CSF 3,当前主流写法)完整走一遍,后面看其他框架时只关心差异部分。以下是与组件源码同目录的Button.stories.ts,仅用于开发期、不会进生产构建:
import type { Meta, StoryObj } from '@storybook/react-vite'; // 换成你用的框架包,如 nextjs、nextjs-vite import { Button } from './Button'; // meta:描述"组件本身",通过默认导出交给 Storybook const meta = { component: Button, } satisfies Meta<typeof Button>; // 校验字段写对,同时保留 Button 的字面类型 export default meta; type Story = StoryObj<typeof meta>; // 从 meta 反推 args/render 的类型,获得自动补全 export const Primary: Story = { // args:描述"某一个状态",只对这个故事生效 args: { primary: true, label: 'Button', }, };故事文件的分工是两条线:meta(默认导出)描述组件本身——渲染哪个组件、侧边栏怎么组织、addon 怎么消费它;具名导出各是一个独立故事,其中args是 JSON 可序列化的对象(字符串键 + 合法值),描述"这个状态需要哪些参数、取值是什么"。
类型桥接为什么这么写?satisfies Meta<typeof Button>负责"校验不扩散":字段拼错直接标红,但不会把 meta 放宽成宽泛的Meta类型;接着StoryObj<typeof meta>拿 Button 的真实 props 给args、render做类型校验和补全——label 填成数字会立刻报错。写 JS 的话删掉两行类型桥接即可,运行时行为一致。
其他框架长什么样:一张表看懂差异
args 的结构在所有框架里始终一致,变的只有"component 指向什么"和"要不要自己写 render":
| 框架 | component 指向 | 需要 render | 类型导入包 |
|---|---|---|---|
| React | 组件引用 | 不需要(自动渲染) | @storybook/react-vite(或 nextjs 等) |
| Vue 3 | .vue组件 | 需要(用 v-bind 把 args 传给组件) | @storybook/vue3-vite |
| Angular | 组件类 | 不需要(直接绑定@Input) | @storybook/angular |
| Svelte | .svelte组件 | 不需要 | @storybook/svelte-vite/ sveltekit |
| Preact | 组件引用 | 需要(JSX 里展开) | @storybook/preact-vite |
| Solid | 组件引用 | 不需要 | storybook-solidjs-vite |
| HTML | 无框架运行时 | 需要(手写 DOM 节点) | @storybook/html |
| Web Components | 自定义元素名字符串 | 不需要 | @storybook/web-components-vite |
挑两段关键差异看。Vue 3 的故事文件(Button.stories.ts,JS 版去掉类型导入即可),render 里把 args 以v-bind一次性透传:
export const Primary: Story = { render: (args) => ({ components: { Button }, setup() { return { args }; }, template: '<Button v-bind="args" />', }), args: { primary: true, label: 'Button', }, };HTML 渲染器没有框架运行时,Button.stories.js里要手动把 args 组装成 DOM:
export const Primary = { render: (args) => { const btn = document.createElement('button'); btn.innerText = args.label; // 消费 args 的值 const mode = args.primary ? 'storybook-button--primary' : 'storybook-button--secondary'; btn.className = ['storybook-button', 'storybook-button--medium', mode].join(' '); return btn; }, args: { primary: true, label: 'Button', }, };只要在 render 里消费 args,Controls、URL 参数等一切依赖 args 的能力照常工作。Web Components 则是唯一component不指向模块的框架——它取自定义元素名(如component: 'demo-button');元素名没法参与类型推导,TS 版退化为宽泛的type Story = StoryObj,args 的约束交给组件自身的 attribute/property 定义。
Svelte 的额外选项:Svelte CSF
社区维护的@storybook/addon-svelte-csf提供了更贴近模板直觉的写法:defineMeta描述组件,Story组件以 props 形式接收name与args,和标准 CSF 3 二选一:
<script module> import { defineMeta } from '@storybook/addon-svelte-csf'; import Button from './Button.svelte'; const { Story } = defineMeta({ component: Button, }); </script> <Story name="Primary" args={{ primary: true, label: 'Button' }} />一个限制要记住:Svelte CSF 下不能用args传插槽内容(children),内容要写在<Story>开闭标签之间、作为childrensnippet prop 传入;若改用渲染完全由 children 决定的asChild形式,Controls 这类依赖 args 的能力就不可用了。
🧩 CSF Next:preview.meta() 改掉了哪三点
带 🧪 实验标记的 CSF Next 把"默认导出 + 具名导出"的隐式约定改写成了链式 API,想提前尝鲜新 API 就看这里:
import preview from '../.storybook/preview'; // meta 来自 preview 模块,不再是本地对象 import { Button } from './Button'; const meta = preview.meta({ component: Button, // 类型由组件自动反推 }); export const Primary = meta.story({ args: { primary: true, label: 'Button', }, });与 CSF 3 的差异就三点:
- meta 不再本地书写,而是从
.storybook/preview导入preview后,用preview.meta()显式创建; - 故事不再是"具名导出的普通对象",而是经
meta.story()链式创建; - 复用取参的路径变了:原来
...Primary.args要写成...Primary.input.args,因为 CSF Next 里故事是带input字段的函数对象。
类型方面,preview.meta()会从组件直接反推出 meta 的具体类型,satisfies/StoryObj这层手写桥接不再需要。
args 从哪来:三层作用域与优先级
同一个键出现在多处时,到底谁说了算?args 可以在三个层级定义,三层都是普通 JS 对象:
| 层级 | 定义位置 | 作用范围 |
|---|---|---|
| Global args | preview.*的默认导出 | 每个组件的所有故事 |
| Component args | CSF 默认导出的args键(Svelte CSF 里是defineMeta的属性) | 当前组件的所有故事 |
| Story args | 故事对象的args键 | 仅当前故事 |
优先级一条线:global → component → story,后写的覆盖先写的,故事级最高。
源码 prepareStory.ts 里能直接看到证据,故事准备阶段按这个顺序展开合并:
const passedArgs: Args = { ...projectAnnotations.args, // global ...componentAnnotations.args, // component ...storyAnnotations?.args, // story(优先级最高) } as Args;合并后的initialArgs还会流经 argsEnhancers 流水线(例如从 argTypes 推导默认值),整个加工都发生在故事"准备"阶段,与组件自身的 props 声明完全解耦——这也印证了 args 不改组件源码的设定。
两条实操建议:大多数故事共享的 args 应上提到 component args;"全局统一设置"(比如主题切换)场景更适合用 globals 而不是 global args,因为 globals 能挂在工具栏菜单里让用户直接切换取值。
复用与组合:args 别复制着写
写完第一个故事后马上会遇到重复问题,有三招由轻到重。
对象展开:args 就是普通对象,ES2015 展开即可复用,这是最轻的一招:
export const Secondary: Story = { args: { ...Primary.args, // 继承 Primary 的全部参数 primary: false, // 只覆盖一个 }, };上提到 component args:当同一组件的大部分故事都在复用同一组 args,就别在每个故事里展开,直接写进 meta 的默认导出——比如把primary: true放进 component args,所有 Button 故事默认变 primary,单个故事仍可覆盖。
复合组件:当组件由多个子组件拼装而成、故事参数原样透传给子组件时,可以导入子组件的故事、直接组合它们的 args。例如 Page 的"已登录"故事直接复用 Header 对应故事的参数:
// 导入 Header 的全部故事 import * as HeaderStories from './Header.stories'; export const LoggedIn: Story = { args: { ...HeaderStories.LoggedIn.args, // 组合参数 = 直接拼装子故事的 args }, };🔗 从面板到 URL:覆盖、mapping 与 useArgs
除了面板,args 还有三个"不打开故事文件也能操作"的入口:写进 URL、Controls 实时编辑、从组件内部驱动。
URL 覆盖怎么编码
URL 里的args恒为一组key: value对,用分号分隔,典型 Controls 链接:
?path=/story/avatar--default&args=style:rounded;size:100特殊值按下表编码:
| 场景 | 编码规则 | 示例 |
|---|---|---|
| 对象、数组 | 直接嵌套 | args=obj.key:val;arr[0]:one;arr[1]:two |
| null / undefined | !前缀 | args=nil:!null |
| 日期 | !date(value),值为 ISO 日期串 | args=birthday:!date(1990-01-01) |
| 颜色 | !hex/!rgba/!hsla,rgb(a)/hsl(a) 不能含空格与百分号 | args=color:!hex(f0f) |
出于 XSS 防护,URL args 的键值只允许字母数字、空格、下划线与连字符,其余类型会被忽略并从 URL 移除——但仍可通过 Controls 面板或故事内部使用。URL 中写出的 args 会扩展并覆盖故事上默认的 args。
JSX 这类没法序列化的值怎么办
JSX 元素这类复杂值无法序列化到 manager(Controls 面板)或同步到 URL,解法是argTypes里的mapping:用简单字符串"映射"到复杂类型,搭配select控件最合理:
const meta = { component: Example, argTypes: { label: { control: { type: 'select' }, options: ['Normal', 'Bold', 'Italic'], mapping: { Bold: <b>Bold</b>, // 键对应 arg 的值,不是 options 的下标 Italic: <i>Italic</i>, }, }, }, } satisfies Meta<typeof Example>;mapping不必穷尽:当前值不在 mapping 键里时直接使用原值。
useArgs:把组件交互反写进面板
args 写进故事后,两个面板自动到位:组件的回调会记录进Actions面板(点一下就能看到事件参数);组件的参数会出现在Controls面板,可实时编辑并即时触发重渲染。反过来——想让组件内部交互驱动 args(比如复选框被点击后,Controls 里的开关状态跟着翻转)——在 render 里用storybook/preview-api导出的useArgs:
import { useArgs } from 'storybook/preview-api'; export const Example: Story = { args: { isChecked: false, label: 'Try Me!', }, render: function Render(args) { // 取当前 args 值与更新函数 const [{ isChecked }, updateArgs] = useArgs(); function onChange() { updateArgs({ isChecked: !isChecked }); // 把交互结果反写回 args } return <Checkbox {...args} onChange={onChange} isChecked={isChecked} />; }, };⚠️ 官方明确警告:在 render 函数里用了 Storybook 的 hooks API,就不要再混用 React 的useState/useEffect/useRef——React hooks 引发的副作用与重渲染不经过 Storybook 的 hook 上下文,二次渲染会直接报错。状态与副作用请统一改用storybook/preview-api提供的同名等价 hooks。
接下来读什么
仓库里有三个入口,按"概念 → 详解 → 源码"的顺序:
- whats-a-story.mdx:认识"故事是什么"的第一篇,同一 Button 示例的入门演示;
- args.mdx:三层作用域、组合、URL 覆盖、mapping 与 useArgs 的权威出处,写作规范细节可在 index.mdx 中补齐;
- prepareStory.ts:
prepareStory把"故事 + 全部装饰器 + 参数"打包成可重复调用的无状态渲染函数,想深挖合并逻辑从这一处入手。
最后把贯穿全程的心智模型收成一句:写故事 = 一组 args + 一个渲染目标。三层作用域、面板、URL,都不过是这组 args 的不同书写入口。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考