返回Storybook 入门
章节
  • 开始
  • 简单组件
  • 复合组件
  • 数据
  • 屏幕
  • 部署
  • 视觉测试
  • 插件
  • 结论
  • 贡献

构建屏幕

使用组件构建屏幕

我们一直专注于自下而上地构建 UI,从小型组件开始,逐步增加复杂性。这样做使我们能够在隔离的环境中开发每个组件,弄清楚它的数据需求,并在 Storybook 中进行测试。所有这些都不需要搭建服务器或构建完整屏幕!

在本章中,我们将通过在屏幕中组合组件并在 Storybook 中开发该屏幕来继续提高复杂性。

连接的屏幕

由于我们的应用比较简单,我们将要构建的屏幕也相当简单,仅从远程 API 获取数据,包装 TaskList 组件(它从 Redux 提供自己的数据),并从 Redux 中提取顶级的 error 字段。

我们将首先更新 Redux 存储(在 src/lib/store.ts 中),使其连接到远程 API 并处理应用程序的各种状态(例如,errorsucceeded

复制
src/lib/store.ts
/* A simple redux store/actions/reducer implementation.
 * A true app would be more complex and separated into different files.
 */
import type { TaskData } from '../types';

import {
  configureStore,
  createSlice,
  createAsyncThunk,
  PayloadAction,
} from '@reduxjs/toolkit';

interface TaskBoxState {
  tasks: TaskData[];
  status: 'idle' | 'loading' | 'failed' | 'succeeded';
  error: string | null;
}

/*
 * The initial state of our store when the app loads.
 * Usually, you would fetch this from a server. Let's not worry about that now
 */
const TaskBoxData: TaskBoxState = {
  tasks: [],
  status: 'idle',
  error: null,
};
/*
 * Creates an asyncThunk to fetch tasks from a remote endpoint.
 * You can read more about Redux Toolkit's thunks in the docs:
 * https://toolkit.redux.js.cn/api/createAsyncThunk
 */
export const fetchTasks = createAsyncThunk('taskbox/fetchTasks', async () => {
  const response = await fetch(
    'https://jsonplaceholder.typicode.com/todos?userId=1'
  );
  const data = await response.json();
  const result = data.map(
    (task: { id: number; title: string; completed: boolean }) => ({
      id: `${task.id}`,
      title: task.title,
      state: task.completed ? 'TASK_ARCHIVED' : 'TASK_INBOX',
    })
  );
  return result;
});

/*
 * The store is created here.
 * You can read more about Redux Toolkit's slices in the docs:
 * https://toolkit.redux.js.cn/api/createSlice
 */
const TasksSlice = createSlice({
  name: 'taskbox',
  initialState: TaskBoxData,
  reducers: {
    updateTaskState: (
      state,
      action: PayloadAction<{ id: string; newTaskState: TaskData['state'] }>
    ) => {
      const task = state.tasks.find((task) => task.id === action.payload.id);
      if (task) {
        task.state = action.payload.newTaskState;
      }
    },
  },
  /*
   * Extends the reducer for the async actions
   * You can read more about it at https://toolkit.redux.js.cn/api/createAsyncThunk
   */
  extraReducers(builder) {
    builder
      .addCase(fetchTasks.pending, (state) => {
        state.status = 'loading';
        state.error = null;
        state.tasks = [];
      })
      .addCase(fetchTasks.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.error = null;
        // Add any fetched tasks to the array
        state.tasks = action.payload;
      })
      .addCase(fetchTasks.rejected, (state) => {
        state.status = 'failed';
        state.error = 'Something went wrong';
        state.tasks = [];
      });
  },
});

// The actions contained in the slice are exported for usage in our components
export const { updateTaskState } = TasksSlice.actions;

/*
 * Our app's store configuration goes here.
 * Read more about Redux's configureStore in the docs:
 * https://toolkit.redux.js.cn/api/configureStore
 */
const store = configureStore({
  reducer: {
    taskbox: TasksSlice.reducer,
  },
});

// Define RootState and AppDispatch types
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

export default store;

既然我们已经更新了存储来从远程 API 端点检索数据并准备好处理应用程序的各种状态,现在让我们在 src/components 目录中创建 InboxScreen.tsx

复制
src/components/InboxScreen.tsx
import { useEffect } from 'react';

import { useDispatch, useSelector } from 'react-redux';

import { AppDispatch, fetchTasks, RootState } from '../lib/store';

import TaskList from "./TaskList";

export default function InboxScreen() {
  const dispatch = useDispatch<AppDispatch>();
  // We're retrieving the error field from our updated store
  const { error } = useSelector((state: RootState) => state.taskbox);
  // The useEffect triggers the data fetching when the component is mounted
  useEffect(() => {
    dispatch(fetchTasks());
  }, []);

  if (error) {
    return (
      <div className="page lists-show">
        <div className="wrapper-message">
          <span className="icon-face-sad" />
          <p className="title-message">Oh no!</p>
          <p className="subtitle-message">Something went wrong</p>
        </div>
      </div>
    );
  }
  return (
    <div className="page lists-show">
      <nav>
        <h1 className="title-page">Taskbox</h1>
      </nav>
      <TaskList />
    </div>
  );
}

