返回Storybook 简介
章节
  • 开始上手
  • 简单的组件
  • 复合组件
  • 数据
  • 页面
  • 部署
  • 视觉测试
  • 可访问性测试
  • 结论
  • 贡献

构建一个屏幕

用组件构建一个页面

我们专注于从底层构建 UI,从小处着手,逐步增加复杂性。这样做使我们能够单独开发每个组件,弄清楚它的数据需求,并在 Storybook 中进行测试。所有这些都不需要启动服务器或构建屏幕!

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

关联页面

由于我们的应用程序很简单,我们将构建的屏幕也非常基础。它只是从远程 API 获取数据,将 TaskList 组件(该组件通过自定义存储和 $state rune 提供自己的数据)包装在某些布局中,并从存储中提取顶层 error 字段(假设在连接服务器出现问题时我们会设置该字段)。

我们将从更新存储(在 src/lib/state/store.svelte.ts 中)开始,以便连接到远程 API 并处理我们应用程序的各种状态(即 errorsucceeded)。

复制
src/lib/state/store.svelte.ts
// A simple Svelte state management implementation using runes update methods and initial data.
// A true app would be more complex and separated into different files.
import type { TaskData } from '../../types';

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

const initialState: TaskBoxState = {
  tasks: [],
  status: 'idle',
  error: null,
};


export const store: TaskBoxState = $state(initialState);

// Function that fetches tasks from the API to populate the store
export async function fetchTasks() {
  store.status = 'loading';
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos?userId=1');
    const data = await response.json();
    // Transform the data to match the TaskData type
    const result = data.map(
      (task: { id: number; title: string; completed: boolean }) => ({
        id: `${task.id}`,
        title: task.title,
        state: task.completed ? 'TASK_ARCHIVED' : 'TASK_INBOX',
      })
    ).filter(
      (task: TaskData) => task.state === 'TASK_INBOX' || task.state === 'TASK_PINNED');


    store.tasks = result;
    store.status = 'succeeded';
  } catch (error) {
    if (error && typeof error === 'object' && 'message' in error) {
      store.error = (error as { message: string }).message;
    } else {
      store.error = String(error);
    }
    store.status = 'failed';
  }
}

// Function that archives a task
export function archiveTask(id: string) {
  const filteredTasks = store.tasks
    .map((task): TaskData =>
      task.id === id ? { ...task, state: 'TASK_ARCHIVED' as TaskData['state'] } : task
    )
    .filter((t) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED');
  store.tasks = filteredTasks;
}

// Function that pins a task
export function pinTask(id: string) {
  const task = store.tasks.find((task) => task.id === id);
  if (task) {
    task.state = "TASK_PINNED";
  }
}

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

复制
src/lib/components/InboxScreen.svelte
<script lang="ts">
  import TaskList from './TaskList.svelte';

  import { fetchTasks, store } from '../state/store.svelte';

  $effect(() => {
    fetchTasks();
  });
</script>

{#if store.status === "failed"}
  <div class="page lists-show">
    <div class="wrapper-message">
      <span class="icon-face-sad"></span>
      <p class="title-message">Oh no!</p>
      <p class="subtitle-message">Something went wrong</p>
    </div>
  </div>
{:else}
  <div class="page lists-show">
    <nav>
      <h1 class="title-page">Taskbox</h1>
    </nav>
    <TaskList />
  </div>
{/if}

我们还需要更改 App 组件以渲染 InboxScreen(最终,我们将使用路由器来选择正确的屏幕,但现在我们暂时不考虑这个问题)。

复制
src/App.svelte
<script lang="ts">
  import InboxScreen from "./lib/components/InboxScreen.svelte";
</script>

<InboxScreen />

最后是 src/main.ts

复制
src/main.ts
import { mount } from 'svelte';

- import './app.css';
+ import './index.css';

import App from './App.svelte';

const app = mount(App, {
  target: document.getElementById('app')!,
});

export default app;

然而,事情变得有趣的是在 Storybook 中渲染组件。

正如我们之前看到的,TaskList 组件是一个 **容器**,它渲染 PureTaskList 表示组件。根据定义,容器组件不能孤立渲染;它们期望被传入一些上下文或连接到服务。这意味着要在 Storybook 中渲染一个容器,我们必须模拟它所需的上下文或服务。

当我们将 TaskList 放入 Storybook 时,我们通过仅渲染 PureTaskList 并避免容器来规避了这个问题。然而,随着应用程序的增长,将连接的组件排除在 Storybook 之外并为每个组件创建表示组件很快就会变得难以管理。由于我们的 InboxScreen 是一个连接的组件,我们将需要提供一种方法来模拟它提供的存储和数据。

因此,当我们在 InboxScreen.stories.svelte 中设置故事时:

复制
src/lib/components/InboxScreen.stories.svelte
<script module>
  import { defineMeta } from '@storybook/addon-svelte-csf';

  import InboxScreen from './InboxScreen.svelte';

  const { Story } = defineMeta({
    component: InboxScreen,
    title: 'InboxScreen',
    tags: ['autodocs'],
  });
</script>

<Story name="Default" />

<Story name="Error" />

我们可以很快发现 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/svelte-vite';

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

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

// Registers the msw addon
+ initialize();

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

export default preview;

最后,更新 `InboxScreen` stories 并包含一个 参数 来模拟远程 API 调用。

复制
src/lib/components/InboxScreen.stories.svelte
<script module>
  import { defineMeta } from '@storybook/addon-svelte-csf';

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

  import InboxScreen from './InboxScreen.svelte';

  import * as PureTaskListStories from './PureTaskList.stories.svelte';

  const { Story } = defineMeta({
    component: InboxScreen,
    title: 'InboxScreen',
    tags: ['autodocs'],
  });
</script>

<Story
  name="Default"
+   parameters={{
+     msw: {
+       handlers: [
+         http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
+           return HttpResponse.json(PureTaskListStories.TaskListData);
+         }),
+       ],
+     },
+   }}
/>

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

