总结了 Angular 的状态管理方法

这篇文章是Angular Advent Calendar 2020的第21天的文章。

在近些年,状况管理已经成为与SPA密不可分的概念。然而,对于初学者而言,这个概念首先就是困难的。特别是在Angular中,它与RxJS紧密结合在一起,如果不懂RxJS,甚至无法进行状况管理,对于初学者来说是非常严苛的规定。
然而,一旦理解了Angular和RxJS特有的特性,就能够用简洁而优雅的代码来实现功能。
在这里,我想综合介绍一下Angular中状况管理的方法以及各种库的特点,以简单的待办事项应用为例。

这篇文章的目标读者

    • Angularチュートリアルは一通りやってみたけど、やっぱりrxjsの概念が分からないよ…という方

 

    • 状態管理したいけど、どうやってやればいいのかわからないという方

 

    ライブラリがありすぎてどれ選べばいいのかわからないという方

让我们了解RxJS的概念

鉴于状态管理是主题,我认为这是一个前言性的存在,但有些人可能不明白,所以我想在这里简要解释一下RxJS在做什么。

RxJS是一个JavaScript库,它的核心是Observable类,它可以用于保持状态和以响应式方式传递状态。它是Angular中各种状态管理库的核心组成部分,可说是非常重要的。

在这里,我将简单介绍BehaviorSubject和Observable。

Untitled Diagram.png

首先,Observable是一个负责“订阅(Subscribe)某个数据源流动值”的类。在上述图中,数据源是BehaviorSubject,初始状态是BehaviorSubject中设置了{“hoge”: true}的值,但当组件上的按钮等触发更新事件时,通过BehaviorSubject.next()方法将数据源的值更改为{“hoge”: false}。然后,更改后的值{“hoge”: false}也会流向订阅它的组件中。

一方面,BehaviorSubject 是一个负责将值传递给 Observable 的数据源的类。它可以通过从外部调用 next 函数来更新值,并且更新后的值会实时传递给所有订阅了它的 Observable。与之类似的类还有 Subject,但是 BehaviorSubject 会“保持最后一次更新的值”,而 Subject 不会保持值。此外,在 Angular 中,用于父子组件之间进行数据交互的 EventEmitter 与 Subject 几乎相同。

如果用简单的源代码来表示这个,大致如下。

<pre><code>{{state$ | async | json}}</code></pre> <!-- asyncパイプを使うことにより購読 -->
<button (click)="setState({ hoge: true })">true</button>
<button (click)="setState({ hoge: false })">false</button>
import { RxjsExampleService } from './rxjs-example.service';

@Component({
    selector: 'app-rxjs-example',
    templateUrl: './rxjs-example.component.html',
    styleUrls: ['./rxjs-example.component.scss'],
})
export class RxjsExampleComponent implements OnInit {
    state$ = this.rxjsExampleService.state$.asObservable(); // Observableとして読み取り専用で参照する

    constructor(private readonly rxjsExampleService: RxjsExampleService) {}

    ngOnInit(): void {
        this.state$.subscribe((state) => console.log(state)); // 購読
    }

    setState(state: { hoge: boolean }): void {
        this.rxjsExampleService.state$.next(state); // BehaviorSubjectに新しい値を設定する
    }
}
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({
    providedIn: 'root',
})
export class RxjsExampleService {
    constructor() {}

    state$ = new BehaviorSubject<{ hoge: boolean }>({ hoge: true });
}

点击按钮时,可以确认值会以反应方式进行变化。示例可以在此处找到:https://github.com/kaito3desuyo/angular-state-example/tree/master/src/app/rxjs-example

状态管理

好的,既然简单介绍了RxJS的概念,现在我想要介绍一些在Angular中进行状态管理的方法。

行为主题

总结

刚才我们介绍了BehaviorSubject可以“保持最后更新的值”,利用这个特性可以简单地将其作为存储使用。
不需要完全为存储创建一个新的服务类,也可以将存储功能添加到现有的服务类中。通过使用@Injectable({providedIn: ‘root’})将其指定为全局存储,使用@Component({providers: [SomeState]})将其指定为组件存储。

