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

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

接下来,我们将探索如何使用 Next.js App Router 在隔离环境中构建整个应用程序,通过使用 Mock Service Worker 重建 Hacker Next 示例。


为什么要隔离地构建页面?
令人惊叹的是,UI 可以装入仅两页。考虑您的页面需要的数据状态。然后,将它们乘以响应式布局、登录视图、主题、浏览器、区域设置和可访问性。少量页面很容易变成 **数百** 种变体。
Storybook 通过将任何 UI 状态隔离为一个 story 来解决这种复杂性!如果您是 Storybook 新手,这就是 story 的工作原理。
为 Hacker Next 编写 story
首先,在您的 Next.js 项目中安装 Storybook
npx storybook@next init
然后,将 experimentalRSC 标志添加到 Storybook 的 main.ts 中,并将其指向我们将要编写的新 story
// 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 的两个组件编写 story:news 主页和 item 页面!这里是一个简单的 story,看起来像 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 进行 Mock 'n' roll
与其使用真实数据,不如我们希望能够控制数据。这使我们可以测试不同的状态并生成一致的结果。
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。这确保了我们现有的 story 继续正常工作。
// .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 主页的 story,其中包含一个帖子
// 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 请求并硬编码响应,我们得到了以下 story

MSW 数据工厂
硬编码的 API 响应难以扩展。所以,让我们编写一个 story,通过一个更高的参数来控制页面内容!我们需要
- 构建一个简化的内存数据库
- 创建 MSW handler 来读取数据库并生成所需的网络响应
- 编写 story 来填充数据库以进行测试
步骤 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 handler
接下来,我们将使用从数据库读取的 MSW handler 来更新 .storybook/preview.tsx。这些 handler 在所有 story 中都可用,并且会读取数据库中的任何内容。这意味着 story 的唯一工作就是用有用的数据填充数据库!
// .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:编写 story
最后,我们将为我们的新设置编写 story。
首先,用一个使用 loader(在 story 渲染之前运行的函数)的新版本替换您现有的 Mocked story。此 loader 调用我们的 createPost 辅助函数,该函数 1) 实例化一个 post,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 函数为 Hacker Next 的点赞和折叠评论状态编写 story。这是一个模拟用户交互的代码片段,在 story 渲染后立即运行。它可以使用 Testing-Library 与 DOM 进行交互,并使用 Vitest 的 expect 和 spies 进行断言。
这是一个使用 play 函数为首页第一个帖子点赞的 story
// 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 状态,例如“信用卡已过期”屏幕
- 在每个屏幕上即时运行视觉回归和 a11y 测试,跨浏览器和不同分辨率进行测试
- 查看与设计文件相邻的生产 story,以确保顺畅的交接
- 通过对整个前端架构进行动态且全面的文档化,让新开发人员快速上手
- 详细了解在 Storybook 中使用 Next.js
Storybook 彻底改变了可重用组件的开发。现在,您可以将相同的优势应用于您的应用程序页面。
在我们下一篇 RSC 文章中,我们将探讨模块模拟,以处理现实世界中不可能或不切实际地模拟网络请求的情况。
Storybook 8(我们的下一个主要版本)为 Storybook 带来了 React Server Components 支持!
— Storybook (@storybookjs) 2024 年 1 月 18 日
在我们的新教程中,学习如何使用 @nextjs、Storybook 和 @ApiMocking 进行独立构建、文档化和测试 RSC 应用 ≫https://#/SVZ3TNJw1I
鸣谢
感谢 Artem Zakharchenko(MSW 的核心维护者)和 Next.js 团队的审查和指导!