返回博客

在 Storybook 中使用 React Server Components 和 Mock Service Worker 构建 Next.js 应用

在隔离环境中开发、记录和测试 RSC 应用,使用 MSW 模拟网络请求

loading
Michael Shilman
@mshilman
最后更新

Storybook 8(我们的下一个主要版本)首次为 Storybook 带来了 React Server Component (RSC) 兼容性,让您可以在隔离环境中构建、测试和记录 Next.js 服务器应用程序。

在我们的第一个演示中,我们使用 Storybook 开发了一个联系卡 RSC,它异步地和从文件系统中访问联系人数据,同时通过模块模拟来模拟服务器代码。

A Storybook story showing a contact card for Chuck Norris
当 Chuck Norris 构建前端应用程序时,UI 会自行测试!

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

为什么要隔离构建页面?

令人惊讶的是,仅仅两个页面就能容纳如此多的 UI。考虑一下您的页面需要的数据状态。然后,将它们乘以响应式布局、登录视图、主题、浏览器、语言环境和可访问性。少量页面很容易变成数百个变体。

Storybook 通过将任何 UI 状态隔离为 story,让您可以瞬间转移到该状态,从而解决了这种复杂性!如果您是 Storybook 的新手,这里介绍了 stories 的工作原理

有兴趣进一步扩展您的 Storybook 测试吗?Storybook 8 现在支持原生的自动化可视化测试,因此您只需单击一个按钮即可捕获整个应用程序中意外的可视化更改。了解更多关于开始使用可视化测试插件的信息

为 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 } },
}
Hacker Next, but there’s no styling :(

虽然这可以工作,但您会注意到它缺少样式。我们可以通过向我们的 .storybook/preview.tsx 添加一个 decorator 来修复它

// .storybook/preview.tsx

import Layout from '../app/layout.tsx';

export default {
  // other settings
  decorators: [(Story) => <Layout><Story /></Layout>],
}
Hacker Next, with styling added back in

这样更好了!现在,尝试对 app/item/[id]/(comments)/page.tsx 执行此操作。如果您遇到困难,查看我们的 repo

使用 Mock Service Worker 进行 Mock ‘n’ roll

我们希望能够控制数据,而不是使用真实数据。这使我们能够测试不同的状态并生成一致的结果。

Hacker Next 从网络 API 获取数据,因此我们将使用 Mock Service Worker (MSW) 模拟其请求。

💡
如果您密切关注这个领域,您可能会问,“MSW 目前不是与 Next.js 应用目录不兼容吗?” 这是真的。但是,由于我们在浏览器中而不是在 Next.js 中运行它,因此在 Storybook 中使用 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

A Storybook story for a Hacker Next detail page, titled ‘Storybook + Next.js = ❤️’

MSW 数据工厂

硬编码的 API 响应难以扩展。因此,让我们编写一个 story,使用更高级别的参数来控制页面内容!我们需要

  1. 构建一个简化的内存数据库
  2. 创建从数据库读取并生成所需网络响应的 MSW 处理程序
  3. 编写 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());
  }];
}
A Hacker Next feed featuring 30 posts

是时候测试了

恭喜!您已经在 Storybook 中构建了 Hacker Next,并使用了您可以为不同测试自定义的数据。或者,查看演示 Storybook(通过 Chromatic 共享)或 我们的 repo

0:00
/0:21

除了将您的 UI 集中在一个地方之外,您还可以以前所未有的方式测试 Hacker Next。

例如,您可以使用 Storybook 的 play 函数为 Hacker Next 的点赞和折叠评论状态编写 stories。这是一个代码片段,它模拟用户交互并在 story 渲染后立即运行。它可以使用 Testing-Library 与 DOM 交互,并使用 Vitest 的 expect 和 spies 进行断言。

这是一个使用 play 函数点赞首页上第一个帖子的 story

0:00
/0:13
// 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 中拥有了这一切,我们就可以

  1. 即使我们的后端正在开发中,也可以针对模拟数据进行开发
  2. 开发难以到达的 UI 状态,例如“信用卡已过期”屏幕
  3. 在每个屏幕上立即运行视觉回归和 a11y 测试,跨浏览器和不同分辨率进行测试
  4. 在设计文件旁边查看生产 stories,以确保顺利交接
  5. 使用整个前端架构的实时和全面的文档来帮助新开发人员入职
  6. 了解更多关于将 Next.js 与 Storybook 结合使用的信息

Storybook 彻底改变了可重用组件的开发。现在,您可以将相同的优势应用于应用程序的页面。

在我们的下一篇 RSC 文章中,我们将探索模块模拟,以处理在实际情况下不可能或不切实际模拟网络请求的情况。

致谢

感谢 Artem ZakharchenkoMSW 的核心维护者)和 Next.js 团队的审查和指导!

加入 Storybook 邮件列表

获取最新的新闻、更新和版本

6,730位开发者和计数

我们正在招聘!

加入 Storybook 和 Chromatic 背后的团队。构建被数十万开发者在生产环境中使用的工具。远程优先。

查看职位

热门文章

React Native Storybook 7

比以往任何时候都更紧密地对齐 React Native 和核心 Storybook
loading
Daniel Williams

Storybook 8 Beta

主要的兼容性和性能改进
loading
Michael Shilman

2024 年 Storybook 的未来

2023 年的亮点以及接下来的发展
loading
Michael Shilman
加入社区
6,730位开发者和计数
为什么为什么选择 Storybook组件驱动的 UI
文档指南教程更新日志遥测
社区插件参与进来博客
案例展示探索项目组件词汇表
开源软件
Storybook - Storybook 中文

特别感谢 Netlify CircleCI