跳转到主要内容

在本文中,我将演示如何仅使用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和一个附加函数。

感谢您的阅读!

 

🅰️ More about Angular Signals 🚦

文章链接