
使用 React Server Components 和 Mock Service Worker 在 Storybook 中构建 Next.js 应用
使用 MSW 模拟网络请求,独立开发、文档化和测试 RSC 应用

Storybook 8(我们的下一个主要版本)首次将 React Server Component (RSC) 兼容性带到 Storybook 中,让您能够独立构建、测试和文档化 Next.js 服务端应用。
在我们的第一个演示中,我们使用 Storybook 开发了一个联系人卡片 RSC,通过模块模拟来模拟服务端代码,同时异步地和从文件系统中访问联系人数据。

接下来,我们将探索如何使用 Next.js App Router 独立构建一个完整的应用,借助 Mock Service Worker 在 Storybook 中重建 Hacker Next 示例。


为何要独立构建页面?
令人惊讶的是,仅仅两个页面就能容纳如此多的 UI。考虑您的页面所需的数据状态。然后,将其乘以响应式布局、登录视图、主题、浏览器、本地化和可访问性。只需要很少的页面就能变成数百种变体。
Storybook 通过将任何 UI 状态隔离为故事来解决这一复杂性,让您可以“传送”到任何 UI 状态!如果您是 Storybook 新手,这里是故事的工作原理。
为 Hacker Next 编写故事
首先,在您的 Next.js 项目中安装 Storybook
npx storybook@next init
然后,将 experimentalRSC
标志添加到 Storybook 的 main.ts 中,并将其指向我们将要编写的新故事
// main.ts
const config: StorybookConfig = {
- stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
+ stories: ['../app/**/*.stories.tsx'],
// ... existing config
+ features: { experimentalRSC: true }
}
现在,让我们为 Hacker Next 的两个组件编写故事:news
首页和 item
页面!这是一个针对 news
页面的简单故事示例
// app/news/[page]/index.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import News from './page';
const meta = {
title: 'app/News',
component: News,
} satisfies Meta<typeof News>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Home: Story = {
args: { params: { page: 1 } },
}

虽然这可行,但您会注意到它缺少样式。我们可以通过将装饰器添加到我们的 .storybook/preview.tsx
来解决这个问题
// .storybook/preview.tsx
import Layout from '../app/layout.tsx';
export default {
// other settings
decorators: [(Story) => <Layout><Story /></Layout>],
}

这就好多了!现在,尝试为 app/item/[id]/(comments)/page.tsx
执行此操作。如果您遇到困难,请查看我们的仓库。
使用 Mock Service Worker 模拟和测试
我们希望能够控制数据,而不是使用真实数据。这使我们能够测试不同的状态并生成一致的结果。
Hacker Next 从网络 API 获取数据,因此我们将使用 Mock Service Worker (MSW) 模拟其请求。
首先,让我们将 Storybook 的 MSW 插件添加到我们的项目中。我们将使用支持 MSW 2.0 大幅改进的 API 的 canary 版本。
pnpx storybook add msw-storybook-addon@2.0.0--canary.122.06f0c92.0
pnpx msw init public
接下来,更新 .storybook/preview.tsx
以使用 onUnhandledRequest
选项初始化 MSW。这确保了我们现有的故事可以继续工作。
// .storybook/preview.tsx
// ... existing imports
+ import { initialize, mswLoader } from 'msw-storybook-addon';
+ initialize({ onUnhandledRequest: 'warn' });
const preview: Preview = {
+ loaders: [mswLoader],
decorators: [(Story) => <Layout><Story /></Layout>],
}
现在,让我们为 Hacker Next 的主页创建一个故事,展示一篇帖子
// app/news/[page]/index.stories.tsx
import { http, HttpResponse } from 'msw'
// ...existing meta/story
export const Mocked = {
...Home,
parameters: {
msw: {
handlers: [
http.get('https://hacker-news.firebaseio.com/v0/topstories.json', () => {
return HttpResponse.json([1]);
}),
http.get('https://hacker-news.firebaseio.com/v0/item/1.json', () => {
return HttpResponse.json({
id: 1,
time: Date.now(),
user: 'shilman',
url: 'https://storybook.org.cn',
title: 'Storybook + Next.js = ❤️',
score: 999,
});
}),
],
},
},
};
通过从前端模拟两个 REST API 请求并硬编码响应,我们得到了以下故事

