【Angular 开发】Angular 信号的应用状态管理

2023-12-13 06:00:37

自我介绍

  • 做一个简单介绍,年近48 ,有20多年IT工作经历,目前在一家500强做企业架构.因为工作需要,另外也因为兴趣涉猎比较广,为了自己学习建立了三个博客,分别是【全球IT瞭望】,【架构师酒馆】和【开发者开聊】.
  • 企业架构师需要比较广泛的知识面,了解一个企业的整体的业务,应用,技术,数据,治理和合规。之前4年主要负责企业整体的技术规划,标准的建立和项目治理。最近一年主要负责数据,涉及到数据平台,数据战略,数据分析,数据建模,数据治理,还涉及到数据主权,隐私保护和数据经济。 因为需要,最近在学习财务,金融和法律。打算先备考CPA,然后CFA,如果可能可以学习法律,备战律考。
  • 欢迎按学习的同学朋友关注,也欢迎大家交流。微信小号【ca_cea】

在本文中,我将演示如何仅使用Angular Signals和一个小函数来管理应用程序的状态。

不仅仅是“与主题一起服务”

让我们从解释为什么在服务中使用一堆BehaviorSubject对象不足以管理异步事件引起的状态修改开始。

在下面的代码中,我们有一个方法saveItems(),它将调用API服务,以异步更新项列表:

saveItems(items: Item[]) {
  this.apiService.saveItems(items).pipe(
    takeUntilDestroyed(this.destroyRef)
  ).subscribe((items) => this.items$.next(items));
}

每次我们调用这种方法,都是在冒险。

例如:假设我们有两个请求,A和B。

请求A在0s 0ms开始,请求B在0s 250ms开始。然而,由于某些问题,API在500ms后对A做出响应,在150ms后对B做出响应。

结果,a在0s 500ms时完成,B在0s 400ms时完成。

这可能会导致保存错误的项目集。

它也适用于GET请求——有时,对搜索请求应用什么过滤器非常重要。

我们可以添加一些支票,如下所示:

saveItems(items: Item[]) {
  if (this.isSaving) {
    return;
  }
  this.isSaving = true;
  this.apiService.saveItems(items).pipe(
    finalize(() => this.isSaving = false),
    takeUntilDestroyed(this.destroyRef)
  ).subscribe((items) => this.items$.next(items));
}

但是,正确的项目集将根本没有机会保存。

这就是为什么我们的Store需要效果。

使用NgRx ComponentStore,我们可以这样写:

 readonly saveItems = this.effect<Item[]>(_ => _.pipe(
   concatMap((items) => this.apiService.saveItems(items)),
   tapResponse(
     (items)=> this.items$.next(items),
     (err) => this.notify.error(err)
   )
));

在这里,您可以确保请求将一个接一个地执行,无论每个请求运行多长时间。

在这里,您可以很容易地为请求排队选择一种策略:switchMap()、concatMap(),exhautMap()或mergeMap()。

基于信号的存储

什么是应用程序状态?应用程序状态是定义应用程序外观和行为的变量集合。

应用程序总是有一些状态,而“Angular?信号”总是有一个值。这是一个完美的匹配,所以让我们使用信号来保持应用程序和组件的状态。

class App {
   $users = signal<User[]>([]);
   $loadingUsers = signal<boolean>(false);
   $darkMode = signal<boolean|undefined>(undefined);
}

这是一个简单的概念,但有一个问题:任何人都可以写信给$loadingUsers。让我们将状态设为只读,以避免全局可写变量可能带来的无限微调器和其他错误:

class App {
   private readonly state = {
     $users: signal<User[]>([]),
     $loadingUsers: signal<boolean>(false),
     $darkMode: signal<boolean|undefined>(undefined),
   } as const;

   readonly $users = this.state.$users.asReadonly();
   readonly $loadingUsers = this.state.$loadingUsers.asReadonly();
   readonly $darkMode = this.state.$darkMode.asReadonly();

   setDarkMode(dark: boolean) {
     this.state.$darkMode.set(!!dark);
   }
}

是的,我们写了更多的行;否则,我们将不得不使用getter和setter,这甚至是更多的行。不,我们不能让它们都是可写的,并添加一些评论“不要写!!”😉

在这个存储中,我们的只读信号(包括使用computed()创建的信号)是状态和选择器的替代品。

剩下的只有:我们需要效果,改变我们的状态。

Angular Signals中有一个名为effect()的函数,但它只对信号的变化做出反应,通常我们应该在向API发出一些请求后修改状态,或者作为对某些异步发出的事件的反应。虽然我们可以使用toSignal()创建额外的字段,然后在Angular的effect()中观察这些信号,但它仍然不能像我们想要的那样对异步代码进行控制(没有switchMap()、没有concatMap(),没有debounceTime()和许多其他东西)。

