Angular 独立组件的组件优先状态管理
介绍
2021 年,Angular发布了针对独立组件的 RFC(征求意见稿)。NgModules
自从 Angular 2-rc.5 中引入可选组件以来,它一直是框架社区的常见需求。独立组件(以及指令和管道)正是 Angular 对此需求的回应。它为纯粹使用组件构建 Angular 应用铺平了道路。
然而,多年来,我们为 Angular 构建的架构模式已经考虑到NgModules
现有的、并成为当前 Angular 应用驱动力的模式。随着 AngularNgModules
成为可选模式,我们需要思考新的模式,以帮助我们构建同样具有弹性和可扩展性的应用,但使用更简单的应用思维模型。
这就是组件优先 (Component-First) 发挥作用的地方。它是用于设计 Angular 应用的一系列模式,一旦我们有了独立组件,它就会强调组件作为用户交互的主要来源,是我们应用的真相来源。
我们应该能够将应用程序中的所有组件连接在一起,并确切地了解应用程序的工作原理。
不会在某个不起眼的模块中发生什么神奇的事情。
为了实现这一点,组件需要管理自己的路由和状态。
在本文中,我们将探讨一种状态管理方法,允许组件控制其状态并成为其自己的事实来源。
如果你有兴趣了解独立组件如何改变路由,请阅读我在下面撰写的关于
使用 Angular 和独立组件的组件优先架构的文章
为什么我们需要不同的方法?
在当前的 Angular 中,框架并未提供内置的状态管理解决方案。它确实提供了构建模块,但并未对如何管理应用中的状态采取任何独断的立场。Angular 社区已介入填补生态系统中的这一空白,并创建了以下软件包:
然而,我列出的可以说是生态系统中最受欢迎的,依赖于NgModules
实例化状态管理解决方案。
如果我们想要真正实现NgModule
无依赖的开发体验,就需要摆脱任何依赖于 的解决方案NgModule
,否则我们的组件将始终与 耦合NgModules
。随着时间的推移,这种耦合将越来越难以消除。这也会使我们系统的建模变得更加复杂。我们的状态将在与组件不同的位置创建和处理。状态管理方式的模糊性使我们更难以评估组件及其功能。
NgRx 已经朝着我认为非常适合独立组件的方向迈出了一步。他们创建了一个名为Component Store 的包,允许组件管理自身的状态。它非常有效,是一个很棒的解决方案!如果你之前用过它,并且熟悉 RxJS,那就赶紧用吧!
但是,我已经创建了一个包,@component-first/redux
它在不使用 RxJS 的本地组件存储中实现了 Redux 模式,我们也可以使用它来实现相同的效果。
在本文的其余部分,我将说明如何使用此包来管理应用程序中独立组件的状态。
创建并使用独立组件的存储
让我们以下面的组件为例。它将是一个基本的待办事项列表组件,用于管理自己的待办事项列表,并允许添加和删除等操作。
我们的准系统组件(没有存储)应该类似于此:
@Component({
standalone: true,
selector: 'todo-list',
template: `<input #newTodo type="text" /><button
(click)="addTodo(newTodo.value)"
>
Add
</button>
<ul>
<li *ngFor="let todo of todos">
{{ todo.name }} <button (click)="deleteTodo(todo.id)">Delete</button>
</li>
</ul>`,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TodoListComponent implements OnInit {
todos = {};
incrementId = 1;
constructor() {}
ngOnInit() {
this.todos = {
0: { name: 'Example Todo' },
};
}
addTodo(todo: string) {
this.todos[this.incrementId++] = { name: todo };
}
deleteTodo(id: number) {
delete this.todos[id];
}
}
这是一个非常简单的组件,内部管理着自己的状态。为它创建一个 Store 可能有点小题大做,但这将是一个很好的例子来展示组件的 Store 功能。
首先,我们需要创建 store。我们在组件旁边创建一个名为的文件todo-list.component.store.ts
,它看起来应该像这样:
import { Store } from '@component-first/redux';
import { ChangeDetectorRef, Injectable } from '@angular/core';
// We need to define the shape of our state
interface TodoListState {
todos: Record<string, { name: string }>;
incrementId: number;
}
// We only want to inject our Store in our component, so do not provide in root
// We also need to extend the Store class from @component-first/redux
@Injectable()
export class TodoListComponentStore extends Store<TodoListState> {
// We define actions and store them on the class so that they can be reused
actions = {
addTodo: this.createAction<{ name: string }>('addTodo'),
deleteTodo: this.createAction<{ id: number }>('deleteTodo'),
};
// We also define selectors that select slices of our state
// That can be used by our components
selectors = {
todos: this.select((state) => state.todos),
};
// We need a function that our Component can call on instantiation that
// will create our store with our intiial state and the change detector ref
create(cd: ChangeDetectorRef) {
const initialState = {
todos: {
1: { name: 'Example Todo' },
},
incrementId: 2,
};
this.init(cd, initialState);
// We then define the reducers for our store
this.createReducer(this.actions.addTodo, (state, { name }) => ({
...state,
todos: {
...state.todos,
[state.incrementId]: { name },
},
incrementId: state.incremenet + 1,
}));
this.createReducer(this.actions.deleteTodo, (state, { id }) => ({
...state,
todos: {
...state.todos,
[id]: undefined,
},
}));
}
}
就是这么简单,现在我们的状态管理已经包含在组件旁边的一个类和文件中了。现在,让我们修改组件以使用新的 store:
import { SelectorResult, LatestPipe } from '@component-first/redux';
import { TodoListComponentStore } from './todo-list.component.store';
@Component({
standalone: true,
selector: 'todo-list',
template: `<input #newTodo type="text" /><button
(click)="addTodo(newTodo.value)"
>
Add
</button>
<ul>
<li *ngFor="let todo of todos | latest">
{{ todo.name }} <button (click)="deleteTodo(todo.id)">Delete</button>
</li>
</ul>`,
imports: [LatestPipe, CommonModule],
providers: [TodoListComponentStore],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TodoListComponent implements OnInit {
todos: SelectorResult<Record<string, { name: string }>>;
constructor(
private cd: ChangeDetectorRef,
private store: TodoListComponentStore
) {
this.store.create(cd);
}
ngOnInit() {
this.todos = this.store.selectors.todos;
}
addTodo(name: string) {
this.store.dispatchAction(this.store.actions.addTodo, { name });
}
deleteTodo(id: number) {
this.store.dispatchAction(this.store.actions.deleteTodo, { id });
}
}
使用我们新的 Store 非常简单,它遵循我们熟悉的 API,前提是你之前使用过 NgRx。我们确实引入了一个新的管道,latest
它总是会在变更检测周期从 Store 获取最新值。
高级技术
效果
Store 还支持 Effects。这在很多情况下都很有用,不过,让我们修改一下TodoListComponentStore
,让它能够通过 API 获取 Todo 列表。
import { Store } from '@component-first/redux';
import { ChangeDetectorRef, Injectable } from '@angular/core';
interface TodoListState {
todos: Record<string, { name: string }>;
incrementId: number;
}
@Injectable()
export class TodoListComponentStore extends Store<TodoListState> {
actions = {
addTodo: this.createAction<{ name: string }>('addTodo'),
deleteTodo: this.createAction<{ id: number }>('deleteTodo'),
// We need a new action to load the todos from an API
loadTodos: this.createAction('loadTodos'),
};
selectors = {
todos: this.select((state) => state.todos),
};
create(cd: ChangeDetectorRef) {
const initialState = {
todos: {},
incrementId: 0,
};
this.init(cd, initialState);
this.createReducer(this.actions.addTodo, (state, { name }) => ({
...state,
todos: {
...state.todos,
[state.incrementId]: { name },
},
incrementId: state.incremenet + 1,
}));
this.createReducer(this.actions.deleteTodo, (state, { id }) => ({
...state,
todos: {
...state.todos,
[id]: undefined,
},
}));
// We create an effect that will occur when the LoadTodos action is dispatched
this.createEffect(this.actions.loadTodos, () => {
// It will make an API call
fetch('api/todos').then((response) => {
const todos = response.json();
todos.forEach((todo) =>
// Then it will dispatch our existing AddTodo action to add the todos
this.dispatchAction(this.actions.addTodo, todo)
);
});
});
}
}
现在我们已经添加了效果,我们可以通过调度动作在组件中利用它:
import { SelectorResult, LatestPipe } from '@component-first/redux';
import { TodoListComponentStore } from './todo-list.component.store';
@Component({
standalone: true,
selector: 'todo-list',
template: `<input #newTodo type="text" /><button
(click)="addTodo(newTodo.value)"
>
Add
</button>
<ul>
<li *ngFor="let todo of todos | latest">
{{ todo.name }} <button (click)="deleteTodo(todo.id)">Delete</button>
</li>
</ul>`,
imports: [LatestPipe, CommonModule],
providers: [TodoListComponentStore],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TodoListComponent implements OnInit {
todos: SelectorResult<Record<string, { name: string }>>;
constructor(
private cd: ChangeDetectorRef,
private store: TodoListComponentStore
) {
this.store.create(cd);
}
ngOnInit() {
this.todos = this.store.selectors.todos;
// OnInit, load the todos!
this.store.dispatchAction(this.store.actions.loadTodos);
}
addTodo(name: string) {
this.store.dispatchAction(this.store.actions.addTodo, { name });
}
deleteTodo(id: number) {
this.store.dispatchAction(this.store.actions.deleteTodo, { id });
}
}
全局/共享状态
现在我们没有了NgModules
,我们如何在组件之间共享存储?
注意:我不推荐它,但它确实有其用途,例如全球通知系统。
在 Component-First 中,由于我们所有的组件都是彼此的子组件或兄弟组件,所以我们可以利用 Angular 的注入树,并将父组件的 Store 简单地注入到我们的子组件中。
假设我们有一个组件,TodoComponent
它是的子组件TodoListComponent
,那么我们可以执行以下操作:
@Component({
...
})
export class TodoComponent {
constructor(private store: TodoListComponentStore) {}
}
我建议谨慎使用这种方法,因为它强制 和 之间耦合,TodoListComponent
并且TodoComponent
必须TodoComponent
始终是的子项TodoListComponent
。在某些情况下,这在逻辑上是合理的,但还是需要注意!
玩转包
该@component-first/redux
软件包已在 npm 上发布,您可以用它来进行实验。需要注意的是,LatestPipe
当前在软件包中不是独立的(我不想附带 Angular 提供的独立 Shim),所以您必须将 添加到的 中LatestPipe
。当独立组件发布后,我会将管道设置为独立!NgModule
declarations
我希望本文能让您对于独立组件感到兴奋,并帮助您开始思考当它们到来时我们可以采取的一些架构方法!
如果您有任何疑问,请随时在下面提问或在 Twitter 上联系我:@FerryColum。
文章来源:https://dev.to/angular/component-first-state-management-for-angular-standalone-components-3l1a