回到部落格

在 Storybook 中使用 React Server Components 和 Mock Service Worker 建構 Next.js 應用程式

使用 MSW 模擬網路請求,在隔離環境中開發、記錄和測試 RSC 應用程式

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 會自行測試!

接下來,我們將探索如何在隔離環境中使用 Next.js App Router 建構整個應用程式,方法是在 Storybook 中重建 Hacker Next 範例,並借助 Mock Service Worker

為什麼要在隔離環境中建構頁面?

令人驚訝的是,僅僅兩個頁面就能容納如此多的 UI。考慮您的頁面需要的資料狀態。然後,將它們乘以響應式佈局、登入視圖、主題、瀏覽器、地區設定和輔助功能。少數幾個頁面很容易變成數百個變體。

Storybook 透過讓您將任何 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 執行此操作。如果您遇到困難,請查看我們的 repo

使用 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.dev.org.tw',
            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 處理程序

接下來,我們將使用從資料庫讀取的 MSW 處理程序更新 .storybook/preview.tsx。這些處理程序在您的所有故事中都可用,並讀取資料庫中的任何內容。這表示故事的唯一工作是用有用的資料填充資料庫!

// .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 故事。此 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.dev.org.tw',
      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 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])
  }
}

整合在一起

在本練習中,我們編catalog了 Next.js 應用程式的關鍵 UI 狀態。一旦我們將所有這些都放入 Storybook 中,我們就可以

  1. 即使我們的後端正在開發中,也可以針對模擬資料進行開發
  2. 開發難以觸及的 UI 狀態,例如「信用卡已過期」畫面
  3. 在每個畫面上立即執行視覺迴歸和 a11y 測試,跨瀏覽器和不同解析度進行測試
  4. 將生產故事與其設計檔案一起檢視,以確保順利交接
  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