首页
/ Storybook 生命周期钩子详解:在 .storybook/preview 配置 beforeEach 实现跨 Story 的状态重置

Storybook 生命周期钩子详解:在 .storybook/preview 配置 beforeEach 实现跨 Story 的状态重置

2026-09-07 11:55:54作者:咎岭娴Homer

导读

在 Storybook 的交互测试与组件测试体系中,多个 Story 复用同一份模块级状态(例如被 mock 的 Date、全局拦截器或共享单例)是导致测试相互污染、随机失败的最常见原因。Storybook 在预览配置文件 .storybook/preview.* 中提供异步 beforeEach 生命周期钩子,使开发者能在每一个 Story 渲染与 play 执行之前统一重置共享状态。本文以 before-each-in-preview 代码片段 为主体,结合 interaction-testing 文档 与其底层实现,完整讲解项目级 beforeEach 的写法、执行时机、清理函数语义与源码运行机制,帮助你在整个 Storybook 项目中建立可靠、可复现的测试前置条件。

一、beforeEach 生命周期钩子:在渲染每个 Story 之前执行

Storybook 提供了一组作用于 play function / 组件测试生命周期 的异步钩子:beforeAllbeforeEachafterEach,它们可以声明在三个不同层级:

  • 项目级:位于 .storybook/preview.*,作用于整个项目的所有 Story;
  • 组件级(meta 级):位于 .stories 文件的 meta 中,作用于该文件内的所有 Story;
  • Story 级:位于单个 Story 定义中,仅作用于该 Story。

其中的项目级 beforeEach 语义如下(见 interaction-testing.mdxSet up or reset state for all tests 一节):

Unlike beforeAll, which runs only once, the beforeEach function in the preview file (.storybook/preview.*) will run before each story in the project. This is best used for resetting state or modules that are used by all or most of your stories.

即:beforeAll 整个测试运行只执行一次;而 preview 文件中的 beforeEach 在项目中每一个 Story 渲染/测试前都会执行,最适用于重置那些被全部或大多数 Story 共享的状态或模块。一个典型场景就是重置被 mock 的 Date(示例中使用了 mockdate 库)。

二、在 .storybook/preview 中声明项目级 beforeEach:完整示例

