返回博客

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

使用 MSW 模拟网络请求,独立开发、文档化和测试 RSC 应用

loading
Michael Shilman
@mshilman
最后更新于

Storybook 8(我们的下一个主要版本)首次将 React Server Component (RSC) 兼容性带到 Storybook 中,让您能够独立构建、测试和文档化 Next.js 服务端应用。

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

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

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

为何要独立构建页面?

令人惊讶的是,仅仅两个页面就能容纳如此多的 UI。考虑您的页面所需的数据状态。然后,将其乘以响应式布局、登录视图、主题、浏览器、本地化和可访问性。只需要很少的页面就能变成数百种变体。

Storybook 通过将任何 UI 状态隔离为故事来解决这一复杂性,让您可以“传送”到任何 UI 状态!如果您是 Storybook 新手,这里是故事的工作原理

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

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

虽然这可行,但您会注意到它缺少样式。我们可以通过将装饰器添加到我们的 .storybook/preview.tsx 来解决这个问题

// .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 执行此操作。如果您遇到困难,请查看我们的仓库

使用 Mock Service Worker 模拟和测试

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

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

💡
如果您密切关注这个领域,您可能会问,“MSW 目前是否与 Next.js app directory 不兼容?” 这是事实。然而,由于我们是在浏览器中运行它,而不是在 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。这确保了我们现有的故事可以继续工作。

// .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 请求并硬编码响应,我们得到了以下故事

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

MSW 数据工厂

硬编码的 API 响应难以扩展。因此,让我们编写一个通过更高层参数控制页面内容的故事!我们需要

  1. 构建一个简化的内存数据库
  2. 创建从数据库读取并生成所需网络响应的 MSW 处理程序
  3. 编写故事以使用测试用例填充数据库

步骤 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());
  }];
}
A Hacker Next feed featuring 30 posts

是时候进行测试了

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

0:00
/0:21

除了将您的 UI 集中在一处,您还可以以其他方式无法实现的方式测试 Hacker Next。

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

这是一个使用 play function 点赞主页上第一篇帖子的故事

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. 立即在每个屏幕上运行视觉回归和可访问性测试,跨浏览器和不同分辨率进行测试
  4. 将生产故事与设计文件并排放置,确保顺利交接
  5. 通过生动全面的前端架构文档来指导新开发者入门
  6. 了解更多关于在 Storybook 中使用 Next.js 的信息

Storybook 彻底改变了可复用组件的开发方式。现在,您可以将同样的优势应用于应用页面。

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

致谢

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

加入 Storybook 邮件列表

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

7,180开发者,还在增长

我们正在招聘!

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

查看职位

热门文章

React Native Storybook 7

将 React Native 与 Storybook 核心更紧密地对齐
loading
Daniel Williams

Storybook 8 Beta

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

2024 年 Storybook 的未来

2023 年亮点和接下来会发生什么
loading
Michael Shilman
加入社区
7,180开发者,还在增长
为何选择为何选择 Storybook组件驱动 UI
文档指南教程更新日志遥测
社区插件参与贡献博客
案例展示探索项目组件术语表
开源软件
Storybook - Storybook 中文

特别感谢 Netlify CircleCI