返回博客

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

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

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 重建 Hacker Next 示例。

为什么要隔离地构建页面?

令人惊叹的是,UI 可以装入仅两页。考虑您的页面需要的数据状态。然后,将它们乘以响应式布局、登录视图、主题、浏览器、区域设置和可访问性。少量页面很容易变成 **数百** 种变体。

Storybook 通过将任何 UI 状态隔离为一个 story 来解决这种复杂性!如果您是 Storybook 新手,这就是 story 的工作原理

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

为 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 } },
}
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 进行 Mock 'n' roll

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

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

💡
如果您密切关注这个领域,您可能会问,“MSW 目前是否 与 Next.js app directory 不兼容?” 这是真的。但是,由于我们在浏览器中运行它而不是在 Next.js 中运行,所以使用 MSW 配合 Storybook 没有问题。

首先,让我们将 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 handler 来读取数据库并生成所需的网络响应
  3. 编写 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());
  }];
}
A Hacker Next feed featuring 30 posts

是时候测试了

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

0:00
/0:21

除了将您的 UI 集中到一个地方外,您还可以以其他方式测试 Hacker Next。

例如,您可以使用 Storybook 的 play 函数为 Hacker Next 的点赞和折叠评论状态编写 story。这是一个模拟用户交互的代码片段,在 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. 查看与设计文件相邻的生产 story,以确保顺畅的交接
  5. 通过对整个前端架构进行动态且全面的文档化,让新开发人员快速上手
  6. 详细了解在 Storybook 中使用 Next.js

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

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

鸣谢

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

加入 Storybook 邮件列表

获取最新消息、更新和发布信息

7,468开发者及更多

我们正在招聘!

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

查看职位

热门帖子

React Native Storybook 7

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

Storybook 8 Beta

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

Storybook 在 2024 年的未来

2023 年的亮点以及未来的展望
loading
Michael Shilman
加入社区
7,468开发者及更多
原因为什么选择 Storybook组件驱动的 UI
文档指南教程更新日志遥测
社区插件参与进来博客
展示探索项目组件词汇表
开源软件
Storybook - Storybook 中文

特别感谢 Netlify CircleCI