Storybook play 函数组合:用可复用的故事构建完整的组件交互工作流
在 Storybook 的测试体系中,play 函数是故事渲染完成后执行的交互脚本。而「组合(Composing stories)」机制允许你直接在一个故事的 play 函数中调用其他已导出故事的 play 函数,把多个单步交互串联成完整的工作流验证——本文基于仓库中 play function 组合示例 及其宿主文档 Play function 的「Composing stories」章节展开,并结合 Storybook 核心渲染层源码(code/core/src/csf/csf-factories.ts、code/core/src/preview-api/modules/preview-web/render/StoryRender.ts)说明这一机制的底层实现原理,读完后可掌握如何以最少样板代码复现、验证多步骤用户流程。
什么是 play 函数组合
play function 官方文档 中「Composing stories」一节的原文说明:
得益于 Component Story Format(一种基于 ES6 模块的文件格式),你可以像组合 Storybook 的其他特性(例如 args)一样,组合你的
play函数。例如,如果你想验证组件的某个特定工作流,可以编写如下故事:
其核心价值在于两点:
- 重建完整工作流:通过组合多个故事的
play函数,你复现了组件的整个使用流程,可以在早期发现潜在问题; - 减少样板代码:每个故事的
play只需关注自己那一步交互,组合故事直接复用已有逻辑,无需复制粘贴。
组合故事的完整代码示例
以下代码继承自仓库中的 组合示例文档,展示了「两个独立交互故事 + 一个组合故事」的典型写法(TypeScript / CSF 3 通用框架版本):
// 将 your-framework 替换为你实际使用的框架,如 react-vite、nextjs、vue3-vite 等
import type { Meta, StoryObj } from '@storybook/your-framework';
import { MyComponent } from './MyComponent';
const meta = {
component: MyComponent,
} satisfies Meta<typeof MyComponent>;
export default meta;
type Story = StoryObj<typeof meta>;
/*
* 关于使用 canvas 查询 DOM 的更多信息,
* 见官方文档 Play function 的 "Working with the canvas" 章节
*/
export const FirstStory: Story = {
play: async ({ canvas, userEvent }) => {
await userEvent.type(canvas.getByTestId('an-element'), 'example-value');
},
};
export const SecondStory: Story = {
play: async ({ canvas, userEvent }) => {
await userEvent.type(canvas.getByTestId('other-element'), 'another value');
},
};
export const CombinedStories: Story = {
play: async ({ context, canvas, userEvent }) => {
// 在本故事的 play 函数执行之前,先依次运行 FirstStory 和 SecondStory 的 play 函数
await FirstStory.play(context);
await SecondStory.play(context);
await userEvent.type(canvas.getByTestId('another-element'), 'random value');
},
};
示例中的关键要点:
canvas对象:play函数上下文的一部分,允许你查询已渲染故事的 DOM。它提供的是 Testing Library 查询的作用域版本,查询默认从组件根元素开始,用法与在普通测试中使用一致;userEvent:用于模拟真实用户交互(type、click等),所有交互都应await以确保步骤按序完成;await FirstStory.play(context):组合的核心——直接把另一个已导出故事当作可调用对象使用,将当前故事的context传给对方的play函数,使其在同一个已渲染画布上继续执行交互。
各框架/渲染器的适配写法
组合示例文档 同时覆盖了多种渲染器的等价写法,核心逻辑(FirstStory.play(context) + SecondStory.play(context))完全一致,差异只在故事的定义形式:
CSF Next(实验性,preview.meta / meta.story 工厂写法)
仓库中 Angular、React、Vue、Web Components 的 CSF Next 示例均采用如下形式(以 Angular 为例):
import preview from '../.storybook/preview';
import { MyComponent } from './my-component.component';
const meta = preview.meta({
component: MyComponent,
});
export const FirstStory = meta.story({
play: async ({ canvas, userEvent }) => {
await userEvent.type(canvas.getByTestId('an-element'), 'example-value');
},
});
export const SecondStory = meta.story({
play: async ({ canvas, userEvent }) => {
await userEvent.type(canvas.getByTestId('other-element'), 'another value');
},
});
export const CombinedStories = meta.story({
play: async ({ context, canvas, userEvent }) => {
// 先运行 FirstStory 与 SecondStory 的 play 函数,再执行本故事自己的交互
await FirstStory.play(context);
await SecondStory.play(context);
await userEvent.type(canvas.getByTestId('another-element'), 'random value');
},
});
CSF Next 中 meta.story() 返回的同样是具备 play 访问器的故事对象,因此组合调用方式与 CSF 3 保持一致。
Web Components
Web Components 的 meta 中 component 使用标签名字符串(而非组件引用),组合逻辑不变:
export default {
component: 'demo-my-component',
};
export const FirstStory = {
play: async ({ canvas, userEvent }) => {
await userEvent.type(canvas.getByTestId('an-element'), 'example-value');
},
};
export const SecondStory = {
play: async ({ canvas, userEvent }) => {
await userEvent.type(canvas.getByTestId('other-element'), 'another value');
},
};
export const CombinedStories = {
play: async ({ context, canvas, userEvent }) => {
// 先运行 FirstStory 与 SecondStory 的 play 函数
await FirstStory.play(context);
await SecondStory.play(context);
await userEvent.type(canvas.getByTestId('another-element'), 'random value');
},
};
源码视角:story.play(context) 为什么能这样用
从源码结构看,FirstStory.play 之所以能作为一个可调用的函数属性被直接 await,根源在于 CSF 工厂层对 play 的 getter 设计。在 csf-factories.ts 中,defineStory 返回的故事对象上定义了一个 play 的 getter:
get play() {
return input.play ?? meta.input?.play ?? (async () => {});
},
这带来三个实现事实:
- 优先取故事自身的
play:input.play(即你在该故事导出中定义的play函数)优先; - 回退到 meta 级
play:若故事未定义play,则回退到组件级(meta)标注中的play; - 兜底为空异步函数:两者都没有时返回一个
async () => {},因此组合调用一个没有play的故事不会报错——它只是安静地跳过。
这意味着在组合故事中调用 FirstStory.play(context) 时,你拿到的正是 FirstStory 定义里那个接收 StoryContext 的异步函数,context 参数(包含 canvas、userEvent、step、canvasElement 等)由当前渲染流程提供,交互会作用在同一个已挂载的画布上。
play 函数的执行时机:render 流程中的 "playing" 阶段
play 函数并非任意时刻执行,而是在预览层渲染状态机的特定阶段被触发。StoryRender.ts 中可以看到完整的执行顺序:
// 先挂载故事
if (!mounted && !isMountDestructured) {
await context.mount();
}
// ...
// 若启用自动播放且需要重新挂载,进入 playing 阶段执行 play 函数
if (this.renderOptions.autoplay && forceRemount && playFunction && this.phase !== 'errored') {
// ...
if (!isMountDestructured) {
context.mount = async () => {
throw new MountMustBeDestructuredError({ playFunction: playFunction.toString() });
};
await this.runPhase(abortSignal, 'playing', async () => playFunction(context));
} else {
// 当 play 函数中使用了 mount 时,playing 阶段会在 mount 调用后才开始
await playFunction(context);
}
// ...
if (!mounted) {
throw new NoStoryMountedError();
}
}
由此可确认以下行为细节:
- 渲染先行:
play在故事挂载(context.mount())完成之后才执行,这保证了组合故事中canvas.getByTestId(...)查询的一定是已渲染的 DOM; - phase 状态机:play 在
playing阶段运行,执行完成后进入played;抛出异常则进入errored阶段,并通过 channel 发出PLAY_FUNCTION_THREW_EXCEPTION事件(见 core-events/index.ts)——所以组合故事中任何一个被调用的play抛错,都会让整条工作流标记为失败; mount解构的特殊约束:如果play函数中显式解构了mount(用于在 play 过程中重新挂载),渲染层要求必须解构使用,否则会抛出MountMustBeDestructuredError(辅助判断逻辑见 mount-utils.ts)。组合场景中通常沿用已挂载的画布,不涉及该分支,但自定义play结构时需留意。
编写组合故事时的实践要点
结合文档与源码,编写组合 play 故事时建议遵循:
- 保持被组合故事的独立性:
FirstStory、SecondStory应该各自可单独运行、单独断言。组合故事只是把它们串起来,避免在单个故事的play里埋入只有组合场景才成立的耦合逻辑; await顺序即执行顺序:await FirstStory.play(context)→await SecondStory.play(context)→ 自己的交互,严格串行。若某一步交互会触发异步状态更新,建议在其后补充await expect(...)断言或显式等待,确保下一步查询到的是更新后的 DOM;- 利用
context.step分组步骤:上下文中除canvas、userEvent外还有step(label, play)方法(见 StoryRender.ts 中step: (label, play) => runStep(label, play, context)的定义),可以用它为工作流的每个阶段打标签,在 Interactions 面板中呈现清晰的分步流程; - 断言仍由测试层负责:仓库中 交互测试文档 与 play function 文档 的定位一致——
play负责驱动交互,test()(CSF Next 中由 csf-factories.ts 的test方法实现,内部同样是先执行this.play?.(context)再执行测试函数)负责断言; - 组合调用不会重新渲染故事:被调用的
FirstStory.play(context)不会触发FirstStory自身的渲染流程,它只是复用当前画布执行交互脚本。这正是「组合重建工作流」能以极低成本成立的原因。
参考文档与源码路径
- 组合示例片段(本文主体来源):docs/_snippets/play-function-composition.md
- 宿主文档(Play function 全文,含 canvas / screen 用法):docs/writing-stories/play-function.mdx
- canvas 查询示例:docs/_snippets/play-function-with-canvas.md
- CSF 3 语法参考:docs/api/csf/index.mdx
- 交互测试 API 与事件说明:docs/writing-tests/interaction-testing.mdx
playgetter 实现:code/core/src/csf/csf-factories.ts- play 执行阶段与挂载约束:code/core/src/preview-api/modules/preview-web/render/StoryRender.ts
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 StartedRust0627
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