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

连接数据

了解如何将数据连接到你的 UI 组件

到目前为止,我们已经创建了隔离的无状态组件——这对于 Storybook 来说很棒,但最终在我们给它们在应用程序中使用的一些数据之前,它们并没有什么用处。

本教程不关注构建应用程序的细节,因此我们不会在此处深入探讨这些细节。但是,我们将花一点时间来了解使用容器组件连接数据的常用模式。

容器组件

我们当前编写的 TaskList 组件是“展示型”的,因为它不与自身实现之外的任何事物对话。为了将数据导入其中,我们需要一个“容器”。

此示例使用 ngxs,这是一个采纳了 Redux/ngrx 原则的库,但侧重于减少样板代码,并提供更angular-y的状态管理方式,从而为我们的应用程序构建一个简单的数据模型。但是,此处使用的模式同样适用于其他数据管理库,如 ngrx/storeApollo

使用以下命令将必要的依赖项添加到你的项目中

复制
npm install @ngxs/store @ngxs/logger-plugin @ngxs/devtools-plugin

首先,我们将在 src/app/state 目录(有意保持简单)中创建一个名为 task.state.ts 的文件,创建一个简单的 store,以响应更改任务状态的操作

复制
src/app/state/task.state.ts
import { Injectable } from '@angular/core';
import { State, Selector, Action, StateContext } from '@ngxs/store';
import { patch, updateItem } from '@ngxs/store/operators';
import { Task } from '../models/task.model';

// Defines the actions available to the app
export const actions = {
  ARCHIVE_TASK: 'ARCHIVE_TASK',
  PIN_TASK: 'PIN_TASK',
};

export class ArchiveTask {
  static readonly type = actions.ARCHIVE_TASK;

  constructor(public payload: string) {}
}

export class PinTask {
  static readonly type = actions.PIN_TASK;

  constructor(public payload: string) {}
}

// The initial state of our store when the app loads.
// Usually you would fetch this from a server
const defaultTasks = [
  { id: '1', title: 'Something', state: 'TASK_INBOX' },
  { id: '2', title: 'Something more', state: 'TASK_INBOX' },
  { id: '3', title: 'Something else', state: 'TASK_INBOX' },
  { id: '4', title: 'Something again', state: 'TASK_INBOX' },
];

export interface TaskStateModel {
  tasks: Task[];
  status: 'idle' | 'loading' | 'success' | 'error';
  error: boolean;
}

// Sets the default state
@State<TaskStateModel>({
  name: 'taskbox',
  defaults: {
    tasks: defaultTasks,
    status: 'idle',
    error: false,
  },
})
@Injectable()
export class TasksState {
  // Defines a new selector for the error field
  @Selector()
  static getError(state: TaskStateModel): boolean {
    return state.error;
  }

  @Selector()
  static getAllTasks(state: TaskStateModel): Task[] {
    return state.tasks;
  }

  // Triggers the PinTask action, similar to redux
  @Action(PinTask)
  pinTask(
    { getState, setState }: StateContext<TaskStateModel>,
    { payload }: PinTask
  ) {
    const task = getState().tasks.find((task) => task.id === payload);

    if (task) {
      const updatedTask: Task = {
        ...task,
        state: 'TASK_PINNED',
      };
      setState(
        patch({
          tasks: updateItem<Task>(
            (pinnedTask) => pinnedTask?.id === payload,
            updatedTask
          ),
        })
      );
    }
  }
  // Triggers the archiveTask action, similar to redux
  @Action(ArchiveTask)
  archiveTask(
    { getState, setState }: StateContext<TaskStateModel>,
    { payload }: ArchiveTask
  ) {
    const task = getState().tasks.find((task) => task.id === payload);
    if (task) {
      const updatedTask: Task = {
        ...task,
        state: 'TASK_ARCHIVED',
      };
      setState(
        patch({
          tasks: updateItem<Task>(
            (archivedTask) => archivedTask?.id === payload,
            updatedTask
          ),
        })
      );
    }
  }
}