长处

    • コンポーネントストア/グローバルストア両方で使える

 

    • 追加のライブラリインストールが必要ない

 

    シンプル

弱点 suǒ)

    • あくまで簡易的なものであることに留意しなければならない

 

    シンプルがゆえに凝ったことをしようとするとコード量が増える

实现示例

这里是示例。
https://github.com/kaito3desuyo/angular-state-example/tree/master/src/app/rxjs-state

import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { Todo } from '../types/todo';
import { v4 as uuid } from 'uuid';

@Injectable({
    providedIn: 'root',
})
export class RxjsStateService {
    private _todos$: BehaviorSubject<Todo[]> = new BehaviorSubject<Todo[]>([
        {
            id: uuid(),
            title: '',
            done: true,
        },
        {
            id: uuid(),
            title: '',
            done: false,
        },
    ]);
    todos$: Observable<Todo[]> = this._todos$.asObservable();

    constructor() {}

    add(todo: Todo): void {
        const current = this._todos$.getValue();
        this._todos$.next([...current, todo]);
    }

    remove(todo: Todo): void {
        const current = this._todos$.getValue();
        const removed = current.filter((o) => o.id !== todo.id);
        this._todos$.next(removed);
    }

    markAsDone(todo: Todo): void {
        const current = this._todos$.getValue();
        const index = current.findIndex((o) => o.id === todo.id);
        current[index].done = true;
        this._todos$.next([...current]);
    }

    markAsUndone(todo: Todo): void {
        const current = this._todos$.getValue();
        const index = current.findIndex((o) => o.id === todo.id);
        current[index].done = false;
        this._todos$.next([...current]);
    }
}
private _todos$: BehaviorSubject<Todo[]> = new BehaviorSubject<Todo[]>([
    ...
]);
todos$: Observable<Todo[]> = this._todos$.asObservable();

这部分的行为类似于一个存储器。通过使用asObservable(),它会将其转换为只能订阅的Observable类。当订阅这个Observable时,每次执行this._todos$.next()时,值会以响应式的方式改变。
如果需要非响应式的快照,则可以使用getValue()方法。
由于只使用了RxJS的基本功能进行实现,所以代码简洁且能减小构建大小,但在大规模开发或多人开发等情况下,代码风格可能会有所变化,因此最好事先明确定义代码规范。
另外,BehaviorSubject提供的只是“保持并向订阅者传递最后输入的值”的功能,所以所有其他方便的功能,如添加、更新、删除等,都需要手动编写。

import { Todo } from '../types/todo';

@Injectable()
export class RxjsStateComponentService {
    private _list$: BehaviorSubject<Todo[]> = new BehaviorSubject<Todo[]>([]);
    list$: Observable<Todo[]> = this._list$.asObservable();
    set list(todos: Todo[]) {
        this._list$.next(todos);
    }

    private _onlyViewNotDone$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(
        false
    );
    onlyViewNotDone$: Observable<boolean> = this._onlyViewNotDone$.asObservable();
    set onlyViewNotDone(bool: boolean) {
        this._onlyViewNotDone$.next(bool);
    }

    constructor() {}
}

这是一个组件存储区。
虽然它的结构几乎相同,但它只是简单地保存要显示的列表,所以使用setter函数而不是函数来更新值。
在全局存储区和组件存储区中,几乎可以使用相同的写法,这可能是一个优点,具体取决于情况。

NgRx –

NgRx 是一个用于管理应用程序状态和进行响应式编程的库。

总结

我认为在选择 Angular 状态管理库时,首先选择的应该是 NgRx。NgRx 在状态管理领域的介绍文章等资料非常丰富,已经成为事实上的标准。
虽然它主要用于全局状态管理,但最近也加入了组件状态管理的扩展,可以全面应用。
然而,由于受到 Redux 的影响,NgRx 变成了一个相当复杂的库,学习难度相对较高,这是一个缺点。

优点

    • グローバルストア/コンポーネントストア両方で使える

 

    • Redux・Vuexの使用経験があれば、比較的理解しやすい

 

    • 厳格な設計なので、実装パターンに迷うことがない

 

    Redux devtoolsに対応していて、ストアの現在の状況をブラウザで簡単に表示可能

