返回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.vuejs.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 中渲染 story。

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

复制
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 story 的问题。它没有显示正确的状态,而是显示了一个任务列表。解决此问题的一种方法是为每个状态提供一个模拟版本,类似于我们在上一章中所做的。相反,我们将使用一个著名的 API 模拟库以及一个 Storybook 插件来帮助我们解决这个问题。

Broken inbox screen state

模拟 API 服务

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

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

在您的终端中,运行以下命令以在您的 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 stories 并包含一个 参数,该参数模拟远程 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 多个 stories 的方式。

检查您的 Storybook,您会看到 error story 现在可以按预期工作了。MSW 拦截了我们的远程 API 调用并提供了适当的响应。

组件测试

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

我们是否可以自动化此工作流程并自动测试我们的组件交互?

使用 play function 编写组件测试

Storybook 的 play@storybook/addon-interactions 可以帮助我们做到这一点。

play function 帮助我们验证在任务更新时 UI 会发生什么。它使用与框架无关的 DOM API,这意味着我们可以使用 play function 编写 stories 来与 UI 交互并模拟人类行为,而无需考虑前端框架。

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

让我们看看它的实际效果!更新您新创建的 InboxScreen story,并通过添加以下内容来设置组件交互

复制
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 story。单击 Interactions 面板以查看 story 的 play function 内的交互列表。

使用 test runner 自动化测试

借助 Storybook 的 play function,我们能够避开我们的问题,使我们能够与我们的 UI 交互并快速检查如果我们更新任务,它将如何响应——以零额外的手动工作量保持 UI 的一致性。

但是,如果我们仔细查看我们的 Storybook,我们可以看到它仅在查看 story 时运行交互测试。因此,如果我们进行更改,我们仍然必须遍历每个 story 以运行所有检查。我们难道不能自动化它吗?

好消息是我们可以!Storybook 的 test runner 允许我们做到这一点。它是一个独立的实用程序——由 Playwright 提供支持——它运行我们所有的交互测试并捕获损坏的 stories。

让我们看看它是如何工作的!运行以下命令来安装它

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

接下来,更新您的 package.json scripts 并添加一个新的测试任务

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

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

复制
yarn test-storybook --watch

💡 使用 play function 进行组件测试是测试您的 UI 组件的绝佳方法。它可以做的事情比我们在这里看到的要多得多;我们建议阅读 官方文档 以了解更多信息。

要更深入地了解测试,请查看 测试手册。它涵盖了大型前端团队用于增强您的开发工作流程的测试策略。

Storybook test runner successfully runs all tests

成功!现在我们有了一个工具,可以帮助我们验证是否所有 stories 都在没有错误的情况下渲染,并且所有断言都自动通过。更重要的是,如果测试失败,它将为我们提供一个链接,该链接会在浏览器中打开失败的 story。

组件驱动开发

我们从底部的 Task 开始,然后发展到 TaskList,现在我们已经到达了整个屏幕 UI。我们的 InboxScreen 容纳了连接的组件,并包括了 accompanying stories。

组件驱动开发 允许您在组件层次结构中向上移动时逐步扩展复杂性。好处包括更集中的开发过程和增加所有可能的 UI 排列的覆盖率。简而言之,CDD 帮助您构建更高质量和更复杂的用户界面。

我们还没有完成 - 工作不会在 UI 构建完成时结束。我们还需要确保它随着时间的推移保持持久。

💡 别忘了用 git 提交您的更改!
使您的代码与本章保持同步。在 GitHub 上查看 6262d7f。
这个免费指南对您有帮助吗?发推文表示赞赏并帮助其他开发人员找到它。
下一章
部署
了解如何在线部署 Storybook
✍️ 在 GitHub 上编辑 – 欢迎 PR!
加入社区
6,721开发者计数中
为什么为什么选择 Storybook组件驱动的 UI
开源软件
Storybook - Storybook 中文

特别感谢 Netlify CircleCI