MSW 数据工厂
硬编码的 API 响应难以扩展。因此,让我们编写一个通过更高层参数控制页面内容的故事!我们需要
- 构建一个简化的内存数据库
- 创建从数据库读取并生成所需网络响应的 MSW 处理程序
- 编写故事以使用测试用例填充数据库
步骤 1:构建数据库
首先,让我们使用 @mswjs/data(MSW 的数据工厂库)和 Faker.js 创建数据库。
// data.mock.ts
import { faker } from '@faker-js/faker'
import { drop, factory, primaryKey } from '@mswjs/data
let _id;
const db = factory({
item: {
id: primaryKey(() => _id++),
time: () => faker.date.recent({ days: 2 }).getTime() / 1000,
user: faker.internet.userName,
title: faker.lorem.words,
url: faker.internet.url,
score: () => faker.number.int(100),
}
})
/** Reset the database */
export const reset = (seed?: number) => {
_id = 1
faker.seed(seed ?? 123)
return drop(db)
}
/** Create a post. Faker will fill in any missing data */
export const createPost = (item = {}) => db.item.create(item);
/** Utility function */
export const range = (n: number) => Array.from({length: n}, (x, i) => i);
/** Return all the post IDs */
export const postIds = () => db.item.findMany({}).map((p) => p.id);
/** Return the content of a single post by ID */
export const getItem = (id: number) => db.item.findFirst({ where: { id: { equals: id }}});
这让您可以精确地指定帖子,使其按照您想要的方式显示。当我们未指定任何数据时,Faker 会填补空白。这样,您可以用最少的代码创建几十甚至数百篇帖子!
步骤 2:创建 MSW 处理程序
接下来,我们将更新 .storybook/preview.tsx
,添加从数据库读取的 MSW 处理程序。这些处理程序可在您的所有故事中访问,并读取数据库中的任何内容。这意味着故事的唯一工作就是用有用数据填充数据库!
// .storybook/preview.tsx
import { postIds, getItem } from '../lib/data.mock.ts';
import { http, HttpResponse } from 'msw'
const preview: Preview = {
// ...existing configuration
parameters: { msw: { handlers: [
http.get(
'https://hacker-news.firebaseio.com/v0/topstories.json',
() => HttpResponse.json(postIds())
),
http.get<{ id: string }>(
'https://hacker-news.firebaseio.com/v0/item/:id.json',
({ params }) => HttpResponse.json(getItem(parseInt(params.id, 10)))
)
] } },
};
步骤 3:编写故事
最后,我们将为我们的新设置编写故事。
首先,使用一个 loader(在故事渲染前运行的函数)替换您现有的 Mocked
故事。这个加载器会调用我们的 createPost
辅助函数,该函数 1) 实例化一篇帖子,以及 2) 将其添加到内存数据库中。
// app/news/[page]/index.stories.tsx
import { createPost } from '../../../lib/data.mock';
// ...existing meta/story
export const MockedNew = {
...Home,
loaders: [() => {
createPost({
id: -1,
user: 'shilman',
url: 'https://storybook.org.cn',
title: 'Storybook + Next.js = ❤️',
score: 999,
});
}],
};
当您需要一次创建大量数据时,这个方案非常出色。为了演示这一点,让我们创建一个显示 30 篇帖子的主页。为了使其更加强大,我们可以让帖子数量在 Storybook 的 UI 中进行交互式控制
// app/news/[page]/index.stories.tsx
import { createPost, range, reset } from '../../../lib/data.mock'
export const FullPage = {
args: {
postCount: 30,
},
loaders: [({ args: { postCount } }) => {
reset();
range(postCount).forEach(() => createPost());
}];
}

是时候进行测试了
恭喜您!您已经在 Storybook 中构建了 Hacker Next,并且可以使用自定义数据进行不同的测试。或者,您可以查看一个演示 Storybook(通过 Chromatic 共享)或我们的仓库。
除了将您的 UI 集中在一处,您还可以以其他方式无法实现的方式测试 Hacker Next。
例如,您可以使用 Storybook 的 play function 编写 Hacker Next 的点赞和评论折叠状态的故事。这是一个代码片段,它模拟用户交互并在故事渲染后立即运行。它可以使用 Testing-Library 与 DOM 交互,并使用 Vitest 的 expect 和 spies 进行断言。
这是一个使用 play function 点赞主页上第一篇帖子的故事
// app/news/[page]/index.stories.tsx
import { within, userEvent } from '@storybook/test';
export const Upvoted: Story = {
...FullPage,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const votes = await canvas.findAllByText('▲')
await userEvent.click(votes[0])
}
}
总结
在本次练习中,我们对 Next.js 应用的关键 UI 状态进行了分类。将这些都放入 Storybook 后,我们可以
- 即使后端正在开发中,也可以针对模拟数据进行开发
- 开发难以触及的 UI 状态,例如“信用卡过期”屏幕
- 立即在每个屏幕上运行视觉回归和可访问性测试,跨浏览器和不同分辨率进行测试
- 将生产故事与设计文件并排放置,确保顺利交接
- 通过生动全面的前端架构文档来指导新开发者入门
- 了解更多关于在 Storybook 中使用 Next.js 的信息
Storybook 彻底改变了可复用组件的开发方式。现在,您可以将同样的优势应用于应用页面。
在我们的下一篇 RSC 文章中,我们将探讨模块模拟,以处理模拟网络请求不可能或不切实际的实际情况。
Storybook 8(我们的下一个主要版本)将 React Server Components 支持带到了 Storybook!
— Storybook (@storybookjs) 2024 年 1 月 18 日
在我们的新教程中,了解如何使用 @nextjs、Storybook 和 @ApiMocking 独立构建、文档化和测试 RSC 应用 ≫https://#/SVZ3TNJw1I
致谢
感谢 Artem Zakharchenko(MSW 的核心维护者)以及 Next.js 团队的审阅和指导!