缺点

    • いろんな要素全部入りのライブラリなので、使いこなすには苦労する+npm読みで1.31MBと重い

 

    • ファイル数が多くなり見通しが悪くなりがち

 

    学習難易度高し

NgRx根据用途分为多个子库,本次示例应用程序实现中使用了核心库@ngrx/store以及@ngrx/entity、@ngrx/store-devtools、@ngrx/component-store、@ngrx/schematics。

实施样本

这里给出了样本。
https://github.com/kaito3desuyo/angular-state-example/tree/master/src/app/ngrx-state

import { Update } from '@ngrx/entity';
import { createAction, props } from '@ngrx/store';
import { Todo } from 'src/app/types/todo';

export const loadNgrxStates = createAction('[NgrxState] Load NgrxStates');

export const loadNgrxStatesSuccess = createAction(
    '[NgrxState] Load NgrxStates Success',
    props<{ data: any }>()
);

export const loadNgrxStatesFailure = createAction(
    '[NgrxState] Load NgrxStates Failure',
    props<{ error: any }>()
);

export const addTodo = createAction(
    '[NgrxState] Add Todo',
    props<{ todo: Todo }>()
);

export const updateTodo = createAction(
    '[NgrxState] Update Todo',
    props<{ todos: Update<Todo> }>()
);

export const removeTodo = createAction(
    '[NgrxState] Remove Todo',
    props<{ id: string }>()
);

import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { createReducer, on } from '@ngrx/store';
import { Todo } from 'src/app/types/todo';
import { addTodo, removeTodo, updateTodo } from './ngrx-state.actions';

export interface State extends EntityState<Todo> {}

export const adapter: EntityAdapter<Todo> = createEntityAdapter<Todo>();

export const initialState: State = adapter.getInitialState();

export const reducer = createReducer(
    initialState,
    on(addTodo, (state, { todo }) => {
        return adapter.addOne(todo, state);
    }),
    on(updateTodo, (state, { todos }) => {
        return adapter.updateOne(todos, state);
    }),
    on(removeTodo, (state, { id }) => {
        return adapter.removeOne(id, state);
    })
);

const {
    selectIds,
    selectEntities,
    selectAll,
    selectTotal,
} = adapter.getSelectors();

export const selectTodoIds = selectIds;

export const selectTodoEntities = selectEntities;

export const selectAllTodos = selectAll;

export const selectTodoTotal = selectTotal;
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ngrxStateFeatureKey } from '.';
import * as fromNgrxState from './ngrx-state.reducer';

const getState = createFeatureSelector<fromNgrxState.State>(
    ngrxStateFeatureKey
);

export const selectAllTodos = createSelector(
    getState,
    fromNgrxState.selectAllTodos
);

商店的编写方式与Redux/Vuex非常相似。在Actions中定义要对商店的值执行的操作,在Reducer中实现每个操作的具体行为,在Selector中实现从商店中获取值的处理程序,这是基本的流程。

由于编写方式非常规范,因此可以说无论谁编写,代码都会相同,这可能是一个优点。但代价是文件数量增多,视野不清晰,并且内部完全成为了黑盒子,所以对于不理解Flux架构的人来说,很难理解在做什么。

当然,并不需要手动编写所有这些,已经准备了用于自动生成模板的schematics,所以相对来说,工作量并不多。

此外,NgRx v10 中新增了一个名为 @ngrx/component-store 的子库,专为组件存储而设计。

import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { Todo } from 'src/app/types/todo';

type State = {
    list: Todo[];
    onlyViewNotDone: boolean;
};

@Injectable()
export class NgrxStateComponentStore extends ComponentStore<State> {
    readonly list$ = this.select((state) => state.list);
    readonly onlyViewNotDone$ = this.select((state) => state.onlyViewNotDone);

    readonly setList = this.updater((state, todos: Todo[]) => ({
        ...state,
        list: todos,
    }));
    readonly setOnlyViewNotDone = this.updater((state, bool: boolean) => ({
        ...state,
        onlyViewNotDone: bool,
    }));

