首页
/ Storybook play 函数组合:用可复用的故事构建完整的组件交互工作流

Storybook play 函数组合:用可复用的故事构建完整的组件交互工作流

2026-09-07 17:54:38作者:齐冠琰

在 Storybook 的测试体系中,play 函数是故事渲染完成后执行的交互脚本。而「组合(Composing stories)」机制允许你直接在一个故事的 play 函数中调用其他已导出故事的 play 函数,把多个单步交互串联成完整的工作流验证——本文基于仓库中 play function 组合示例 及其宿主文档 Play function 的「Composing stories」章节展开,并结合 Storybook 核心渲染层源码(code/core/src/csf/csf-factories.tscode/core/src/preview-api/modules/preview-web/render/StoryRender.ts)说明这一机制的底层实现原理,读完后可掌握如何以最少样板代码复现、验证多步骤用户流程。

什么是 play 函数组合

play function 官方文档 中「Composing stories」一节的原文说明:

得益于 Component Story Format(一种基于 ES6 模块的文件格式),你可以像组合 Storybook 的其他特性(例如 args)一样,组合你的 play 函数。例如,如果你想验证组件的某个特定工作流,可以编写如下故事:

其核心价值在于两点:

  1. 重建完整工作流:通过组合多个故事的 play 函数,你复现了组件的整个使用流程,可以在早期发现潜在问题;
  2. 减少样板代码:每个故事的 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:用于模拟真实用户交互(typeclick 等),所有交互都应 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 () => {});
},

这带来三个实现事实:

  1. 优先取故事自身的 playinput.play(即你在该故事导出中定义的 play 函数)优先;
  2. 回退到 meta 级 play:若故事未定义 play,则回退到组件级(meta)标注中的 play
  3. 兜底为空异步函数:两者都没有时返回一个 async () => {},因此组合调用一个没有 play 的故事不会报错——它只是安静地跳过。

这意味着在组合故事中调用 FirstStory.play(context) 时,你拿到的正是 FirstStory 定义里那个接收 StoryContext 的异步函数,context 参数(包含 canvasuserEventstepcanvasElement 等)由当前渲染流程提供,交互会作用在同一个已挂载的画布上。

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 故事时建议遵循:

  1. 保持被组合故事的独立性FirstStorySecondStory 应该各自可单独运行、单独断言。组合故事只是把它们串起来,避免在单个故事的 play 里埋入只有组合场景才成立的耦合逻辑;
  2. await 顺序即执行顺序await FirstStory.play(context)await SecondStory.play(context) → 自己的交互,严格串行。若某一步交互会触发异步状态更新,建议在其后补充 await expect(...) 断言或显式等待,确保下一步查询到的是更新后的 DOM;
  3. 利用 context.step 分组步骤:上下文中除 canvasuserEvent 外还有 step(label, play) 方法(见 StoryRender.tsstep: (label, play) => runStep(label, play, context) 的定义),可以用它为工作流的每个阶段打标签,在 Interactions 面板中呈现清晰的分步流程;
  4. 断言仍由测试层负责:仓库中 交互测试文档play function 文档 的定位一致——play 负责驱动交互,test()(CSF Next 中由 csf-factories.tstest 方法实现,内部同样是先执行 this.play?.(context) 再执行测试函数)负责断言;
  5. 组合调用不会重新渲染故事:被调用的 FirstStory.play(context) 不会触发 FirstStory 自身的渲染流程,它只是复用当前画布执行交互脚本。这正是「组合重建工作流」能以极低成本成立的原因。

参考文档与源码路径

登录后查看全文
热门项目推荐
相关项目推荐