集成数据
到目前为止,我们已经创建了独立的无状态组件——非常适合 Storybook,但直到我们为它们提供一些应用程序中的数据,它们最终都没有多大用处。
本次教程不侧重于构建应用程序的细节,所以我们在这里不深入探讨。但我们会花点时间来看一个常见的模式,即如何通过容器组件来连接数据。
容器组件
在本教程中,我们将使用 Angular 的 Signals,这是一个强大的响应式系统,提供了显式、细粒度的响应式原始类型来实现一个简单的 store。我们将使用 signal 来构建应用程序的简单数据模型,并帮助我们管理任务的状态。
首先,我们将构建一个简单的 store,它响应在 src/app/state 目录下的 store.ts 文件中的操作,这些操作会改变任务的状态(故意保持简单)。
// A simple Angular state management implementation using signals update methods and initial data.
// A true app would be more complex and separated into different files.
import type { TaskData } from '../types';
import { Injectable, signal, computed } from '@angular/core';
interface TaskBoxState {
tasks: TaskData[];
status: 'idle' | 'loading' | 'error' | 'success';
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 defaultTasks: TaskData[] = [
{ 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' },
];
const initialState: TaskBoxState = {
tasks: defaultTasks,
status: 'idle',
error: null,
};
@Injectable({
providedIn: 'root',
})
export class Store {
private state = signal<TaskBoxState>(initialState);
// Public readonly signal for components to subscribe to
readonly tasks = computed(() => this.state().tasks);
readonly status = computed(() => this.state().status);
readonly error = computed(() => this.state().error);
readonly getFilteredTasks = computed(() => {
const filteredTasks = this.state().tasks.filter(
(t) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED'
);
return filteredTasks;
});
archiveTask(id: string): void {
this.state.update((currentState) => {
const filteredTasks = currentState.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');
return {
...currentState,
tasks: filteredTasks,
};
});
}
pinTask(id: string): void {
this.state.update((currentState) => ({
...currentState,
tasks: currentState.tasks.map((task) =>
task.id === id ? { ...task, state: 'TASK_PINNED' } : task
),
}));
}
}
然后,我们将更新我们的 TaskList 来从 store 中读取数据。首先,让我们将现有的展示型版本移到 src/app/components/pure-task-list.component.ts 文件中,并用一个容器包裹它。
在 src/app/components/pure-task-list.component.ts 中
/* This file was moved from task-list.component.ts */
import type { TaskData } from '../types';
import { CommonModule } from '@angular/common';
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { TaskComponent } from './task.component';
@Component({
selector: 'app-pure-task-list',
standalone: true,
imports: [CommonModule, TaskComponent],
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"
data-testid="empty"
>
<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 class PureTaskListComponent {
/**
* @ignore
* Component property to define ordering of tasks
*/
tasksInOrder: TaskData[] = [];
/**
* Checks if it's in loading state
*/
@Input() loading = false;
/**
* Event to change the task to pinned
*/
@Output()
onPinTask = new EventEmitter<Event>();
/**
* Event to change the task to archived
*/
@Output()
onArchiveTask = new EventEmitter<Event>();
/**
* The list of tasks
*/
@Input()
set tasks(arr: TaskData[]) {
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 { CommonModule } from '@angular/common';
import { Component, inject } from '@angular/core';
import { Store } from '../state/store';
import { PureTaskListComponent } from './pure-task-list.component';
@Component({
selector: 'app-task-list',
standalone: true,
imports: [CommonModule, PureTaskListComponent],
template: `
<app-pure-task-list
[tasks]="store.getFilteredTasks()"
(onArchiveTask)="store.archiveTask($event)"
(onPinTask)="store.pinTask($event)"
></app-pure-task-list>
`,
})
export class TaskListComponent {
store = inject(Store);
}
将 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 } from '@storybook/angular';
import { PureTaskListComponent } from './pure-task-list.component';
import * as TaskStories from './task.stories';
export const TaskListData = [
{ ...TaskStories.TaskData, id: '1', title: 'Task 1' },
{ ...TaskStories.TaskData, id: '2', title: 'Task 2' },
{ ...TaskStories.TaskData, id: '3', title: 'Task 3' },
{ ...TaskStories.TaskData, id: '4', title: 'Task 4' },
{ ...TaskStories.TaskData, id: '5', title: 'Task 5' },
{ ...TaskStories.TaskData, id: '6', title: 'Task 6' },
];
const meta: Meta<PureTaskListComponent> = {
component: PureTaskListComponent,
title: 'PureTaskList',
tags: ['autodocs'],
excludeStories: /.*Data$/,
decorators: [
//👇 Wraps our stories with a decorator
componentWrapperDecorator((story) => `<div style="margin: 3em">${story}</div>`),
],
args: {
...TaskStories.TaskData.events,
},
};
export default meta;
type Story = StoryObj<PureTaskListComponent>;
export const Default: Story = {
args: {
// Shaping the stories through args composition.
// Inherited data coming from the Default story.
tasks: TaskListData,
},
};
export const WithPinnedTasks: Story = {
args: {
// Shaping the stories through args composition.
// Inherited data coming from the Default story.
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.ts 并在那里渲染组件。不用担心,我们将在下一章处理这个问题。