然后,我们将更新我们的 TaskList 组件以从 store 读取数据。首先,让我们将现有的展示型版本移动到文件 src/app/components/pure-task-list.component.ts 并用容器包装它。

src/app/components/pure-task-list.component.ts

复制
src/app/components/pure-task-list.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Task } from '../models/task.model';

@Component({
- selector:'app-task-list',
+ selector: 'app-pure-task-list',
  standalone: false,
  template: `
    <div class="list-items">
      <app-task
        *ngFor="let task of tasksInOrder"
        [task]="task"
        (onArchiveTask)="onArchiveTask.emit($event)"
        (onPinTask)="onPinTask.emit($event)"
      >
      </app-task>
      <div
        *ngIf="tasksInOrder.length === 0 && !loading"
        class="wrapper-message"
      >
        <span class="icon-check"></span>
        <p class="title-message">You have no tasks</p>
        <p class="subtitle-message">Sit back and relax</p>
      </div>
      <div *ngIf="loading">
        <div *ngFor="let i of [1, 2, 3, 4, 5, 6]" class="loading-item">
          <span class="glow-checkbox"></span>
          <span class="glow-text">
            <span>Loading</span> <span>cool</span> <span>state</span>
          </span>
        </div>
      </div>
    </div>
  `,
})
- export default class TaskListComponent {
+ export default class PureTaskListComponent {
    /**
     * @ignore
     * Component property to define ordering of tasks
    */
    tasksInOrder: Task[] = [];

    @Input() loading = false;

    // tslint:disable-next-line: no-output-on-prefix
    @Output() onPinTask: EventEmitter<any> = new EventEmitter();

    // tslint:disable-next-line: no-output-on-prefix
    @Output() onArchiveTask: EventEmitter<any> = new EventEmitter();

    @Input()
    set tasks(arr: Task[]) {
      const initialTasks = [
        ...arr.filter((t) => t.state === 'TASK_PINNED'),
        ...arr.filter((t) => t.state !== 'TASK_PINNED'),
      ];
      const filteredTasks = initialTasks.filter(
        (t) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED'
      );
      this.tasksInOrder = filteredTasks.filter(
        (t) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED'
      );
    }
 }

src/app/components/task-list.component.ts

复制
src/app/components/task-list.component.ts
import { Component } from '@angular/core';
import { Store } from '@ngxs/store';
import { ArchiveTask, PinTask } from '../state/task.state';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-task-list',
  standalone: false,
  template: `
    <app-pure-task-list
      [tasks]="tasks$ | async"
      (onArchiveTask)="archiveTask($event)"
      (onPinTask)="pinTask($event)"
    ></app-pure-task-list>
  `,
})
export default class TaskListComponent {
  tasks$?: Observable<any>;

  constructor(private store: Store) {
     this.tasks$ = store.select((state) => state.taskbox.tasks);
  }

  /**
   * Component method to trigger the archiveTask event
   */
  archiveTask(id: string) {
    this.store.dispatch(new ArchiveTask(id));
  }

  /**
   * Component method to trigger the pinTask event
   */
  pinTask(id: string) {
    this.store.dispatch(new PinTask(id));
  }
}

现在我们将创建一个 Angular 模块来桥接组件和 store。

src/app/components 目录中创建一个名为 task.module.ts 的新文件,并添加以下内容

复制
src/app/components/task.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgxsModule } from '@ngxs/store';

import TaskComponent from './task.component';
import TaskListComponent from './task-list.component';
import { TasksState } from '../state/task.state';
import PureTaskListComponent from './pure-task-list.component';

@NgModule({
  imports: [CommonModule, NgxsModule.forFeature([TasksState])],
  exports: [TaskComponent, TaskListComponent],
  declarations: [TaskComponent, TaskListComponent, PureTaskListComponent],
  providers: [],
})
export class TaskModule {}