我们还需要更改我们的 App 组件来渲染 InboxScreen(最终我们会使用路由器来选择正确的屏幕,但在此处无需担心)

复制
src/App.tsx
- import { useState } from 'react'
- import reactLogo from './assets/react.svg'
- import viteLogo from '/vite.svg'
- import './App.css'

+ import './index.css';
+ import store from './lib/store';

+ import { Provider } from 'react-redux';
+ import InboxScreen from './components/InboxScreen';

function App() {
- const [count, setCount] = useState(0)
  return (
-   <div className="App">
-     <div>
-       <a href="https://vite.ac.cn" target="_blank">
-         <img src={viteLogo} className="logo" alt="Vite logo" />
-       </a>
-       <a href="https://reactjs.ac.cn" target="_blank">
-         <img src={reactLogo} className="logo react" alt="React logo" />
-       </a>
-     </div>
-     <h1>Vite + React</h1>
-     <div className="card">
-       <button onClick={() => setCount((count) => count + 1)}>
-         count is {count}
-       </button>
-       <p>
-         Edit <code>src/App.jsx</code> and save to test HMR
-       </p>
-     </div>
-     <p className="read-the-docs">
-       Click on the Vite and React logos to learn more
-     </p>
-   </div>
+   <Provider store={store}>
+     <InboxScreen />
+   </Provider>
  );
}
export default App;

然而,有趣的部分在于在 Storybook 中渲染故事。

正如我们之前所见,TaskList 组件现在是一个 连接 组件,它依赖于 Redux 存储来渲染任务。由于我们的 InboxScreen 也是一个连接组件,我们将做类似的事情,为故事提供一个存储。所以当我们在 InboxScreen.stories.tsx 中设置我们的故事时

复制
src/components/InboxScreen.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';

import InboxScreen from './InboxScreen';

import store from '../lib/store';

import { Provider } from 'react-redux';

const meta = {
  component: InboxScreen,
  title: 'InboxScreen',
  decorators: [(story) => <Provider store={store}>{story()}</Provider>],
  tags: ['autodocs'],
} satisfies Meta<typeof InboxScreen>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const Error: Story = {};

我们可以快速发现 error 故事存在一个问题。它没有显示正确的状态,而是显示了一个任务列表。绕过这个问题的一种方法是为每种状态提供一个模拟版本,类似于我们在上一章中做的那样。相反,我们将使用一个知名的 API 模拟库以及一个 Storybook 插件来帮助我们解决这个问题。

Broken inbox screen state

模拟 API 服务

由于我们的应用程序相当简单,并且不太依赖远程 API 调用,我们将使用 Mock Service WorkerStorybook 的 MSW 插件。Mock Service Worker 是一个 API 模拟库。它依赖于 Service Worker 捕获网络请求并在响应中提供模拟数据。

当我们在开始部分设置应用程序时,这两个包也已安装。剩下的就是配置它们并更新我们的故事来使用它们。

在你的终端中,运行以下命令在你的 public 文件夹内生成一个通用的 Service Worker

复制
yarn init-msw

然后,我们需要更新我们的 .storybook/preview.ts 并初始化它们

复制
.storybook/preview.ts
import type { Preview } from '@storybook/react';

import { initialize, mswLoader } from 'msw-storybook-addon';

import '../src/index.css';

// Registers the msw addon
initialize();

const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/,
      },
    },
  },
  loaders: [mswLoader],
};

export default preview;

最后,更新 InboxScreen 故事并包含一个模拟远程 API 调用的参数

复制
src/components/InboxScreen.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';

import InboxScreen from './InboxScreen';

import store from '../lib/store';

+ import { http, HttpResponse } from 'msw';

+ import { MockedState } from './TaskList.stories';

import { Provider } from 'react-redux';

const meta = {
  component: InboxScreen,
  title: 'InboxScreen',
  decorators: [(story) => <Provider store={store}>{story()}</Provider>],
  tags: ['autodocs'],
} satisfies Meta<typeof InboxScreen>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
+ parameters: {
+   msw: {
+     handlers: [
+       http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
+         return HttpResponse.json(MockedState.tasks);
+       }),
+     ],
+   },
+ },
};

export const Error: Story = {
+ parameters: {
+   msw: {
+     handlers: [
+       http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
+         return new HttpResponse(null, {
+           status: 403,
+         });
+       }),
+     ],
+   },
+ },
};

💡 顺带一提,将数据向下传递到层次结构是有效的方法,特别是在使用 GraphQL 时。我们就是这样构建了Chromatic,并为其创建了 800 多个故事。

检查你的 Storybook,你会看到 error 故事现在如预期般工作了。MSW 拦截了我们的远程 API 调用并提供了适当的响应。

组件测试

到目前为止,我们已经能够从零开始构建一个功能齐全的应用程序,从一个简单的组件到一个屏幕,并使用我们的故事不断测试每次更改。但是每个新故事都需要手动检查所有其他故事,以确保 UI 不会崩溃。这增加了大量额外的工作。

