Storybook 文件级 `beforeEach` 钩子实战:用 MockDate 统一控制每个 Story 的渲染时间
在 Storybook 交互测试与组件开发中,很多组件(日历、倒计时、报表、聊天时间戳等)的行为依赖系统当前时间,导致每次渲染或运行 play 函数时结果都不稳定。本篇文章基于 Storybook 官方交互测试文档提供的 beforeEach 代码片段,讲解如何在组件 meta(默认导出)上声明异步 beforeEach 钩子,用 mockdate 将 Date 固定为某个确定值,再通过钩子返回的清理函数在每个 Story 结束后自动还原——最终让文件内所有 Story 与交互测试都运行在可预期的“假想时间”里。读完你将掌握 Storybook 生命周期钩子的声明位置、执行顺序、清理函数约定,以及它在各渲染器(Angular、React、Vue、Svelte、Web Components)下的完整写法。
这段代码解决了什么问题
Storybook 中,一个 .stories 文件可以同时导出多个 Story,而每个 Story 的 play 函数是进行组件交互测试的入口。文档 interaction-testing.mdx 指出:当组件包含时间相关逻辑时,直接渲染会让画面随真实时间“漂移”,快照与断言都难以稳定。
代码片段给出的方案是在组件 meta 上声明:
async beforeEach() {
MockDate.set('2024-02-14');
return () => {
MockDate.reset();
};
}
这一段干了两件事:在每个 Story 渲染之前把全局 Date 固定到 2024-02-14;在 Story 结束(重挂载或导航离开)时自动执行返回的清理函数还原时间。两者配合,即实现了“文件级时间隔离”。
beforeEach 生命周期钩子的定位与执行顺序
在 Storybook 的注解类型定义 story.ts 中,beforeEach 被描述为:
在每个 Story 之前调用的函数,若为异步则会被 await。
beforeEach可以添加到 preview、默认导出(meta)以及具体的某个 Story 上,三者按 preview → 默认导出 → story 的顺序依次运行(并依次 await),且可以返回一个清理函数。
同一位置还定义了配套的 afterEach(运行于每个 play 函数结束之后,用于后置断言,不应用于清理状态)以及仅允许声明在全局(preview 文件)的 beforeAll 钩子。
由此可以提炼出 beforeEach 的三个合法声明位置与语义:
| 声明位置 | 作用范围 | 运行时机 |
|---|---|---|
.storybook/preview.*(全局) |
项目内所有 Story | 每个 Story 渲染前 |
组件 meta(export default) |
该文件内所有 Story | 每个 Story 渲染前 |
| 单个 Story 定义 | 该 Story | 每个 Story 渲染前 |
本文片段使用的正是第二个位置——组件 meta。当某些需求是某个组件特有的(例如该文件下的页面组件强依赖固定的“今天”),把它放在 meta 级而非全局 preview 中,职责更内聚,不会影响项目中其他组件的 Story。
在组件 meta 中统一 Mock Date:各渲染器完整写法
官方代码片段为同一逻辑提供了跨渲染器、跨 CSF 语法(CSF 3 与 CSF Next)的多种写法。其核心钩子体完全一致,差异仅在 meta 的组装方式与组件导入语句上,下面按渲染器分组给出可运行的完整示例。
Angular + CSF 3
Angular 渲染器下,meta 是 Meta<Page> 类型,beforeEach 直接写进对象:
import type { Meta, StoryObj } from '@storybook/angular';
import MockDate from 'mockdate';
import { Page } from './Page.component';
const meta: Meta<Page> = {
component: Page,
// 👇 为文件内每个 Story 固定 Date
async beforeEach() {
MockDate.set('2024-02-14');
// 👇 在每个 Story 结束后还原 Date
return () => {
MockDate.reset();
};
},
};
export default meta;
type Story = StoryObj<Page>;
export const Basic: Story = {
async play({ canvas }) {
// ... 此处运行在 Mock 的 Date 下
},
};
在 Angular 场景下,官方文档特意强调:Angular 组件在渲染前执行代码的方式就是为 Story 定义异步 beforeEach 函数(其他渲染器还可通过 play 中调用 mount 来完成渲染前准备),这使该片段在 Angular 项目中尤为重要。
React / Vue / Web Components + CSF Next(preview.meta)
使用实验性 CSF Next 语法时,meta 通过从 .storybook/preview 导入的 preview.meta({ ... }) 创建,story 则通过 meta.story({ ... }) 声明。React 的写法如下:
import MockDate from 'mockdate';
import preview from '../.storybook/preview';
import { Page } from './Page';
const meta = preview.meta({
component: Page,
// 👇 为文件内每个 Story 固定 Date
async beforeEach() {
MockDate.set('2024-02-14');
// 👇 在每个 Story 结束后还原 Date
return () => {
MockDate.reset();
};
},
});
export const Basic = meta.story({
async play({ canvas }) {
// ... 此处运行在 Mock 的 Date 下
},
});
Vue 渲染器的 CSF Next 版本结构完全一致,仅需将组件导入改为 import Page from './Page.vue'。Web Components 渲染器的 CSF Next 版本也遵循同一模式,不同点是 meta 通过 component: 'my-page' 指定自定义元素标签名:
import MockDate from 'mockdate';
import preview from '../.storybook/preview';
const meta = preview.meta({
component: 'my-page',
// 👇 为文件内每个 Story 固定 Date
async beforeEach() {
MockDate.set('2024-02-14');
return () => {
MockDate.reset();
};
},
});
export const Basic = meta.story({
async play({ canvas }) {
// ...
},
});
React / Vue / Web Components + CSF 3
不启用 CSF Next 的项目沿用 CSF 3:meta 就是 export default 的对象,Story 为具名导出。React 的典型写法(配合 satisfies 获得类型收窄):
import type { Meta, StoryObj } from '@storybook/your-framework';
import MockDate from 'mockdate';
import { Page } from './Page';
const meta = {
component: Page,
// 👇 为文件内每个 Story 固定 Date
async beforeEach() {
MockDate.set('2024-02-14');
return () => {
MockDate.reset();
};
},
} satisfies Meta<typeof Page>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Basic: Story = {
async play({ canvas }) {
// ... 此处运行在 Mock 的 Date 下
},
};
其中 @storybook/your-framework 只是一个占位写法,实际项目需替换为你所用的框架包,例如 react-vite、nextjs、vue3-vite 等。若不使用 TypeScript,可以去掉类型标注与 satisfies,直接导出纯对象,钩子体保持完全一致。
Web Components + CSF 3(无组件导入)
Web Components 的 CSF 3 版本无需导入组件文件,直接以字符串标签声明组件:
import type { Meta, StoryObj } from '@storybook/web-components-vite';
import MockDate from 'mockdate';
const meta: Meta = {
component: 'my-page',
// 👇 为文件内每个 Story 固定 Date
async beforeEach() {
MockDate.set('2024-02-14');
return () => {
MockDate.reset();
};
},
};
export default meta;
type Story = StoryObj;
export const Basic: Story = {
async play({ canvas }) {
// ...
},
};
Svelte + Svelte CSF(defineMeta)
Svelte 使用 Storybook 官方的 @storybook/addon-svelte-csf 扩展,需要在 <script module>(模块级脚本)中调用 defineMeta 并在其中声明钩子,然后用解构出的 <Story> 组件声明 story:
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import MockDate from 'mockdate';
import Page from './Page.svelte';
const { Story } = defineMeta({
component: Page,
// 👇 为文件内每个 Story 固定 Date
async beforeEach() {
MockDate.set('2024-02-14');
return () => {
MockDate.reset();
};
},
});
</script>
<Story name="Default" play={async ({ canvas }) => {
// ... 此处运行在 Mock 的 Date 下
}}
/>
若 Svelte 项目坚持使用标准 CSF 3(.stories.js / .stories.ts),则只需像其他框架一样把 beforeEach 写入默认导出即可,见 Svelte CSF 相关文档 之外的 writing-stories 目录 说明。
钩子体详解:MockDate.set 与清理函数的配合
先安装依赖:
npm install mockdate
代码片段中的关键点可拆成三层:
MockDate.set('2024-02-14'):mockdate会接管全局Date构造函数,使此后新建的new Date()、Date.now()、new Date(Date.now())等一律返回所设置的时刻(其余时间字段如时分秒归零)。字符串会被解析为本地时区的当天零点,若需要更精确的时间可传入'2024-02-14T12:00:00'这样的完整时间串或时间戳。return () => { MockDate.reset(); }:这是beforeEach的清理函数约定——钩子返回的函数会在 Story 被重新挂载或导航离开时执行。将它用于还原被篡改的全局状态,正好补上了“测试后残留污染”的缺口。- 作用域:由于钩子声明在 meta 上,
set与reset的配对逻辑对文件内每一个 Story 生效,无需在每个 Story 里重复书写。
官方文档建议不要把清理工作放在 afterEach 中,理由是 afterEach 运行在 Story 渲染与交互结束之后,此时清理会破坏你观察 Story 最终状态的能力;正确的还原时机就是 beforeEach 返回的清理函数,它只在 Story 之间切换时执行,从而保留单次 Story 的终态(见 after-each-in-meta 代码片段相关章节)。
与全局钩子的分工:什么时候用 meta,什么时候用 preview
如果项目中有大量 Story 都依赖固定时间,把还原逻辑放在 .storybook/preview.* 的全局 beforeEach 中更省事:
// .storybook/preview.ts
import type { Preview } from '@storybook/your-framework';
import MockDate from 'mockdate';
const preview: Preview = {
async beforeEach() {
MockDate.reset();
},
};
export default preview;
官方代码片段文件 before-each-in-preview.md 给出的正是这个“全局兜底”思路,并保留了 CSF Next 下 definePreview({ ... }) 的等价写法。官方文档(interaction-testing.mdx 中 “Set up or reset state for all tests” 一节)的建议是:
- 组件或模块状态的复位应优先放在 preview 的全局钩子里,保证覆盖整个项目;meta 级
beforeEach的清理函数适合处理某个组件特别定制、不便全局化的状态。 - 项目级的
beforeAll(仅全局可声明,见 story.ts 的类型注释)只在测试会话开始时执行一次,适合做启动引导类操作(其完整示例见 before-all-in-preview.md),不应依赖它在不同 Story 间反复重置时间。 - 事件监听器等
fn()产生的 mock 无需手动还原,Storybook 会在渲染前自动恢复,相关细节见parameters.test.restoreMocks文档。
因此对本片段而言,一个稳妥的组合是:meta 级 beforeEach 中 MockDate.set + 清理函数 reset,保证文件内确定性;同时也可以在 preview 全局 beforeEach 中冗余一次 MockDate.reset(),为“漏网”的 Story 兜底。
底层实现:清理函数如何被收集与触发
从源码看,Storybook 在正式渲染 Story 前会执行各层 beforeEach。在 StoryRender.ts 中可以看到如下调用:
const cleanupCallbacks = await applyBeforeEach(context);
this.store.addCleanupCallbacks(story, ...cleanupCallbacks);
即 Storybook 会先对 StoryContext 应用所有 beforeEach(并等待异步完成),把返回的清理函数统一登记到 store 中;随后在 Story 重挂载、切换或测试收尾时通过 cleanupStory 依次执行这些清理回调。这也解释了为什么钩子的返回函数必须在 Story 离开时才运行——因为它是被 Storybook 生命周期统一调度的,而非在 play 内手动触发。
正因如此,beforeEach 中任何异步准备工作(例如等待某个模块初始化完成后再固定时间)都是安全的:它是 async 且会被 await,只有钩子 resolve 之后 Story 才会进入渲染阶段。
实践要点小结
- 值域一致性:为每个使用时间快照的 Story 固定同一个
Date,能同时稳定浏览器渲染、视觉回归(如 Chromatic 快照)与play函数中的断言,是最常见的用法。 - 匹配真实业务:
MockDate.set的值应与组件业务场景吻合(如“今天”附近的时间),否则会出现月末、闰年等边界展示与真实日历不一致的假阳性。 - 不要忘记 reset:任何对全局
Date的篡改若不还原,都会顺着 Story 顺序“传染”给后续 Story;务必利用beforeEach的清理函数或全局 preview 钩子成对复位。 - 渲染器差异只影响包装层:从本文的多个示例可以看出,Angular、React、Vue、Svelte、Web Components 之间的区别仅在 meta 组装语法(CSF 3
export default、CSF Nextpreview.meta、SveltedefineMeta)与组件导入方式,async beforeEach() { MockDate.set(...); return () => MockDate.reset(); }这一核心结构在所有渲染器中通用,可直接复制迁移。
若你的测试运行环境切换到 Vitest/Storybook Test,也可以考虑用 Vitest 内置的 vi.useFakeTimers 或 vi.setSystemTime 实现等价效果;而本文的方案因完全位于 Storybook 生命周期内,对于在浏览器预览面板中手工浏览 Story 的场景同样生效,适用范围更广。
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