将NgRx和本地存储同步

首先

我负责撰写的是2023年Angular Advent Calendar的第10篇文章。

当使用Angular进行状态管理时,NgRx可能会被视为选择之一。然而,NgRx由于占用内存而对浏览器的刷新等操作相对较弱。
作为对策,可以采用将数据存储在Storage中的方法,但是每次更新Store时都需要额外的处理来更新Storage,这很繁琐。
最好是希望它们能够自动同步。
实现这一点的方法是使用ngrx-store-localstorage。

本文将在v16的standalone版本中进行实施。
虽然Angular v17刚刚发布了一个月,但在撰写本文时,此时完成ngrx-store-localstorage的v17支持仍处于提出PR的阶段。

    ngrx-store-localstorage#260

环境

ng version

Angular CLI: 16.2.10
Node: 18.18.0
Package Manager: npm 9.8.1
Angular: 16.2.12

安装

ng add @ngrx/store
npm install ngrx-store-localstorage

因为手动实现UI方面很麻烦,所以选择依赖于Angular Material。

ng add @angular/material

应用程序 xù)

由于没有特定的理由需要分隔组件,因此将UI部分完全集中在app.component.*中。

app.compoent.html组件的HTML模板

<div class="main">
  <mat-form-field>
    <mat-label>ID</mat-label>
    <input matInput [formControl]="idForm">
  </mat-form-field>

  <mat-form-field>
    <mat-label>NAME</mat-label>
    <input matInput [formControl]="nameForm">
  </mat-form-field>

  <mat-form-field>
    <mat-label>AGE</mat-label>
    <input
      matInput
      type="number"
      [formControl]="ageForm"
      [max]="200"
      [min]="0"
    >
  </mat-form-field>

  <mat-slide-toggle [formControl]="isAdminSelect">
    isAdmin
  </mat-slide-toggle>
</div>

app.component.scss -> 应用程序组件样式文件

.main {
  margin: 24px;
  display: flex;
  flex-direction: column;
  gap: 16ox;
}

app.component.ts的原生中文释疑:应用组件.ts

import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
  ReactiveFormsModule,
  FormControl,
  FormGroup
} from '@angular/forms';
import { Subscription } from 'rxjs';
import { Store } from '@ngrx/store';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { UserInfo, UserInfoInitial } from './app.interface';
import { AppActions } from './store/app.actions';
import { AppSelector } from './store/app.selector';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [
    CommonModule,
    ReactiveFormsModule,
    MatFormFieldModule,
    MatInputModule,
    MatSlideToggleModule,
  ],
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit, OnDestroy {
  private subscription = new Subscription();
  private store = inject(Store);

  idForm = new FormControl<UserInfo['id']>(
    UserInfoInitial.id,
    {nonNullable: true},
  );
  nameForm = new FormControl<UserInfo['name']>(
    UserInfoInitial.name,
    {nonNullable: true},
  );
  ageForm = new FormControl<UserInfo['age']>(
    UserInfoInitial.age,
    {nonNullable: true},
  );
  isAdminSelect = new FormControl<UserInfo['isAdmin']>(
    UserInfoInitial.isAdmin,
    {nonNullable: true},
  );

  formGroup = new FormGroup({
    id: this.idForm,
    name: this.nameForm,
    age: this.ageForm,
    isAdmin: this.isAdminSelect,
  });

  ngOnInit(): void {
    this.subscription.add(
      this.store.select(AppSelector.selectUserInfo).subscribe((data) => {
        this.idForm.setValue(data.id, {onlySelf: true});
        this.nameForm.setValue(data.name, {onlySelf: true});
        this.ageForm.setValue(data.age, {onlySelf: true});
        this.isAdminSelect.setValue(data.isAdmin, {onlySelf: true});
      })
    );

    this.subscription.add(
      this.formGroup.valueChanges.subscribe((inputValue) => {
        const param: UserInfo = {
          id: inputValue.id ?? UserInfoInitial.id,
          name: inputValue.name ?? UserInfoInitial.name,
          age: inputValue.age ?? UserInfoInitial.age,
          isAdmin: inputValue.isAdmin ?? UserInfoInitial.isAdmin,
        };
        this.store.dispatch(AppActions.updateForm({data: param}));
      })
    );
  }

  ngOnDestroy(): void {
    this.subscription.unsubscribe();
  }
}

app.interface.ts -> 应用程序接口.ts

export interface UserInfo {
  id: string;
  name: string;
  age: number;
  isAdmin: boolean;
}

export const UserInfoInitial: UserInfo = {
  id: '',
  name: '',
  age: 0,
  isAdmin: false,
};

商店

在商店中创建。