    constructor() {
        super({ list: [], onlyViewNotDone: false });
    }
}

可以像Akita那样,实现以组件为单位的状​​态管理,取代先前使用BehaviorSubject坚持不懈的实现方式。这种实现方式与Flux的风格有所不同。

image.png

秋田犬 tǎ

简而言之

这是一个简单的状态管理库,适用于Store/Query的两种类。它同时支持全局存储和组件存储,并且还有用于Redux Devtools的扩展库。与NgRx相比,它的知名度较低,npm的每周下载量只有NgRx的十分之一,但官方文档内容丰富,看一看就能大致了解大部分内容,尽管介绍文章较少。该库的设计更贴近Angular的功能,其中一些代表性功能/插件有「管理Angular Router的历史状态」「管理ReactiveForm的状态」「使用LocalStorage进行持久化存储」等。

优点

    • グローバルストア/コンポーネントストア両方で使える

 

    • 基本的な状態管理だけなら2クラスで完結するシンプルさ

 

    • 設計の適度な緩さ

 

    • 追加機能/プラグインが豊富

 

    • Redux devtoolsに対応していて、ストアの現在の状況をブラウザで簡単に表示可能

 

    アイコンの秋田犬がかわいい

缺点

    • npm読みで2.74MBと重い(NgRxと異なり、サブライブラリに分かれていないため)

 

    • 圧倒的知名度の低さ

 

    検索性が悪い(Akitaで検索すると秋田県が出てきます)

实施示例

这里是示例。
https://github.com/kaito3desuyo/angular-state-example/tree/master/src/app/akita-state

import { Injectable } from '@angular/core';
import { EntityState, EntityStore, StoreConfig } from '@datorama/akita';
import { Todo } from 'src/app/types/todo';

export interface TodosState extends EntityState<Todo, string> {}

@StoreConfig({ name: 'todos' })
@Injectable({ providedIn: 'root' })
export class AkitaStateStore extends EntityStore<TodosState> {
    constructor() {
        super();
    }

    markAsDone(todo: Todo): void {
        this.update(todo.id, (entity) => ({
            ...entity,
            done: true,
        }));
    }

    markAsUndone(todo: Todo): void {
        this.update(todo.id, (entity) => ({
            ...entity,
            done: false,
        }));
    }
}
import { Injectable } from '@angular/core';
import { QueryEntity } from '@datorama/akita';
import { AkitaStateStore, TodosState } from './akita-state.store';

@Injectable({ providedIn: 'root' })
export class AkitaStateQuery extends QueryEntity<TodosState> {
    todos$ = this.selectAll();

    constructor(protected store: AkitaStateStore) {
        super(store);
    }
}

为了实现存储功能,只需要上述的两个类,非常简单。
名为EntityStore的类已经实现了各种便利函数来操作集合,只需要扩展它就可以用作存储。当然,也可以在Store类中自己实现函数,而且所有的便利函数都以公共函数的形式定义,所以可以直接从组件中使用。(我认为这是有争议的,所以在团队中制定统一的使用方式非常重要)
查询方面,只是扩展了一个名为QueryEntity的类,它已经实现了方便的函数来获取数据。

Akita的特点是具有”简约性”和相对容易理解的”适度弹性”,这使得即使是Angular新手也能够轻松上手。
虽然NgRx的设计是以函数为主,但Akita的设计仍然是以类为主,对于某些人来说可能会被视为”过时”,但考虑到与Angular的Service类的兼容性,我认为这是一个非常合理的设计。

import { Injectable } from '@angular/core';
import { guid, Query, Store } from '@datorama/akita';

type State = { onlyViewNotDone: boolean };

@Injectable()
export class AkitaStateComponentStore extends Store<State> {
    constructor() {
        super(
            {
                onlyViewNotDone: false,
            },
            { name: `AkitaState-${guid()}` }
        );
    }

    setOnlyViewNotDone(bool: boolean): void {
        this.update({ onlyViewNotDone: bool });
    }
}

@Injectable()
export class AkitaStateComponentQuery extends Query<State> {
    onlyViewNotDone$ = this.select('onlyViewNotDone');