我们拥有了所需的一切。所有需要做的就是将 store 连接到应用程序。更新你的顶级模块 (src/app/app.module.ts)

复制
src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

+ import { TaskModule } from './components/task.module';
+ import { NgxsModule } from '@ngxs/store';
+ import { NgxsReduxDevtoolsPluginModule } from '@ngxs/devtools-plugin';
+ import { NgxsLoggerPluginModule } from '@ngxs/logger-plugin';

+ import { environment } from '../environments/environment';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
+   TaskModule,
+    NgxsModule.forRoot([], {
+     developmentMode: !environment.production,
+   }),
+   NgxsReduxDevtoolsPluginModule.forRoot(),
+   NgxsLoggerPluginModule.forRoot({
+     disabled: environment.production,
+   }),
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

保持 TaskList 的展示型版本分离的原因是它更容易测试和隔离。由于它不依赖于 store 的存在,因此从测试的角度来看,它更容易处理。让我们将 src/app/components/task-list.stories.ts 重命名为 src/app/components/pure-task-list.stories.ts,并确保我们的 stories 使用展示型版本

复制
src/app/components/pure-task-list.stories.ts
import type { Meta, StoryObj } from '@storybook/angular';

import { componentWrapperDecorator, moduleMetadata } from '@storybook/angular';

import { CommonModule } from '@angular/common';

import PureTaskListComponent from './pure-task-list.component';
import TaskComponent from './task.component';

import * as TaskStories from './task.stories';

const meta: Meta<PureTaskListComponent> = {
  component: PureTaskListComponent,
  title: 'PureTaskList',
  tags: ['autodocs'],
  decorators: [
    moduleMetadata({
      //👇 Imports both components to allow component composition with Storybook
      declarations: [PureTaskListComponent, TaskComponent],
      imports: [CommonModule],
    }),
    //👇 Wraps our stories with a decorator
    componentWrapperDecorator(
      (story) => `<div style="margin: 3em">${story}</div>`
    ),
  ],
  args: {
    ...TaskStories.ActionsData,
  },
};
export default meta;
type Story = StoryObj<PureTaskListComponent>;

export const Default: Story = {
  args: {
    tasks: [
      { ...TaskStories.Default.args?.task, id: '1', title: 'Task 1' },
      { ...TaskStories.Default.args?.task, id: '2', title: 'Task 2' },
      { ...TaskStories.Default.args?.task, id: '3', title: 'Task 3' },
      { ...TaskStories.Default.args?.task, id: '4', title: 'Task 4' },
      { ...TaskStories.Default.args?.task, id: '5', title: 'Task 5' },
      { ...TaskStories.Default.args?.task, id: '6', title: 'Task 6' },
    ],
  },
};

export const WithPinnedTasks: Story = {
  args: {
    tasks: [
      // Shaping the stories through args composition.
      // Inherited data coming from the Default story.
      ...(Default.args?.tasks?.slice(0, 5) || []),
      { id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
    ],
  },
};

export const Loading: Story = {
  args: {
    tasks: [],
    loading: true,
  },
};

export const Empty: Story = {
  args: {
    // Shaping the stories through args composition.
    // Inherited data coming from the Loading story.
    ...Loading.args,
    loading: false,
  },
};
💡 别忘了使用 git 提交你的更改!

现在我们已经从 store 获取了一些实际数据来填充我们的组件,我们可以将其连接到 src/app/app.component.ts 并在那里渲染组件。不用担心。我们将在下一章中处理它。

使你的代码与本章保持同步。在 GitHub 上查看 db7a1a2。
这个免费指南对您有帮助吗? 发推文以示赞赏并帮助其他开发者找到它。
下一章
屏幕
用组件构建屏幕
✍️ 在 GitHub 上编辑 – 欢迎 PR!
加入社区
6,721位开发者和计数中
为什么为什么选择 Storybook组件驱动的 UI
开源软件
Storybook - Storybook 中文

特别鸣谢 Netlify CircleCI