app.actions.ts 文件的内容需要进行转述。

import { createAction, props } from '@ngrx/store';
import { UserInfo } from '../app.interface';

export namespace AppActions {
  export const updateForm = createAction(
    '[App Page] Change Form Value',
    props<{data: UserInfo}>(),
  );
}

app.reducer.ts的中文释义:应用程序减少器.ts

import { createReducer, on } from '@ngrx/store';
import { UserInfo, UserInfoInitial } from '../app.interface';
import { AppActions } from './app.actions';

export interface State {
  userInfo: UserInfo;
}

export const initialState: State = {
  userInfo: UserInfoInitial,
};

export const appReducer = createReducer(
  initialState,
  on(
    AppActions.updateForm,
    (state, { data }) => ({
      userInfo: {...data},
    }),
  ),
);

export const AppFeatureKey = 'app';

应用选择器.ts

import { createSelector, createFeatureSelector } from '@ngrx/store';
import { State, AppFeatureKey } from './app.reducer';

export const selectAppFeature = createFeatureSelector<State>(AppFeatureKey);

export namespace AppSelector {
  export const selectUserInfo = createSelector(
    selectAppFeature,
    (state) => state.userInfo
  );
}

配置

就像ngrx-store-localstorage的使用指南中所述,您需要在这里进行设置。

import { ApplicationConfig } from '@angular/core';
import {
  provideState,
  provideStore,
  ActionReducer,
  MetaReducer,
  Action
} from '@ngrx/store';
import { provideAnimations } from '@angular/platform-browser/animations';
import { localStorageSync } from 'ngrx-store-localstorage';
import { appReducer, AppFeatureKey, State } from './store/app.reducer';

const localStorageSyncReducer = (
  reducer: ActionReducer<State, Action>
): ActionReducer<State, Action> =>
  localStorageSync({
    keys: ['userInfo'],
    rehydrate: true,
    storage: localStorage,
    storageKeySerializer: (key) => `my_app_data_for_${key}`,
  })(reducer);

const metaReducers: Array<MetaReducer<any, any>> = [localStorageSyncReducer];

export const appConfig: ApplicationConfig = {
  providers: [
    provideStore(),
    provideAnimations(),
    provideState(AppFeatureKey, appReducer, { metaReducers }),
  ],
};

只是作为一个文档的例子,并不支持独立运行。
但除了导入部分之外,我们可以看出其他部分大致相同,因此其他部分将保持原样制作。

const localStorageSyncReducer = (
  reducer: ActionReducer<State, Action>
): ActionReducer<State, Action> =>
  localStorageSync({
    keys: ['userInfo'],
    rehydrate: true,
    storage: localStorage,
    storageKeySerializer: (key) => `my_app_data_for_${key}`,
  })(reducer);

const metaReducers: Array<MetaReducer<any, any>> = [localStorageSyncReducer];

将要绑定的密钥添加到keys中。
因为这里只有usetInfo,所以我会直接输入一个键名。

希望初次同步本地存储,使用rehydrate = true。

存储的选项设为localStorage。虽然默认就是localStorage,所以不写也可以,但是为了便于理解,我写了一下,因为我觉得更多的情况下会使用SessionStorage。

storageKeySerializer的key值会根据keys的值进行填充,而storage的键名将成为返回值。
在这种情况下,键名将是my_app_data_for_userInfo。
从ngrx-store-localstorage#usage-1中可以看到,可以进行多个注册。例如,如果给存储加上类似main这样可能产生冲突的键名时,可以利用前缀或后缀来确保唯一性,可以在这方面发挥作用。

如果我们看一下例子,似乎只需要将其注册到metaReducers中,所以我们来看一下ngrx的元reducers。
然后我们发现当前只有以下的说明,我不知道如何在provideState中实现。

StoreModule.forRoot(
  reducers,
  {metaReducers}
)

只要查看 provideState 的文档,就可以看到在 config 中可以注册 metaReducers。

但是在Reducer的独立示例中,无法注册config的以下代码。

provideStore({ [scoreboardFeatureKey]: scoreboardReducer })

因此,我会按照以下方式进行注册。

provideState(AppFeatureKey, appReducer, { metaReducers }),

完成了!

在开发者工具中可以确认,当更改表单的值时,本地存储也会更新。
此外,即使刷新浏览器,之前输入的值仍会显示在表单中。

最后

感谢您的阅读。

尽管现在存储的同步变得容易,但是选择将数据放入存储或是将键值分开存储现在取决于存储的责任。尽管如此,由于无需手动编写同步处理代码,仍然非常方便。

这是Angular Advent Calendar 2023的第10天。

bannerAds