连接数据
到目前为止,我们已经创建了隔离的无状态组件——这对于 Storybook 来说很棒,但最终在我们给它们在应用程序中使用的一些数据之前,它们并没有什么用处。
本教程不关注构建应用程序的细节,因此我们不会在此处深入探讨这些细节。但是,我们将花一点时间来了解使用容器组件连接数据的常用模式。
容器组件
我们当前编写的 TaskList
组件是“展示型”的,因为它不与自身实现之外的任何事物对话。为了将数据导入其中,我们需要一个“容器”。
此示例使用 ngxs,这是一个采纳了 Redux/ngrx 原则的库,但侧重于减少样板代码,并提供更angular-y的状态管理方式,从而为我们的应用程序构建一个简单的数据模型。但是,此处使用的模式同样适用于其他数据管理库,如 ngrx/store 或 Apollo。
使用以下命令将必要的依赖项添加到你的项目中
npm install @ngxs/store @ngxs/logger-plugin @ngxs/devtools-plugin
首先,我们将在 src/app/state
目录(有意保持简单)中创建一个名为 task.state.ts
的文件,创建一个简单的 store,以响应更改任务状态的操作
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
中
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
中
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
的新文件,并添加以下内容
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
)
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 使用展示型版本
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,
},
};
现在我们已经从 store 获取了一些实际数据来填充我们的组件,我们可以将其连接到 src/app/app.component.ts
并在那里渲染组件。不用担心。我们将在下一章中处理它。