💡 顺便说一句,将数据向下传递到层级结构是一种合法的方法,尤其是在使用 GraphQL 时。这就是我们构建 Chromatic 以及 800 多个 stories 的方式。

检查您的 Storybook,您会发现 Error 故事现在按预期工作。MSW 拦截了我们的远程 API 调用并提供了相应的响应。

交互测试

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

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

使用 play 函数编写交互测试

Storybook 的 play 函数可以帮助我们。play 函数包含一些在 story 渲染后运行的小代码片段。它使用框架无关的 DOM API,这意味着我们可以使用 play 函数编写 story 来与 UI 进行交互,并模拟人类行为,无论前端框架如何。我们将使用它们来验证当我们更新任务时 UI 是否按预期运行。

更新你新创建的 `InboxScreen` story,并通过添加以下内容来设置组件交互:

复制
src/lib/components/InboxScreen.stories.svelte
<script module>
  import { defineMeta } from '@storybook/addon-svelte-csf';

  import { http, HttpResponse } from 'msw';

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

  import InboxScreen from './InboxScreen.svelte';

  import * as PureTaskListStories from './PureTaskList.stories.svelte';

  const { Story } = defineMeta({
    component: InboxScreen,
    title: 'InboxScreen',
    tags: ['autodocs'],
  });
</script>

<Story
  name="Default"
  parameters={{
    msw: {
      handlers: [
        http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
          return HttpResponse.json(PureTaskListStories.TaskListData);
        }),
      ],
    },
  }}
+ play={async ({ canvas, userEvent }) => {
+   await waitForElementToBeRemoved(await canvas.findByTestId("loading"));
+   await waitFor(async () => {
+     await userEvent.click(canvas.getByLabelText("pinTask-1"));
+     await userEvent.click(canvas.getByLabelText("pinTask-3"));
+   });
+ }}
/>

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

💡 `Interactions` 面板帮助我们可视化 Storybook 中的测试,提供一个逐步流程。它还提供了一组方便的 UI 控件来暂停、恢复、倒带和逐步执行每个交互。

检查 `Default` story。点击 `Interactions` 面板以查看 story 的 play 函数中的交互列表。

使用 Vitest 插件自动化测试

借助 play 函数,我们可以快速模拟组件的用户交互,并验证我们在更新任务时它的行为——保持 UI 的一致性。然而,如果我们看看我们的 Storybook,我们可以看到我们的交互测试只在查看 story 时运行。这意味着,如果我们进行更改,我们仍然必须手动检查每个 story 来运行所有检查。我们不能自动化它吗?

我们可以!Storybook 的 Vitest 插件 允许我们以更自动化的方式运行交互测试,利用 Vitest 的强大功能来获得更快、更高效的测试体验。让我们看看它是如何工作的!

当你的 Storybook 运行时,点击侧边栏中的“运行测试”。这将对我们的 stories、它们的渲染方式、它们的行为以及 play 函数中定义的交互进行测试,包括我们刚刚添加到 `InboxScreen` story 的那个。

💡 Vitest 插件可以做的远不止我们在这里看到的,包括其他类型的测试。我们建议阅读 官方文档 以了解更多关于它的信息。

现在,我们有了一个工具,可以帮助我们自动化 UI 测试,而无需手动检查。这是在继续构建应用程序时确保 UI 保持一致和功能强大的好方法。更重要的是,如果我们的测试失败,我们将立即收到通知,使我们能够快速轻松地修复任何未解决的问题。

组件驱动开发

我们从最底层的 Task 开始,然后进展到 TaskList,现在我们拥有了一个完整的屏幕 UI。我们的 InboxScreen 容纳了连接的组件,并附带了相应的测试。

组件驱动开发 允许你在向上移动组件层级时逐步增加复杂性。其优点包括更集中的开发流程和对所有可能的 UI 排列的更高覆盖率。总之,CDD 有助于构建更高质量、更复杂的 UI。

我们还没有完成——工作并不会在 UI 构建完成后就结束。我们还需要确保它在长时间内保持稳定。

💡 别忘了使用 git 提交你的更改!
这个免费指南对您有帮助吗?请在 Twitter 上分享以表示赞赏,并帮助其他开发者发现它。
下一章
部署
了解如何将 Storybook 部署到在线
✍️ 在 GitHub 上编辑 – PR 欢迎!
加入社区
7,424开发者及更多
原因为什么选择 Storybook组件驱动的 UI
文档指南教程更新日志遥测
社区插件参与进来博客
展示探索关于
开源软件
Storybook - Storybook 中文

特别感谢 Netlify CircleCI