但是,让我们使用一个著名的、经过充分测试的函数,使用一个强大的API:ComponentStore.effect(),并使其独立!

createEffect()

使用此链接,您可以获得修改后的函数的代码。它很短,但如果你不能理解它是如何在引擎盖下工作的,请不要担心(这需要一些时间):你可以在这里阅读关于如何使用原始effect()方法的文档:NgRx Docs,并以同样的方式使用createEffect()。

如果不键入注释,它非常小:

function createEffect(generator) {
  const destroyRef = inject(DestroyRef);
  const origin$ = new Subject();
  generator(origin$).pipe(
    retry(),
    takeUntilDestroyed(destroyRef)
  ).subscribe();

  return ((observableOrValue) => {
    const observable$ = isObservable(observableOrValue)
      ? observableOrValue.pipe(retry())
      : of(observableOrValue);
    return observable$.pipe(takeUntilDestroyed(destroyRef)).subscribe((value) => {
      origin$.next(value);
    });
  });
}

它被命名为createEffect(),以不干扰Angular的effect()函数。

修改:

  1. createEffect()?is a standalone function. Under the hood, it subscribes to an observable, and because of that?createEffect()?can only be called in an injection context. That’s exactly how we were using the original?effect()?method;
  2. createEffect()?function will resubscribe on errors, which means that it will not break if you forget to add?catchError()?to your API request.

当然,您可以随意添加您的修改:)

把这个函数放在项目的某个地方,现在就可以管理应用程序状态,而不需要任何额外的库:Angular Signals+createEffect()。

Store类型

有三种类型的Store:

  • 全局存储(应用程序级)--应用程序中的每个组件和服务都可以访问;
  • 功能存储(“功能”级别)——某些特定功能的后代可以访问;
  • 本地存储(也称为“组件存储”)--不共享,每个组件都会创建一个新实例,当组件被销毁时,该实例将被销毁。

我编写了一个示例应用程序,向您展示如何使用Angular Signals和createEffect()实现每种类型的存储。我将使用该应用程序中的存储和组件(不带模板),让您看到本文中的代码示例。你可以在这里找到这个应用程序的全部代码:GitHub链接。

Global Store

@Injectable({ providedIn: 'root' })
export class AppStore {
  private readonly state = {
    $planes: signal<Item[]>([]),
    $ships: signal<Item[]>([]),
    $loadingPlanes: signal<boolean>(false),
    $loadingShips: signal<boolean>(false),
  } as const;

  public readonly $planes = this.state.$planes.asReadonly();
  public readonly $ships = this.state.$ships.asReadonly();
  public readonly $loadingPlanes = this.state.$loadingPlanes.asReadonly();
  public readonly $loadingShips = this.state.$loadingShips.asReadonly();
  public readonly $loading = computed(() => this.$loadingPlanes() || this.$loadingShips());

  constructor() {
    this.generateAll();
  }

  generateAll() {
    this.generatePlanes();
    this.generateShips();
  }

  private generatePlanes = createEffect(_ => _.pipe(
    concatMap(() => {
      this.state.$loadingPlanes.set(true);
      return timer(3000).pipe(
        finalize(() => this.state.$loadingPlanes.set(false)),
        tap(() => this.state.$planes.set(getRandomItems()))
      )
    })
  ));

  private generateShips = createEffect(_ => _.pipe(
    exhaustMap(() => {
      this.state.$loadingShips.set(true);
      return timer(3000).pipe(
        finalize(() => this.state.$loadingShips.set(false)),
        tap(() => this.state.$ships.set(getRandomItems()))
      )
    })
  ));
}

要创建全局存储,请添加以下装饰器:
@Injectable({ providedIn: ‘root’ })

在这里,你可以看到,每次你点击紫色的大按钮“Reload”,“飞机”和“飞船”这两个列表都会被重新加载。不同之处在于,“平面”将被连续加载,与您单击按钮的次数一样多。“Ships”将只加载一次,所有连续的点击都将被忽略,直到上一次请求完成。

字段$loading被称为“派生的”——它的值是使用compute()从其他信号的值中创建的。它是角信号中最强大的部分。与基于可观察的存储中的派生选择器相比,computed()具有一些优势:

  • 动态依赖项跟踪:在上面的代码中,当$loadingPlanes()返回true时,$loadingShips()将从依赖项列表中删除。对于非平凡的派生字段,它可能会节省内存;
  • 无毛刺,无脱落;
  • 懒惰的计算:派生值不会在它所依赖的信号的每次变化时重新计算,而是只有在读取该值时(或者如果生成的信号在effect()函数内部或在模板中使用)。