1. CSF 3 语法(当前主流的 .storybook/preview.js / .storybook/preview.ts

JavaScript 写法(.storybook/preview.js):

import MockDate from 'mockdate';

export default {
  async beforeEach() {
    MockDate.reset();
  },
};

TypeScript 写法(.storybook/preview.ts):

// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc.
import type { Preview } from '@storybook/your-framework';

import MockDate from 'mockdate';

const preview: Preview = {
  async beforeEach() {
    MockDate.reset();
  },
};

export default preview;

要点说明:

  • beforeEach 必须声明为 async(异步)函数,Storybook 会 await 其完成后再继续渲染流程;
  • TS 项目中通过 Preview 类型获得完整的类型提示,需要将 @storybook/your-framework 替换为你实际使用的框架包名(如 react-vitenextjsvue3-vite 等);
  • 该示例的效果是:每个 Story 测试开始前强制将 mock 的 Date 复位,从而保证 mock 状态不会从一个 Story 泄漏到下一个。

2. CSF Next(🧪 实验性 definePreview 语法)变体

除了 CSF 3 的 export default 对象写法,before-each-in-preview.md 还针对 React、Vue 3、Angular、Web Components 分别给出了基于 definePreview 的实验性语法(文档中以 🧪 标注)。beforeEach 字段的语义在两种写法中完全一致。

React(.storybook/preview.tsx):

// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite)
import { definePreview } from '@storybook/your-framework';

import MockDate from 'mockdate';

export default definePreview({
  async beforeEach() {
    MockDate.reset();
  },
});

Vue 3(.storybook/preview.ts):

import { definePreview } from '@storybook/vue3-vite';

import MockDate from 'mockdate';

export default definePreview({
  async beforeEach() {
    MockDate.reset();
  },
});

Angular(.storybook/preview.ts):

import { definePreview } from '@storybook/angular';

import MockDate from 'mockdate';

export default definePreview({
  async beforeEach() {
    MockDate.reset();
  },
});

Web Components(.storybook/preview.ts):

import { definePreview } from '@storybook/web-components-vite';

import MockDate from 'mockdate';

export default definePreview({
  async beforeEach() {
    MockDate.reset();
  },
});

两种语法风格可以并存,选用哪一种取决于你的项目使用的是 CSF 3 常规导出(.js/.ts),还是实验性的 CSF Next definePreview/preview.meta() 写法。

三、执行时机与粒度:beforeAll、beforeEach 与返回的 cleanup 函数

同文件 before-all-in-preview 片段interaction-testing.mdx 说明了在 preview 中重置状态的两种可选钩子:

钩子 执行时机 适用场景
beforeAll 测试运行开始时执行一次,不会在 Story 之间重复执行;除非 preview 文件更新,否则不再运行 启动/引导项目、初始化全局依赖的环境(bootstrapping)
beforeEach 项目中每个 Story 渲染前都会执行 重置被全部或大多数 Story 共享的状态、模块(如 mock 的 Date)

同时,beforeEach 还可以返回一个清理(cleanup)函数,该函数会在每个 Story 结束、Story 被重新挂载或导航离开时执行:

You can return a cleanup function from the beforeEach function, which will run after each story, when the story is remounted or navigated away from.

这一机制让“测试前设置、测试后清理”成对出现,且清理动作发生在导航切换的边界上,从而保证 Story 被重新渲染时始终处于干净的初始状态。一个典型的成对使用方式可见组件级示例 before-each-in-meta-mock-date.md:在 meta.beforeEach 中先 MockDate.set('2024-02-14') 固定时间,再 return () => MockDate.reset() 作为清理函数在 Story 结束后复位 Date。

四、执行顺序与底层实现:从源码看 beforeEach 的编排

项目级 beforeEach 并非特殊实现——在 Storybook 内部,preview(project annotations)、组件 meta(component annotations)与单个 Story(story annotations)上的生命周期钩子是被统一规约(normalize)后按层级依次执行的。核心实现位于 prepareStory.ts

const applyBeforeEach = async (context: StoryContext<TRenderer>): Promise<CleanupCallback[]> => {
  const cleanupCallbacks = new Array<() => unknown>();
  for (const beforeEach of [
    ...normalizeArrays(projectAnnotations.beforeEach),   // 1. 项目级(preview)
    ...normalizeArrays(componentAnnotations.beforeEach), // 2. 组件级(meta)
    ...normalizeArrays(storyAnnotations.beforeEach),      // 3. Story 级
  ]) {
    if (context.abortSignal.aborted) {
      return cleanupCallbacks;
    }
    const cleanup = await beforeEach(context);

    if (cleanup) {
      cleanupCallbacks.push(cleanup);
    }
  }
  return cleanupCallbacks;
};

const applyAfterEach = async (context: StoryContext<TRenderer>): Promise<void> => {
  const reversedFinalizers = [
    ...normalizeArrays(projectAnnotations.afterEach),
    ...normalizeArrays(componentAnnotations.afterEach),
    ...normalizeArrays(storyAnnotations.afterEach),
  ].reverse();
  for (const finalizer of reversedFinalizers) {
    if (context.abortSignal.aborted) {
      return;
    }
    await finalizer(context);
  }
};

从源码结构可以提炼出以下关键事实:

  1. 执行顺序beforeEach 严格遵循「项目级 → 组件级 → Story 级」的顺序串行执行,且每个钩子都会被 await,因此你在 .storybook/preview 中定义的项目级钩子永远最先运行;
  2. 返回值收集:每个 beforeEach 返回的 cleanup 函数会被依次收集进 cleanupCallbacks,用于 Story 结束后统一触发;
  3. 对称的收尾afterEach 则按 项目级 → 组件级 → Story 级的倒序(reverse) 执行,形成类似 beforeEach/afterEach 的栈式配对清理;
  4. 支持中断:两类循环都会检查 context.abortSignal.aborted,Story 被中止时能立即退出钩子链,避免在已取消的测试上继续执行清理逻辑。

此外,多份 preview 配置的合并(即多个配置文件各自携带钩子时的组合)由 composeConfigs.ts 负责:它从模块导出列表中收集各配置的 beforeAll 字段,并通过 beforeAll.tscomposeBeforeAllHooks 将多个 beforeAll 组合为一个可顺序执行的聚合钩子(对应测试见 beforeAll.test.tscomposeConfigs.test.ts 中的 "composes beforeAll hooks" 用例)。

五、为什么不要用 afterEach 来重置状态

既然 afterEach 会在 Story 渲染并完成 play 后执行,直觉上很适合做清理。但 interaction-testing.mdx 明确给出了反直觉的结论:

You should not use afterEach to reset state in your tests. Because it runs after the story, resetting state here could prevent you from seeing the correct end state of your story. Instead, use the beforeEach's returned cleanup function to reset state, which will run only when navigating between stories to preserve the end state.

原因在于:

  • afterEach 紧跟当前 Story 的渲染与断言执行,若在此刻立刻重置状态(如把组件/模块还原到初始值),会破坏 Story 运行结束时的最终形态,使你无法在 UI 或调试面板中观察与验证 Story 的真实结束状态;
  • beforeEach 返回的 cleanup 函数只在导航切换、Story 重新挂载的边界上执行,能够先保留当前 Story 的结束状态供观察,再在下一次渲染前完成清理。

因此推荐的实践是:在 .storybook/preview.*(或组件 meta)的 beforeEach 中设置前置条件并返回 cleanup,而不是依赖 afterEach 做重置。

六、与 mock 自动恢复机制的配合

在使用 beforeEach 清理测试状态时,还需要注意不需要手动恢复 fn() 间谍/桩函数。文档明确指出:

It is not necessary to restore fn() mocks, as Storybook will already do that automatically before rendering a story.

Storybook 会在渲染每个 Story 前自动恢复由 fn() 创建的 mock,因此你无需beforeEach 中对它们调用 .mockReset() 或类似方法。若需调整该自动行为的细节,可查看 parameters.test.restoreMocks 相关 API。项目级 beforeEach 应聚焦于那些不受自动恢复机制覆盖的全局状态,例如 DateMath.random、计时器、网络层 mock 或模块单例。

七、最佳实践小结

  1. 全局状态在 preview 统一清理:当组件需要依赖被 mock 的全局对象(时间、随机数、地理位置等)时,将重置逻辑放在 .storybook/preview.*beforeEach,保证项目内每个 Story 都从干净状态开始,避免 Story 间污染导致偶发失败;
  2. 按需返回 cleanup:如果钩子不仅需要“设置”还需要“还原”(例如 MockDate.set() 之后再 MockDate.reset()),应返回 cleanup 函数,让还原动作发生在 Story 切换的边界而非紧随渲染结束的时刻;
  3. 不要用 afterEach 做重置:它会遮盖 Story 的结束状态,应改由 beforeEach 返回的 cleanup 完成;
  4. 层级搭配使用:项目级钩子覆盖绝大多数 Story 的共性需求;对某个文件内特有的状态,可在组件 meta 中定义(参考 before-each-in-meta-mock-date.md),而不要污染全局 preview——从 prepareStory.ts 的编排顺序可以看到这种分层的执行优先级是明确且可预期的;
  5. 无需手动恢复 fn():Storybook 在渲染前会自动完成恢复,可用 restoreMocks 参数控制。

延伸阅读

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