    constructor(protected store: AkitaStateComponentStore) {
        super(store);
    }
}

组件存储也只需要按照全局存储相同的方式创建两个类。与全局存储的区别在于,由于@Injectable()不是在根级别使用,所以实例会被多次生成,因此在super()中为存储名称分配了一个唯一的ID。由于全局/组件之间的接口相同,所以在实现时不会产生困惑。

image.png

@药方角/国家

总结

这个库与以往的库有很大不同,它是为了实现”响应式和更智能的组件存储实现”而开发的。从这个角度来看,对RxJS有深入的理解是必要条件,但作为代替,它有一个无法替代的优点,那就是完全摆脱了Angular中最复杂的之一:Subscription管理的负担。

这个库最近成为热门话题,尽管仍然在发展中,但它已经具备Subscription管理无负担的能力,而且已经在初始状态下进行了Observable性能调优等工作,因此在目前已经成为一个非常强大的库。

优点 suǒ)

    • 小難しいコードなしでパフォーマンスの高いコンポーネントストアを作れる

 

    • Subscriptionを自分で管理する必要なし(!)

@Input() @Output()といったイベントとの親和性も高い
npm読みで891KB

缺点 (quē

    • RxJSへの深い理解が必須

 

    • グローバルストアとしても一応使えるが、使い勝手は悪い

 

    ストアの状態をブラウザ等で確認することはできない

示例实施

以下是示例:
请点击这里查看样例。
https://github.com/kaito3desuyo/angular-state-example/tree/master/src/app/rx-angular-state

由于这是一个以组件存储为主要的库,所以我们先介绍组件存储部分。

import { Component, Inject, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { RxState } from '@rx-angular/state';
import { combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
import { v4 as uuid } from 'uuid';
import { Todo } from '../types/todo';
import {
    GLOBAL_RX_STATE,
    RxAngularGlobalState,
} from './store/rx-angular-state.state';

interface State {
    list: Todo[];
    onlyViewNotDone: boolean;
}

@Component({
    selector: 'app-rx-angular-state',
    templateUrl: './rx-angular-state.component.html',
    styleUrls: ['./rx-angular-state.component.scss'],
    providers: [RxState],
})
export class RxAngularStateComponent implements OnInit {
    readonly list$ = this.state.select('list');
    readonly onlyViewNotDone$ = this.state.select('onlyViewNotDone');

    form = this.fb.group({
        title: [''],
    });

    constructor(
        private readonly fb: FormBuilder,
        @Inject(GLOBAL_RX_STATE)
        private readonly globalState: RxState<RxAngularGlobalState>,
        private readonly state: RxState<State>
    ) {
        this.state.set({
            list: [],
            onlyViewNotDone: false,
        });

        this.state.connect(
            'list',
            combineLatest([
                this.globalState.select('todos'),
                this.onlyViewNotDone$,
            ]).pipe(
                map(([todos, onlyViewNotDone]) => {
                    if (onlyViewNotDone) {
                        return todos.filter((o) => !o.done);
                    } else {
                        return todos;
                    }
                })
            )
        );
    }

    ngOnInit(): void {}

    onClickAdd(): void {
        const current = this.globalState.get('todos') ?? [];
        this.globalState.set({
            todos: [
                ...current,
                {
                    id: uuid(),
                    title: this.form.get('title').value,
                    done: false,
                },
            ],
        });
        this.form.reset({
            title: '',
        });
    }

    onClickRemove(todo: Todo): void {
        this.globalState.set({
            todos: [
                ...this.globalState
                    .get('todos')
                    .filter((o) => o.id !== todo.id),
            ],
        });
    }

    onClickMarkAsDone(todo: Todo): void {
        const current = this.globalState.get('todos') ?? [];
        const index = current.findIndex((o) => o.id === todo.id);
        current[index].done = true;
        this.globalState.set({
            todos: [...current],
        });
    }

    onClickMarkAsUndone(todo: Todo): void {
        const current = this.globalState.get('todos') ?? [];
        const index = current.findIndex((o) => o.id === todo.id);
        current[index].done = false;
        this.globalState.set({
            todos: [...current],
        });
    }

    onChangeOnlyViewNotDone(bool: boolean): void {
        this.state.set({ onlyViewNotDone: bool });
    }
}

虽然如此,没有必要创建单独的服务类,您只需要将RxState指定给要使用的组件的providers数组即可。初始化值等可以在constructor()中设置。

this.state.connect(
    'list',
    combineLatest([
        this.globalState.select('todos'),
        this.onlyViewNotDone$,
    ]).pipe(
        map(([todos, onlyViewNotDone]) => {
            if (onlyViewNotDone) {
                return todos.filter((o) => !o.done);
            } else {
                return todos;
            }
        })
    )
);

特徵的部分是這部分。this.state.connect()能夠”連接”到所創建的組件存儲的特定屬性,並自動訂閱並將來自指定的Observable的值設置到存儲中。RxState還會自動處理Observable的Subscription的銷毀,因此在處理Hot Observable時也很安全。
此外,我們還可以自動設置常用的Observable性能調優操作,例如distinctUntilChanged()和shareReplay(),從而創建高性能的組件存儲。
然而,方便用於作為存儲的函數的實現只有最基本的,所以在更新或刪除等操作方面,我們需要像管理BehaviorSubject一樣自己編寫代碼。這是因為(幾乎)不考慮將其用作全局存儲。

import { InjectionToken } from '@angular/core';
import { Todo } from 'src/app/types/todo';
import { RxState } from '@rx-angular/state';

export interface RxAngularGlobalState {
    todos: Todo[];
}

export const GLOBAL_RX_STATE = new InjectionToken<
    RxState<RxAngularGlobalState>
>('GLOBAL_RX_STATE');
@NgModule({
    declarations: [AppComponent],
    imports: [
        ...
    ],
    providers: [
        {
            provide: GLOBAL_RX_STATE,
            useFactory: () => new RxState<RxAngularGlobalState>(),
        },
    ],
    bootstrap: [AppComponent],
})
export class AppModule {}
import { Component, Inject } from '@angular/core';
import { RxState } from '@rx-angular/state';
import {
    GLOBAL_RX_STATE,
    RxAngularGlobalState,
} from './rx-angular-state/store/rx-angular-state.state';

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.scss'],
})
export class AppComponent {
    title = 'angular-state-example';

    constructor(
        @Inject(GLOBAL_RX_STATE) private state: RxState<RxAngularGlobalState>
    ) {
        this.state.set({ todos: [] });
    }
}

虽然可以按照上述方式创建全球商店,但个人不建议,因为感觉效果不太好。
即使在官方文档中,创建全球商店也只被当作额外的功能处理,所以最好引入其他库,如NgRx或Akita库来处理全球商店。这些库本来就是为这样的使用场景而设计的。

概况

    ストアとして高機能なのは?
NgRx = Akita >>>> @rx-angular/state >> BehaviorSubject
    シンプルなのは?
BehaviorSubject >> Akita > @rx-angular/state >>>>>>>>>> NgRx
    軽いのは?
BehaviorSubject >>>>> @rx-angular/state >>> NgRx(コアライブラリのみ) >> Akita

个人觉得,对于小到中等规模的开发来说,Akita可能已经足够了。NgRx作为基于Flux的强大设计,在多人团队中进行大规模开发时可能会有优势,但在小中等规模的开发中,Flux的Store则可能只会带来麻烦。
在这一点上,我觉得Akita非常简单但功能强大,并且对Angular的文化有很好的理解,因此对初学者来说,从BehaviorSubject Store升级非常易懂。不过,参考资料相对较少,需要边看官方网站边理解。
@rx-angular/state不仅仅是创建Store,更像是连接组件和全局Store的桥梁,并且非常出色。虽然使用起来有一些特点,但对于性能至关重要的应用程序可能会成为必不可少的。

从综合角度来判断,我个人喜欢Akita + @rx-angular/state,但我希望读者们能考虑各个库的优缺点、团队的技能水平以及产品所需的规范等因素,找到最适合的组合。

广告
将在 9 秒后关闭
bannerAds