还有一个缺点:你无法控制依赖关系,它们都是自动跟踪的。

Feature Store

@Injectable()
export class PlanesStore {
  private readonly appStore = inject(AppStore);
  private readonly state = {
    $page: signal<number>(0),
    $pageSize: signal<number>(10),
    $displayDescriptions: signal<boolean>(false),
  } as const;

  public readonly $items = this.appStore.$planes;
  public readonly $loading = this.appStore.$loadingPlanes;
  public readonly $page = this.state.$page.asReadonly();
  public readonly $pageSize = this.state.$pageSize.asReadonly();
  public readonly $displayDescriptions = this.state.$displayDescriptions.asReadonly();

  public readonly paginated = createEffect<PageEvent>(_ => _.pipe(
    debounceTime(200),
    tap((event) => {
      this.state.$page.set(event.pageIndex);
      this.state.$pageSize.set(event.pageSize);
    })
  ));

  setDisplayDescriptions(display: boolean) {
    this.state.$displayDescriptions.set(display);
  }
}

该功能的根组件(或路由)应“提供”此存储:

@Component({
  // ...
  providers: [
    PlanesStore
  ]
})
export class PlanesComponent { ... }

不要将此存储添加到子代组件的提供程序中,否则,它们将创建自己的本地功能存储实例,这将导致令人不快的错误。

Local Store

@Injectable()
export class ItemsListStore {
  public readonly $allItems = signal<Item[]>([]);

  public readonly $page = signal<number>(0);

  public readonly $pageSize = signal<number>(10);

  public readonly $items: Signal<Item[]> = computed(() => {
    const pageSize = this.$pageSize();
    const offset = this.$page() * pageSize;
    return this.$allItems().slice(offset, offset + pageSize);
  });

  public readonly $total: Signal<number> = computed(() => this.$allItems().length);

  public readonly $selectedItem = signal<Item | undefined>(undefined);

  public readonly setSelected = createEffect<{
    item: Item,
    selected: boolean
  }>(_ => _.pipe(
    tap(({ item, selected }) => {
      if (selected) {
        this.$selectedItem.set(item);
      } else {
        if (this.$selectedItem() === item) {
          this.$selectedItem.set(undefined);
        }
      }
    })
  ));
}

与功能存储非常相似,组件应该为自己提供此存储:

@Component({
  selector: 'items-list',
  // ...
  providers: [
    ItemsListStore
  ]
})
export class ItemsListComponent { ... }

Component as a Store

如果我们的组件没有那么大,我们确信它不会那么大,而且我们只是不想为这个小组件创建一个存储区,该怎么办?

我有一个组件的例子,是这样写的:

@Component({
  selector: 'list-progress',
  // ...
})
export class ListProgressComponent {
  protected readonly $total = signal<number>(0);
  protected readonly $page = signal<number>(0);
  protected readonly $pageSize = signal<number>(10);

  protected readonly $progress: Signal<number> = computed(() => {
    if (this.$pageSize() < 1 && this.$total() < 1) {
      return 0;
    }
    return 100 * (this.$page() / (this.$total() / this.$pageSize()));
  });


  @Input({ required: true })
  set total(total: number) {
    this.$total.set(total);
  }

  @Input() set page(page: number) {
    this.$page.set(page);
  }

  @Input() set pageSize(pageSize: number) {
    this.$pageSize.set(pageSize);
  }

  @Input() disabled: boolean = false;
}

在Angular的版本17中,将引入input()函数来创建作为信号的输入,从而使此代码变得更短。

此示例应用程序部署在此处:?GitHub Pages link.

您可以使用它来查看不同列表的状态是如何独立的,功能状态如何在功能的组件之间共享,以及所有组件如何使用应用程序全局状态中的列表。

在代码中,您可以找到对事件的反应、异步状态修改的排队、派生(计算)状态字段和其他详细信息的示例。

我知道我们可以改进代码,让事情变得更好——但这不是这个示例应用程序的重点。这里的所有代码只有一个目的:说明本文并解释事情是如何工作的。

我已经演示了如何在没有第三方库的情况下管理Angular应用程序状态,只使用Angular Signals和一个附加函数。

感谢您的阅读!

文章链接:

【Angular 开发】Angular 信号的应用状态管理 | 程序员云开发,云时代的程序员.

欢迎收藏??【全球IT瞭望】,【架构师酒馆】和【开发者开聊】.

文章来源:https://blog.csdn.net/jiagoushipro/article/details/134901376
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。