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

Storybook 8(我们的下一个主要版本)首次为 Storybook 带来了 React Server Component (RSC) 兼容性,让您可以在隔离环境中构建、测试和记录 Next.js 服务器应用程序。
在我们的第一个演示中,我们使用 Storybook 开发了一个联系卡 RSC,它异步地和从文件系统中访问联系人数据,同时通过模块模拟来模拟服务器代码。

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


为什么要隔离构建页面?
令人惊讶的是,仅仅两个页面就能容纳如此多的 UI。考虑一下您的页面需要的数据状态。然后,将它们乘以响应式布局、登录视图、主题、浏览器、语言环境和可访问性。少量页面很容易变成数百个变体。
Storybook 通过将任何 UI 状态隔离为 story,让您可以瞬间转移到该状态,从而解决了这种复杂性!如果您是 Storybook 的新手,这里介绍了 stories 的工作原理。
为 Hacker Next 编写 stories
首先,在您的 Next.js 项目中安装 Storybook
npx storybook@next init
然后,将 experimentalRSC
标志添加到 Storybook 的 main.ts,并将其指向我们即将编写的新 stories
// 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 的两个组件编写 stories:news
首页和 item
页面!以下是一个针对 news
页面的简单 story 示例
// 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
添加一个 decorator 来修复它
// .storybook/preview.tsx
import Layout from '../app/layout.tsx';
export default {
// other settings
decorators: [(Story) => <Layout><Story /></Layout>],
}

这样更好了!现在,尝试对 app/item/[id]/(comments)/page.tsx
执行此操作。如果您遇到困难,查看我们的 repo。
使用 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 处理程序
- 编写 stories 以使用测试用例填充数据库
步骤 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 处理程序
接下来,我们将使用从数据库读取的 MSW 处理程序更新 .storybook/preview.tsx
。这些处理程序在您的所有 stories 中都可用,并读取数据库中的任何内容。这意味着 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:编写 stories
最后,我们将为我们的新设置编写 stories。
首先,使用 loader(在 story 渲染之前运行的函数)替换您现有的 Mocked
story。此 loader 调用我们的 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 共享)或 我们的 repo。
除了将您的 UI 集中在一个地方之外,您还可以以前所未有的方式测试 Hacker Next。
例如,您可以使用 Storybook 的 play 函数为 Hacker Next 的点赞和折叠评论状态编写 stories。这是一个代码片段,它模拟用户交互并在 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 测试,跨浏览器和不同分辨率进行测试
- 在设计文件旁边查看生产 stories,以确保顺利交接
- 使用整个前端架构的实时和全面的文档来帮助新开发人员入职
- 了解更多关于将 Next.js 与 Storybook 结合使用的信息
Storybook 彻底改变了可重用组件的开发。现在,您可以将相同的优势应用于应用程序的页面。
在我们的下一篇 RSC 文章中,我们将探索模块模拟,以处理在实际情况下不可能或不切实际模拟网络请求的情况。
Storybook 8(我们的下一个主要版本)为 Storybook 带来了 React Server Components 支持!
— Storybook (@storybookjs) 2024 年 1 月 18 日
在我们的新教程中,了解如何使用 @nextjs、Storybook 和 @ApiMocking 隔离构建、记录和测试 RSC 应用 ≫https://#/SVZ3TNJw1I
致谢
感谢 Artem Zakharchenko(MSW 的核心维护者)和 Next.js 团队的审查和指导!