Angular 独立组件的组件优先状态管理

2025-06-04

Angular 独立组件的组件优先状态管理

介绍

2021 年,Angular发布了针对独立组件的 RFC(征求意见稿)NgModules自从 Angular 2-rc.5 中引入可选组件以来,它一直是框架社区的常见需求。独立组件(以及指令和管道)正是 Angular 对此需求的回应。它为纯粹使用组件构建 Angular 应用铺平了道路。

然而,多年来,我们为 Angular 构建的架构模式已经考虑到NgModules现有的、并成为当前 Angular 应用驱动力的模式。随着 AngularNgModules成为可选模式,我们需要思考新的模式,以帮助我们构建同样具有弹性和可扩展性的应用,但使用更简单的应用思维模型。

这就是组件优先 (Component-First) 发挥作用的地方。它是用于设计 Angular 应用的一系列模式,一旦我们有了独立组件,它就会强调组件作为用户交互的主要来源,是我们应用的真相来源。

我们应该能够将应用程序中的所有组件连接在一起,并确切地了解应用程序的工作原理。
不会在某个不起眼的模块中发生什么神奇的事情。

为了实现这一点,组件需要管理自己的路由和状态。

在本文中,我们将探讨一种状态管理方法,允许组件控制其状态并成为其自己的事实来源。


如果你有兴趣了解独立组件如何改变路由,请阅读我在下面撰写的关于

使用 Angular 和独立组件的组件优先架构的文章


为什么我们需要不同的方法?

在当前的 Angular 中,框架并未提供内置的状态管理解决方案。它确实提供了构建模块,但并未对如何管理应用中的状态采取任何独断的立场。Angular 社区已介入填补生态系统中的这一空白,并创建了以下软件包:

  • NgRx
  • NgXs
  • ...其他我没有列出的。

然而,我列出的可以说是生态系统中最受欢迎的,依赖于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];
  }
}
Enter fullscreen mode Exit fullscreen mode

这是一个非常简单的组件,内部管理着自己的状态。为它创建一个 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,
      },
    }));
  }
}
Enter fullscreen mode Exit fullscreen mode

就是这么简单,现在我们的状态管理已经包含在组件旁边的一个类和文件中了。现在,让我们修改组件以使用新的 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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

使用我们新的 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)
        );
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

现在我们已经添加了效果,我们可以通过调度动作在组件中利用它:

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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

全局/共享状态

现在我们没有了NgModules,我们如何在组件之间共享存储?

注意:我不推荐它,但它确实有其用途,例如全球通知系统。

在 Component-First 中,由于我们所有的组件都是彼此的子组件或兄弟组件,所以我们可以利用 Angular 的注入树,并将父组件的 Store 简单地注入到我们的子组件中。

假设我们有一个组件,TodoComponent它是的子组件TodoListComponent,那么我们可以执行以下操作:

@Component({
    ...
})
export class TodoComponent {

    constructor(private store: TodoListComponentStore) {}
}
Enter fullscreen mode Exit fullscreen mode

我建议谨慎使用这种方法,因为它强制 和 之间耦合,TodoListComponent并且TodoComponent必须TodoComponent始终的子项TodoListComponent。在某些情况下,这在逻辑上是合理的,但还是需要注意!

玩转包

@component-first/redux软件包已在 npm 上发布,您可以用它来进行实验。需要注意的是,LatestPipe当前在软件包中不是独立的(我不想附带 Angular 提供的独立 Shim),所以您必须将 添加到的 中LatestPipe当独立组件发布后,我会将管道设置为独立!NgModuledeclarations


我希望本文能让您对于独立组件感到兴奋,并帮助您开始思考当它们到来时我们可以采取的一些架构方法!

如果您有任何疑问,请随时在下面提问或在 Twitter 上联系我:@FerryColum

文章来源:https://dev.to/angular/component-first-state-management-for-angular-standalone-components-3l1a
PREV
探索 Angular 中的 HttpClientModule
NEXT
使用 GitHub Actions 在 GitHub Pages 中构建和部署 Angular 应用程序简介 1. 配置 gh-pages 2. 设置用于部署的 npm-script 3. GitHub Actions