Storybook 生命周期钩子详解:在 .storybook/preview 配置 beforeEach 实现跨 Story 的状态重置
导读
在 Storybook 的交互测试与组件测试体系中,多个 Story 复用同一份模块级状态(例如被 mock 的 Date、全局拦截器或共享单例)是导致测试相互污染、随机失败的最常见原因。Storybook 在预览配置文件 .storybook/preview.* 中提供异步 beforeEach 生命周期钩子,使开发者能在每一个 Story 渲染与 play 执行之前统一重置共享状态。本文以 before-each-in-preview 代码片段 为主体,结合 interaction-testing 文档 与其底层实现,完整讲解项目级 beforeEach 的写法、执行时机、清理函数语义与源码运行机制,帮助你在整个 Storybook 项目中建立可靠、可复现的测试前置条件。
一、beforeEach 生命周期钩子:在渲染每个 Story 之前执行
Storybook 提供了一组作用于 play function / 组件测试生命周期 的异步钩子:beforeAll、beforeEach、afterEach,它们可以声明在三个不同层级:
- 项目级:位于
.storybook/preview.*,作用于整个项目的所有 Story; - 组件级(meta 级):位于
.stories文件的meta中,作用于该文件内的所有 Story; - Story 级:位于单个 Story 定义中,仅作用于该 Story。
其中的项目级 beforeEach 语义如下(见 interaction-testing.mdx 的 Set up or reset state for all tests 一节):
Unlike
beforeAll, which runs only once, thebeforeEachfunction 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-vite、nextjs、vue3-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
beforeEachfunction, 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);
}
};
从源码结构可以提炼出以下关键事实:
- 执行顺序:
beforeEach严格遵循「项目级 → 组件级 → Story 级」的顺序串行执行,且每个钩子都会被 await,因此你在.storybook/preview中定义的项目级钩子永远最先运行; - 返回值收集:每个
beforeEach返回的 cleanup 函数会被依次收集进cleanupCallbacks,用于 Story 结束后统一触发; - 对称的收尾:
afterEach则按 项目级 → 组件级 → Story 级的倒序(reverse) 执行,形成类似beforeEach/afterEach的栈式配对清理; - 支持中断:两类循环都会检查
context.abortSignal.aborted,Story 被中止时能立即退出钩子链,避免在已取消的测试上继续执行清理逻辑。
此外,多份 preview 配置的合并(即多个配置文件各自携带钩子时的组合)由 composeConfigs.ts 负责:它从模块导出列表中收集各配置的 beforeAll 字段,并通过 beforeAll.ts 的 composeBeforeAllHooks 将多个 beforeAll 组合为一个可顺序执行的聚合钩子(对应测试见 beforeAll.test.ts 与 composeConfigs.test.ts 中的 "composes beforeAll hooks" 用例)。
五、为什么不要用 afterEach 来重置状态
既然 afterEach 会在 Story 渲染并完成 play 后执行,直觉上很适合做清理。但 interaction-testing.mdx 明确给出了反直觉的结论:
You should not use
afterEachto 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 thebeforeEach'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 应聚焦于那些不受自动恢复机制覆盖的全局状态,例如 Date、Math.random、计时器、网络层 mock 或模块单例。
七、最佳实践小结
- 全局状态在 preview 统一清理:当组件需要依赖被 mock 的全局对象(时间、随机数、地理位置等)时,将重置逻辑放在
.storybook/preview.*的beforeEach,保证项目内每个 Story 都从干净状态开始,避免 Story 间污染导致偶发失败; - 按需返回 cleanup:如果钩子不仅需要“设置”还需要“还原”(例如
MockDate.set()之后再MockDate.reset()),应返回 cleanup 函数,让还原动作发生在 Story 切换的边界而非紧随渲染结束的时刻; - 不要用
afterEach做重置:它会遮盖 Story 的结束状态,应改由beforeEach返回的 cleanup 完成; - 层级搭配使用:项目级钩子覆盖绝大多数 Story 的共性需求;对某个文件内特有的状态,可在组件 meta 中定义(参考 before-each-in-meta-mock-date.md),而不要污染全局 preview——从 prepareStory.ts 的编排顺序可以看到这种分层的执行优先级是明确且可预期的;
- 无需手动恢复
fn():Storybook 在渲染前会自动完成恢复,可用restoreMocks参数控制。
延伸阅读
- 本文主体代码片段:before-each-in-preview.md
- 使用场景原文:interaction-testing.mdx 中 Set up or reset state for all tests 小节
- 项目级一次性初始化:before-all-in-preview.md
- 组件级(meta 级)
beforeEach配对清理示例:before-each-in-meta-mock-date.md - 生命周期钩子编排源码:prepareStory.ts
- 多配置合并实现:composeConfigs.ts、beforeAll.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 StartedRust0624
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