Storybook 文件级beforeEach钩子实战:用 MockDate 统一控制每个 Story 的渲染时间
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
在 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 的场景同样生效,适用范围更广。
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考