我们不能自动化这个流程并自动测试组件交互吗?

使用 play 函数编写组件测试

Storybook 的 play@storybook/addon-interactions 可以帮助我们做到这一点。play 函数包含在故事渲染后运行的一小段代码片段。

play 函数帮助我们验证任务更新时 UI 会发生什么变化。它使用与框架无关的 DOM API,这意味着无论前端框架是什么,我们都可以使用 play 函数编写故事来与 UI 交互并模拟人类行为。

@storybook/addon-interactions 帮助我们在 Storybook 中可视化测试,提供一步一步的流程。它还提供了一组方便的 UI 控制,用于暂停、恢复、回退和单步执行每个交互。

来看看它是如何工作的吧!更新你新创建的 InboxScreen 故事,并通过添加以下内容设置组件交互

复制
src/components/InboxScreen.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';

import InboxScreen from './InboxScreen';

import store from '../lib/store';

import { http, HttpResponse } from 'msw';

import { MockedState } from './TaskList.stories';

import { Provider } from 'react-redux';

+ import {
+  fireEvent,
+  waitFor,
+  within,
+  waitForElementToBeRemoved
+ } from '@storybook/test';

const meta = {
  component: InboxScreen,
  title: 'InboxScreen',
  decorators: [(story) => <Provider store={store}>{story()}</Provider>],
  tags: ['autodocs'],
} satisfies Meta<typeof InboxScreen>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
          return HttpResponse.json(MockedState.tasks);
        }),
      ],
    },
  },
+ play: async ({ canvasElement }) => {
+   const canvas = within(canvasElement);
+   // Waits for the component to transition from the loading state
+   await waitForElementToBeRemoved(await canvas.findByTestId('loading'));
+   // Waits for the component to be updated based on the store
+   await waitFor(async () => {
+     // Simulates pinning the first task
+     await fireEvent.click(canvas.getByLabelText('pinTask-1'));
+     // Simulates pinning the third task
+     await fireEvent.click(canvas.getByLabelText('pinTask-3'));
+   });
+ },
};

export const Error: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
          return new HttpResponse(null, {
            status: 403,
          });
        }),
      ],
    },
  },
};

💡 @storybook/test 包取代了 @storybook/jest@storybook/testing-library 测试包,提供了更小的包体积和基于 Vitest 包的更直接的 API。

检查 Default 故事。点击 Interactions 面板查看故事 play 函数中的交互列表。

使用测试运行器自动化测试

借助 Storybook 的 play 函数,我们能够绕过问题,允许我们与 UI 交互并快速检查更新任务时它的响应——无需额外手动操作即可保持 UI 一致性。

但是,如果我们仔细查看 Storybook,会发现它只在查看故事时运行交互测试。因此,如果我们做了改动,仍然必须遍历每个故事来运行所有检查。我们不能自动化它吗?

好消息是,我们可以!Storybook 的测试运行器就可以做到这一点。它是一个独立的工具——由 Playwright 提供支持——可以运行我们所有的交互测试并捕获损坏的故事。

来看看它是如何工作的吧!运行以下命令进行安装

复制
yarn add --dev @storybook/test-runner

接下来,更新你的 package.jsonscripts 并添加一个新的测试任务

{
  "scripts": {
    "test-storybook": "test-storybook"
  }
}

最后,在 Storybook 运行的情况下,打开一个新的终端窗口并运行以下命令

复制
yarn test-storybook --watch

💡 使用 play 函数进行组件测试是测试 UI 组件的绝佳方法。它的功能远不止我们在此处展示的;我们建议阅读官方文档以了解更多信息。

要更深入地了解测试,请查阅测试手册。它涵盖了大型前端团队用来提升开发工作流程的测试策略。

Storybook test runner successfully runs all tests

成功!现在我们有了一个工具,可以帮助我们验证所有故事是否无误地渲染,并且所有断言都自动通过。更棒的是,如果测试失败,它会提供一个链接,在浏览器中打开失败的故事。

组件驱动开发

我们从底层的 Task 开始,然后发展到 TaskList,现在我们拥有了一个完整的屏幕 UI。我们的 InboxScreen 容纳了连接组件,并包含了配套的故事。

组件驱动开发 (CDD) 使您能够在组件层次结构中逐步增加复杂性。其优势包括更专注的开发过程以及增加对所有可能的 UI 组合的覆盖范围。简而言之,CDD 可帮助您构建更高质量、更复杂的用户界面。

我们还没有完成——工作并不止于 UI 构建完成。我们还需要确保它随着时间的推移保持稳定可靠。

💡 不要忘记使用 git 提交你的更改!
保持你的代码与本章同步。在 GitHub 上查看 6262d7f。
这份免费指南对你有帮助吗?发推文表示赞赏,帮助其他开发者找到它。
下一章
部署
学习如何在线部署 Storybook
✍️ 在 GitHub 上编辑 – 欢迎提交 PR!
加入社区
6,975名开发者及仍在增加
缘何选择为何选择 Storybook组件驱动的 UI
开源软件
Storybook - Storybook 中文

特别鸣谢 Netlify CircleCI