[GPT-4]Angular Singals 完全指南

原文地址:blog.angular-university.io/angular-sig...
这篇文章介绍了Angular的新功能Signals,这是一种简化状态管理和优化组件渲染的反应式原语。Signals允许开发者轻松跟踪和响应数据变化,通过最小化不必要的组件更新和检查来提高应用性能。指南强调使用Signals的简便性,同时警告避免常见的陷阱,如不当处理条件逻辑、直接变更复杂signal值和不必要的自定义。它鼓励在Angular应用中尝试Signals,亲身体验其在提高反应性和效率方面的好处。 --- Summary by GPT-4

在Angular应用程序中使用Signals的完整指南。了解Signals,它们的好处,最佳实践和模式,并避免最常见的陷阱。

Angular Signals最初在Angular 17中引入,它们非常强大。

Signals的整个目标是为开发者提供一个新的、易于使用的反应式原语,可以用来以反应式风格构建应用程序。

Signals提供了一个易于理解的API来向框架报告数据变化,允许框架以一种迄今为止不可能实现的方式优化变更检测和重新渲染。

有了Signals,Angular将能够精确地确定页面的哪些部分需要更新,并且只更新这些部分,不会更多。

这与当前发生的情况形成了鲜明对比,例如使用默认的变更检测时,Angular必须检查页面上的所有组件,即使它们使用的数据没有变化。

Signals的全部目的是启用非常细粒度的DOM更新,这是我们当前可用的变更检测系统所无法实现的。

Signals的全部目的是通过摆脱Zone.js来提高应用程序的运行时性能。

即使没有运行时性能的好处,Signals也是以反应式风格构建应用程序的一种绝佳方式。

相比于在应用程序中传播数据变化时使用RxJS,Signals的使用和理解要简单得多

Signals是一个游戏规则改变者,它们将Angular中的反应性提升到了一个全新的水平!

在这个指南中,我们将详细探索Signals API,解释Signals一般是如何工作的,以及讨论你应该注意的最常见的陷阱。

注意:Signals还没有接入Angular的变更检测系统。在这一点上,尽管大部分Signals API已经退出开发者预览阶段,但还没有任何基于signal的组件。 这可能会在Angular 17.2左右可用,待确认。 但这并不妨碍我们从现在开始就学习Signals及其优势,为即将到来的内容做准备。

目录

本文包括以下主题:

  • 什么是Signals?
  • 如何读取signal的值?
  • 如何修改signal的值?
  • update() Signal API
  • 使用signals而不是原始值的主要优势是什么?
  • computed() Signal API
  • 我们如何订阅一个signal?
  • 我们可以从计算signal中读取signal的值而不创建依赖吗?
  • 创建计算signal时要注意的主要陷阱是什么?
  • signal依赖是否仅基于对compute函数的初始调用来确定?
  • 带有数组和对象值的Signals
  • 覆盖signal相等性检查
  • 使用effect() API检测signal变化
  • Signals与变更检测之间的关系是什么?
  • 如何修复错误NG0600:不允许写入signals
  • 如何在需要时从effect内部设置signals
  • 默认的effect清理机制
  • 如何手动清理effects
  • effect销毁时执行清理操作
  • 只读signals
  • 在多个组件中使用signal
  • 如何创建基于signal的反应式数据服务
  • Signals和OnPush组件
  • 我可以在组件/存储/服务之外创建signals吗?
  • Signals与RxJs的比较
  • 总结

注意:如果你想了解Angular 17除Signals之外的其他特性,请查看以下文章:

什么是Signals?

简单来说,signal是一种反应式原语,代表一个值,并且允许我们以一种受控的方式改变这个值,并随时间追踪其变化。

Signals不是Angular独有的新概念。它们在其他框架中已经存在多年,有时候会用不同的名字。

为了更好地理解signals,让我们从一个还未使用signals的简单示例开始,然后用Signals API重写同一个示例。

首先,假设我们有一个简单的Angular组件,其中有一个计数器变量:

scss 复制代码
@Component(
    selector: "app",
    template: `

<h1>Current value of the counter {{counter}}</h1>

<button (click)="increment()">Increment</button>

`)
export class AppComponent {

    counter: number = 0;

    increment() {
        this.counter++;
    }

}

正如你所看到的,这是一个非常简单的组件。

它仅仅显示一个计数器的值,并且有一个按钮来增加计数器。这个组件使用了Angular默认的变更检测机制。

这意味着{{counter}}表达式以及页面上的任何其他表达式在每次事件之后都会被检查变化,例如点击增加按钮。

你可以想象,这可能是尝试检测页面上需要更新什么的一种潜在低效方式。

在我们的组件案例中,这种方法实际上是必要的,因为我们使用了一个可变的普通JavaScript成员变量,如counter来存储我们的状态。

所以当一个事件发生时,页面上几乎任何东西都可能影响到那些数据。

此外,点击增加按钮也很容易在页面上的其他地方触发变化,而不仅仅是在这个组件内部。

想象一下,如果我们调用一个共享服务,它会影响页面的多个部分。

使用默认的变更检测,Angular无法确切知道页面上什么变了,这就是为什么我们不能对发生了什么做出任何假设,我们需要检查一切!

因为我们没有任何保证什么可能改变或可能没有改变,我们需要扫描整个组件树和每个组件上的所有表达式。

使用默认的变更检测,别无他法。

但这就是Signals来拯救的地方!

这是相同的简单示例,但这次使用Signals API重写:

scss 复制代码
@Component(
    selector: "app",
    template: `

<h1>Current value of the counter {{counter()}}</h1>

<button (click)="increment()">Increment</button>

`)
export class AppComponent {

  counter = signal(0);

  constructor() {

    console.log(`counter value: ${this.counter()}`)

  }

  increment() {

    console.log(`Updating counter...`)

    this.counter.set(this.counter() + 1);

  }

}

正如您所见,我们组件的基于signal的版本看起来并没有太大不同。

主要的区别是,我们现在使用signal() API将我们的计数器值包装在一个signal中,而不是仅仅使用一个普通的计数器Javascript成员变量。

这个signal代表了从零开始的计数器的值。

我们可以看到,signal就像是我们想要跟踪的值的容器。

如何读取signal的值?

Signal封装了它所代表的值,但我们可以随时通过调用signal作为一个函数来获取该值,无需传递任何参数。

请注意我们AppComponent的基于signal版本的构造函数中的代码:

javascript 复制代码
   constructor() {

    console.log(`counter value: ${this.counter()}`)

}

正如您所见,通过调用counter(),我们得到了signal中封装的值,在这个案例中将解析为零(signal的初始值)。

如何修改signal的值?

更改signal值的方法有几种不同的方式。

在我们的案例中,我们在实现计数器增加函数时使用了set() API:

javascript 复制代码
increment() {

    console.log(`Updating counter...`)

    this.counter.set(this.counter() + 1);

  }

我们可以使用set() API在signal中设置任何我们需要的值,只要该值与signal的初始值类型相同。

所以在我们的计数器signal的情况下,我们只能设置数字,因为signal的初始值是零。

update() Signal API

除了set() API之外,我们还可以使用update() API。

让我们用它来重写我们的计数器增加函数:

javascript 复制代码
 increment() {

    console.log(`Updating counter...`)

    this.counter.update(counter => counter + 1);

}

正如您所见,update API接受一个函数作为输入参数,这个函数接收signal的当前值,并返回signal的新值。

两个版本的增量方法都是等效的,工作得同样好。

使用signals而不是原始值的主要优势是什么?

到这一点,我们现在可以看到,signal只是一个值的轻量级包装器或容器。

那么使用它的优势是什么呢?

主要优势是我们可以在signal值改变时得到通知,然后对新的signal值做出响应。

这与我们仅使用一个普通值作为计数器的情况不同。

使用普通值时,我们无法在值改变时得到通知。

但是使用Signals?很简单!

这就是首先使用signals的全部要点。

computed() Signal API

Signals可以从其他signals创建和派生。当一个signal更新时,所有依赖于它的signals将会自动更新。

例如,假设我们希望在我们的组件上有一个派生计数器,即计数器值乘以10的结果。

我们可以使用computed() API基于我们的计数器signal创建一个派生signal:

scss 复制代码
@Component(
    selector: "app",
    template: `

  <h3>Counter value {{counter()}}</h3>

  <h3>10x counter: {{derivedCounter()}}</h3>

  <button (click)="increment()">Increment</button>

    `)
export class AppComponent {

    counter = signal(0);

    derivedCounter = computed(() => {

        return this.counter() * 10;

    })

    increment() {

        console.log(`Updating counter...`)

        this.counter.set(this.counter() + 1);

    }

}

computed API通过接收一个或多个源signals,并创建一个新的signal来工作。

当源signal变化时(在我们的案例中是计数器signal),派生的signal derivedCounter也会立即更新。

因此,当我们点击增加按钮时,counter将有初始值1,而derivedCounter将是10,然后是2和20,3和30,等等。

我们如何订阅一个signal?

请注意,derivedCounter signal没有以任何明确的方式订阅源counter signal。

它唯一做的就是在其computed函数内部使用counter()调用源signal。

但这就是我们需要做的所有事情来将这两个signals连接在一起!

现在,无论何时counter源signal有一个新值,派生signal也会自动更新。

这听起来有点神奇,让我们来分析一下发生了什么:

  • 每当我们创建一个computed signal时,传递给computed()的函数至少会被调用一次,以确定派生signal的初始值。
  • Angular在调用compute函数时会跟踪,并注意到它知道的其他signals正在被使用。
  • Angular会注意到,在计算derivedCounter的值时,调用了signal的getter函数counter()。
  • 因此,Angular现在知道这两个signals之间存在依赖关系,所以每当counter signal被设置了一个新值时,派生的derivedCounter也会被更新。

正如您所见,通过这种方式,框架拥有了关于哪些signals依赖于其他signals的所有信息。

Angular知道整个signal依赖树,并且知道改变一个signal的值将如何影响应用中的所有其他signals。

我们可以从计算signal中读取signal的值而不创建依赖吗?

在某些高级场景中,我们可能想要从另一个computed signal读取signal的值,但不在两个signals之间创建任何依赖。

这种需求应该很少见,但如果你遇到了需要这样做的情况,以下是如何操作的:

scss 复制代码
@Component(
    selector: "app",
    template: `

  <h3>Counter value {{counter()}}</h3>

  <h3>10x counter: {{derivedCounter()}}</h3>

    `)
export class AppComponent {

    counter = signal(0);

    derivedCounter = computed(() => {

        return untracked(this.counter) * 10;

    })

}

通过使用untracked API,我们可以访问counter signal的值,而不在counter和derivedCounter signal之间创建依赖。

请注意,这个untracked特性是一个高级功能,很少需要使用,如果有的话。

如果你发现自己经常使用这个功能,那么可能有些地方不对劲。

创建计算signal时要注意的主要陷阱是什么?

为了确保一切顺利进行,我们需要确保以某种方式计算我们的派生signals。

记住,只有当Angular注意到一个signal是计算另一个signal的值所必需的时候,它才会认为一个signal依赖于另一个signal。

这意味着我们需要小心地在computed函数内部引入条件逻辑。

这里有一个例子,展示了事情如何容易出错:

kotlin 复制代码
@Component(
    selector: "app",
    template: `

  <h3>Counter value {{counter()}}</h3>

  <h3>Derived counter: {{derivedCounter()}}</h3>

  <button (click)="increment()">Increment</button>

  <button (click)="multiplier = 10">
    Set multiplier to 10
  </button>

`)
export class AppComponent {

    counter = signal(0);

    multiplier: number = 0;

    derivedCounter = computed(() => {

        if (this.multiplier < 10) {
            return 0
        }
        else {
            return this.counter() * this.multiplier;
        }
    })

    increment() {

        console.log(`Updating counter...`)

        this.counter.set(this.counter() + 1);

    }

}

正如你在这个示例中看到的,compute函数中有一些条件逻辑。

我们像之前一样调用counter()源signal,但只有在满足某个条件时才这么做。

这个逻辑的目标是通过某些用户操作(如点击"将乘数设置为10"的按钮)动态地设置乘数的值。

但这个逻辑并不如预期那样工作!

如果你运行它,计数器本身仍然会被增加。

但是表达式{{derivedCounter()}}在"设置乘数为10"的按钮被点击之前和之后,都会被评估为零。

问题在于,当我们计算派生值时,最初没有进行对counter()的调用。

对counter()的调用是在一个else分支内进行的,这个分支最初不会运行。

因此,Angular不知道counter signal和derivedCounter signal之间存在依赖。

在Angular看来,这两个signals被认为是完全独立的,没有任何联系。

这就是为什么当我们更新counter的值时,derivedCounter不会被更新。

这意味着,在定义computed signals时,我们需要有些小心。

如果一个派生signal依赖于一个源signal,我们需要确保每次调用compute函数时都调用源signal。

否则,两个signals之间的依赖关系将被打破。

这并不意味着我们在compute函数内部完全不能有任何条件分支。

例如,以下代码将正确工作:

kotlin 复制代码
@Component(
    selector: "app",
    template: `

  <h3>Counter value {{counter()}}</h3>

  <h3>Derived counter: {{derivedCounter()}}</h3>

  <button (click)="increment()">Increment</button>

  <button (click)="multiplier = 10">
    Set multiplier to 10
  </button>

`)
export class AppComponent {

    counter = signal(0);

    multiplier: number = 0;

    derivedCounter = computed(() => {
        if (this.counter() == 0) {
            return 0
        }
        else {
            return this.counter() * this.multiplier;
        }
    })

    increment() {

        console.log(`Updating counter...`)

        this.counter.set(this.counter() + 1);

    }

}

在这个版本的代码中,我们的compute函数现在在每次调用中都调用了this.counter()。

因此,Angular现在可以识别出两个signals之间的依赖关系,代码按预期工作。

这意味着在第一次点击"将乘数设置为10"的按钮后,乘数将被正确应用。

signal依赖是否仅基于对compute函数的初始调用来确定?

不,派生signal的依赖是基于其最后计算出的值动态识别的。

因此,每次调用computed函数时,都会重新识别computed signal的源signals。

这意味着signal的依赖是动态的,它们并非在signal的整个生命周期内都是固定的。

再次强调,这意味着我们在定义派生signal时,需要对我们创建的任何条件逻辑小心谨慎。

带有数组和对象值的Signals

到目前为止,我们展示的示例中signal的值都是原始类型,比如数字。

但如果我们定义一个其值为数组或对象的signal会发生什么呢?

数组和对象在大多数情况下的工作方式与原始类型相同,但有几点需要注意。

例如,让我们来看看一个其值为数组的signal,以及另一个其值为对象的signal的情况:

kotlin 复制代码
@Component(
    selector: "app",
    template: `

  <h3>List value: {{list()}}</h3>

  <h3>Object title: {{object().title}}</h3>

`)
export class AppComponent {

    list = signal([
        "Hello",
        "World"
    ]);

    object = signal({
       id: 1,
       title: "Angular For Beginners"
    });

    constructor() {

        this.list().push("Again");

        this.object().title = "overwriting title";
    }

}

这些signals没有什么特别之处,我们可以像往常一样通过调用signal作为函数来访问它们的值。

但关键要注意的是,与原始值不同,没有什么可以阻止我们直接通过调用push来改变数组的内容,或者改变对象属性。

因此,在这个例子中,屏幕上生成的输出将是:

  • 在list的情况下是"Hello", "World", "Again"
  • 在对象标题的情况下是"overwriting title"

当然,这并不是Signals的预期用法!

相反,我们希望总是使用set()和update() API来更新signal的值。

通过这样做,我们给所有派生signals一个更新自身的机会,并在页面上反映这些变化。

通过直接访问signal值并直接修改其值,我们绕过了整个signal系统,可能会引起各种各样的bug。

值得记住的是,我们应该避免直接修改signal值,而应该始终使用Signals API。

这值得一提,因为Signals API目前没有防止这种误用的保护机制,比如预防性地冻结数组或对象值。

覆盖signal相等性检查

关于数组或对象signals的另一件值得一提的事情是,默认的相等性检查是"==="。

这个相等性检查很重要,因为只有当我们试图发出的新值与前一个值不同时,signal才会发出新值。

如果我们试图发出的值被认为与前一个值相同,那么Angular将不会发出新的signal值。

这是一种性能优化,可以防止在我们系统地发出相同值的情况下,不必要地重新渲染页面。

然而,默认行为是基于"==="的引用相等性,它不允许我们识别出功能上相同的数组或对象。

如果我们想做到这一点,我们需要覆盖signal的相等性函数并提供我们自己的实现。

为了理解这一点,让我们从一个仍然使用signal对象默认相等性检查的简单示例开始。

然后我们从中创建一个派生signal:

kotlin 复制代码
@Component(
    selector: "app",
    template: `

  <h3>Object title: {{title()}}</h3>

  <button (click)="updateObject()">Update</button>

`)
export class AppComponent {

    object = signal({
            id: 1,
            title: "Angular For Beginners"
        });

    title = computed(() => {

        console.log(`Calling computed() function...`)

        const course = this.object();

        return course.title;

    })

    updateObject() {

      // We are setting the signal with the exact same
      // object to see if the derived title signal will
      // be recalculated or not
      this.object.set({
        id: 1,
        title: "Angular For Beginners"
      });

    }

}

在这个示例中,如果我们多次点击更新按钮,我们将在控制台中得到多条日志行:

scss 复制代码
Calling computed() function...
Calling computed() function...
Calling computed() function...
Calling computed() function...
etc.

这是因为默认的"==="无法检测到我们传递给对象signal的值在功能上等同于当前值。

因此,signal将认为这两个值是不同的,因此任何依赖于对象signal的computed signals也将被计算。

如果我们想避免这种情况,我们需要传递我们的相等性函数给signal:

css 复制代码
object = signal(
  {
    id: 1,
    title: "Angular For Beginners",
  },
  {
    equal: (a, b) => {
      return a.id === b.id && a.title == b.title;
    },
  }
);

有了这个相等性函数,我们现在基于其属性值进行对象的深度比较。

有了这个新的相等性函数,无论我们点击更新按钮多少次,派生signal只会计算一次:

Calling computed() function...

值得一提的是,在大多数情况下,我们不应该为我们的signals提供这种类型的相等性函数。

实际上,默认的相等性检查工作得很好,使用自定义相等性检查很少会有任何明显的差异。

编写这种类型的自定义相等性检查可能导致可维护性问题和各种奇怪的bug,例如,如果我们添加了一个属性到对象并忘记更新比较函数。

相等性函数在这个指南中只是为了完整性而覆盖,在罕见的情况下你需要它们,但总的来说,对于大多数用例,没有必要使用这个功能。

使用effect() API检测signal变化

使用computed API向我们展示了signals的一个最有趣的属性,即我们可以以某种方式检测到它们何时发生变化。

毕竟,这正是computed() API在做的事情,对吗?

它检测到一个源signal发生了变化,并作为响应,它计算出一个派生signal的值。

但是,如果我们不是想计算一个依赖signal的新值,而只是想检测出某个值因某些其他原因而变化了怎么办?

想象一下,你处于一个需要检测到一个signal(或一组signals)的值发生变化以执行某种副作用的情况,这种副作用不会修改其他signals。

例如,这可能是:

  • 使用日志库记录一系列signals的值
  • 将signal的值导出到localStorage或cookie
  • 在后台透明地将signal的值保存到数据库
  • 等等

所有这些场景都可以使用effect() API来实现:

javascript 复制代码
//The effect will be re-run whenever any
// of the signals that it uses changes value.
effect(() => {

  // We just have to use the source signals
  // somewhere inside this effect
  const currentCount = this.counter();

  const derivedCounter = this.derivedCounter();

  console.log(`current values: ${currentCount}
    ${derivedCounter}`);

});

这个effect将在counter或derivedCounter signals发出新值时,向控制台打印出一个日志声明。

注意,这个effect函数在声明时至少会运行一次。

这次初始运行允许我们确定effect的初始依赖。

就像computed() API的情况一样,effect的signal依赖是基于effect函数的最后一次调用动态确定的。

Signals与变更检测之间的关系是什么?

我想你可以看出这是往哪个方向去的...

Signals使我们能够轻松跟踪应用数据的变化。

现在想象以下情况:假设我们把所有应用数据放在signals里!

首先要提到的一点是,因此应用的代码不会因此变得复杂。

Signals API和signal概念相当直接,所以一个到处使用signals的代码库会保持相当可读。

尽管如此,那应用代码的可读性不会像使用普通Javascript成员变量作为应用数据时那么简单。

那么优势在哪里呢?

我们为什么想要转向基于signal的方法来处理我们的数据呢?

优势在于,有了signals,我们可以轻松地检测到应用数据的任何部分何时发生变化,并自动更新任何依赖。

现在想象Angular的变更检测被接入到你应用的signals中,Angular知道每个signal在哪些组件和表达式中被使用。

这将使Angular准确知道应用中哪些数据发生了变化,以及响应新signal值需要更新哪些组件和表达式。

就不再需要像默认变更检测那样检查整个组件树了!

如果我们给Angular保证,所有应用数据都放在signal里,Angular突然间就有了执行最优化变更检测和重新渲染所需的所有必要信息。

Angular将知道如何以最优化的方式更新页面以显示最新数据。

这就是使用signals的主要性能优势!

仅仅通过在signal中包装我们的数据,我们就使Angular能够在DOM更新方面提供可能的最佳性能。

有了这个,你现在对signals如何工作以及它们为什么有用有了相当好的了解。

现在的问题是,如何正确使用它们?

在讨论一些在应用中使用signals的常见模式之前,让我们先快速完成对effect() API的覆盖。

如何修复错误NG0600:不允许写入signals

默认情况下,Angular不允许在effect函数内部更改signal值。

例如,我们不能做这样的操作:

scss 复制代码
@Component({...})
export class CounterComponent {
  counter = signal(0);

  constructor() {
    effect(() => {
      this.counter.set(1);
    });
  }
}

默认情况下,这种代码是不允许的,而且有充分的理由。

在这个示例的特定情况下,这甚至会创建一个无限循环并破坏整个应用程序!

如何在需要时从effect内部设置signals

然而,在某些情况下,我们仍然希望能够从effect内部更新其他signals。

为了允许这样做,我们可以向effect传递allowSignalWrites选项:

kotlin 复制代码
@Component({...})
export class CounterComponent {
  count = signal(0);

  constructor() {

    effect(() => {
      this.count.set(1);
    },
        {
            allowSignalWrites: true
        });

  }
}

这个选项需要非常小心地使用,仅在特殊情况下使用。

在大多数情况下,你不应该需要这个选项。如果你发现自己在应用中系统地使用这个选项,重新审视你的应用设计,因为可能有问题。

默认的effect清理机制

effect是一个响应signal值变化而被调用的函数。

就像任何函数一样,它可以通过闭包在应用程序中引用其他变量。

这意味着,就像任何函数一样,在使用effects时,我们需要注意创建意外内存泄露的潜在风险。

为了帮助处理这个问题,Angular将默认自动清理effect函数,这取决于它被使用的上下文。

例如,如果你在组件内创建effect,Angular将在组件被销毁时清理effect,指令等也是如此。

如何手动清理effects

但有时,出于某种原因,你可能想要手动清理你的effects。

不过,这只在罕见情况下才有必要。

如果你在应用程序中系统地手动清理effects,很可能有些地方不对劲。

但如果你需要这样做,可以通过调用第一次创建时返回的EffectRef实例上的destroy方法来手动销毁effect()。

在这些情况下,你可能还想通过使用manualCleanup选项禁用默认的清理行为:

typescript 复制代码
@Component({...})
export class CounterComponent {

  count = signal(0);

  constructor() {

    const effectRef = effect(() => {

      console.log(`current value: ${this.count()}`);
    },
        {
            manualCleanup: true
        });

    // we can manually destroy the effect
    // at any time
    effectRef.destroy();
  }
}

manualCleanup标志禁用了默认的清理机制,允许我们完全控制effect何时被销毁。

effectRef.destroy()方法将销毁effect,将其从任何即将进行的计划执行中移除,并清理effect函数范围外对变量的任何引用,从而潜在地防止内存泄露。

effect销毁时执行清理操作

有时,仅仅从内存中移除effect函数并不足以进行适当的清理。

在某些情况下,我们可能想要执行某种清理操作,比如在effect被销毁时关闭网络连接或以其他方式释放一些资源。

为了支持这些用例,我们可以向effect传递一个onCleanup回调函数:

typescript 复制代码
@Component({...})
export class CounterComponent {

  count = signal(0);

  constructor() {

    effect((onCleanup) => {

      console.log(`current value: ${this.count()}`);

      onCleanup(() => {
        console.log("Perform cleanup action here");
      });
    });
  }
}

当清理发生时,这个函数将被调用。

在onCleanup函数内部,我们可以进行任何我们想要的清理,例如:

  • 取消订阅一个observable
  • 关闭网络或数据库连接
  • 清除setTimeout或setInterval
  • 等等

现在让我们探索一些与signals相关的概念,然后展示一些你在应用signals到你的应用程序时可能需要的常见模式。

只读signals

我们已经在使用只读signals了,即使没有注意到。

这些是其值不能被改变的signals。它们就像是JavaScript语言中const的等价物。

只读signals可以被访问以读取它们的值,但不能使用set或update方法进行更改。只读signals没有任何内置机制来防止它们的值被深度修改。- Angular repo

只读signals可以通过以下方式创建:

  • computed()
  • signal.asReadonly()

让我们尝试更改派生signal的值,看看会发生什么:

scss 复制代码
@Component(
    selector: "app",
    template: `

  <h3>Counter value {{counter()}}</h3>

  <h3>Derived counter: {{derivedCounter()}}</h3>

`)
export class AppComponent {

    counter = signal(0);

    derivedCounter = computed(() => this.counter() * 10)

    constructor() {

        // this works as expected
        this.counter.set(5);

        // this throws a compilation error
        this.derivedCounter.set(50);

    }

}

正如我们所见,我们可以为counter signal设置新值,它是一个正常的可写signal。

但我们不能在derivedCounter signal上设置值,因为set()和update() API都不可用。

这意味着derivedCounter是一个只读signal。

如果需要,你可以很容易地从一个可写signal派生出一个只读signal:

scss 复制代码
@Component(
    selector: "app",
    template: `

  <h3>Counter value {{counter()}}</h3>

`)
export class AppComponent {

    counter = signal(0);

    constructor() {

        const readOnlyCounter = this.counter.asReadonly();

        // this throws a compilation error
        readOnlyCounter.set(5);

    }

}

注意,反过来是不可能的:你没有API可以从一个只读signal创建一个可写signal。

为了做到这一点,你需要用只读signal当前的值创建一个新的signal。

在多个组件中使用signal

现在让我们讨论一些在应用中使用signals的常见模式。

如果一个signal仅在一个组件内使用,那么最好的解决方案是将其转换为我们迄今为止一直在做的成员变量。

但如果signal数据在应用的多个不同地方需要怎么办?

好吧,没有什么阻止我们创建一个signal并在多个组件中使用它。

当signal变化时,所有使用该signal的组件都会更新。

javascript 复制代码
// main.ts
import { signal } from "@angular/core";

export const count = signal(0);

正如你所见,我们的signal在一个单独的文件中,因此我们可以在任何需要它的组件中导入它。

让我们创建两个使用count signal的组件。

typescript 复制代码
// app.component.ts
import { Component } from "@angular/core";
import { count } from "./main";

@Component({
  selector: "app",
  template: `
    <div>
      <p>Counter: {{ count() }}</p>
      <button (click)="increment()">Increment from HundredIncrComponent</button>
    </div>
  `,
})
export class HundredIncrComponent {
  count = count;

  increment() {
    this.count.update((value) => value + 100);
  }
}

在这里,我们导入了count signal并在这个组件中使用它,我们可以在应用中的任何其他组件中做同样的事情。

在一些简单的场景中,这可能就是你所需要的一切。

然而,我认为对于大多数应用程序来说,这种方法不会足够。

这有点像在Javascript中使用全局可变变量。

任何人都可以改变它,或者在signals的情况下,通过调用set()在其上发出一个新值。

我认为在大多数情况下,这不是一个好主意,因为这与全局可变变量通常不是一个好主意的原因相同。

我们不想给任何人无限制地访问这个signal,我们想确保这个signal的访问以某种方式被封装并保持在控制之下。

如何创建基于signal的反应式数据服务

在多个组件之间共享一个可写signal的最简单模式是将signal包装在一个数据服务中,像这样:

typescript 复制代码
@Injectable({
  providedIn: "root",
})
export class CounterService {

  // this is the private writeable signal
  private counterSignal = signal(0);

  // this is the public read-only signal
  readonly counter = this.counterSignal.asReadonly();

  constructor() {
    // inject any dependencies you need here
  }

  // anyone needing to modify the signal
  // needs to do so in a controlled way
  incrementCounter() {
    this.counterSignal.update((val) => val + 1);
  }
}

这个模式与使用RxJs和BehaviorSubject的Observable数据服务非常相似(如果你熟悉那个模式的话,看看这个指南 blog.angular-university.io/how-to-buil...

不同之处在于,这个服务更直接易懂,这里的高级概念更少。

我们可以看到,可写signal counterSignal是从服务中保持私有的。

任何需要signal值的人都可以通过它的公共只读对应物,counter成员变量来获取它。

任何需要修改counter值的人只能通过incrementCounter公共方法以受控方式做到这一点。

这样,任何验证或错误处理的业务逻辑都可以添加到方法中,没有人可以绕过它们。

想象一下,有一个规则说计数器不能超过100。

使用这种模式,我们可以在incrementCounter方法中一个地方轻松实现这一点,而不是在应用中的每个地方重复那个逻辑。

我们也可以更好地重构和维护应用程序。

如果我们想找出应用中所有增加计数器的部分,我们只需要使用我们的IDE找到incrementCounter方法的使用。

如果我们只是直接提供对signal的访问,这种类型的代码分析将不可能实现。

此外,如果signal需要访问任何依赖项才能正常工作,这些可以在构造函数中接收,就像在任何其他服务中一样。

这里发挥作用的一个原则是封装原则。

我们不希望应用的任何部分都能自由地为signal发出新值,我们只希望以受控的方式允许这样做。

所以总的来说,如果你有一个在多个组件之间共享的signal,对于大多数情况,你可能最好使用这种模式,而不是直接提供对可写signal的访问。

Signals和OnPush组件

OnPush组件是只在其输入属性改变时或订阅了async管道的Observables发出新值时更新的组件。

它们在其输入属性被改变时不会更新。

现在OnPush组件也与signals集成。

当在组件上使用signals时,Angular将该组件标记为signal的一个依赖。当signal变化时,组件被重新渲染。

在OnPush组件的情况下,当附加到它的signal更新时,它们也会被重新渲染:

scss 复制代码
@Component({
  selector: "counter",
  template: `
    <h1>Counter</h1>
    <p>Count: {{ count() }}</p>
    <button (click)="increment()">Increment</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CounterComponent {
  count = signal(0);

  increment() {
    this.count.update((value) => value + 1);
  }
}

在这个示例中,如果我们点击增加按钮,组件将被重新渲染,意味着Signals直接与OnPush集成。

这意味着在这种情况下,我们不再需要注入ChangeDetectorRef并调用markForCheck来更新OnPush组件。

考虑下面不使用signals的示例:

typescript 复制代码
@Component({
  selector: "app",
  standalone: true,
  template: ` Number: {{ num }} `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
class ExampleComponent {
  num = 1;

  private cdr = inject(ChangeDetectorRef);

  ngOnInit() {
    setInterval(() => {
      this.num = this.num + 1;
      this.cdr.markForCheck();
    }, 1000);
  }
}

正如你所见,对于相同的等效功能,这一切都要复杂得多。基于signal的版本要简单得多。

我可以在组件/存储/服务之外创建signals吗?

当然可以!你可以在任何你想要的地方创建signals。没有约束说signal需要在组件、存储或服务内部。

我们之前已经演示过了。这就是Signals的美丽和力量。不过,在大多数情况下,你可能想要像我们看到的那样在服务中包装signal。

Signals与RxJs的比较

Signals并不是直接替代RxJs,但它们在某些通常需要RxJs的情况下提供了一个更易用的替代品。

例如,当涉及到透明地将数据变化传播到应用的多个部分时,signals是RxJS Behavior Subjects的一个很好的替代品。

我希望你喜欢这篇文章,要获得更多类似文章的通知,我邀请你订阅我们的新闻稿:angular-university.io/course/angu...

总结

在这个指南中,我们探索了Angular Signals API和一个新的反应式原语概念:signal。

我们已经学到,通过使用signals来跟踪我们所有的应用状态,我们使Angular能够轻松知道视图的哪些部分需要被更新。

总结来说,Signals的主要目标是:

  • 提供一个更易于理解的新反应式原语,使我们能够更容易地以反应式风格构建应用程序。
  • 避免不需要重新渲染的组件的不必要重新渲染。
  • 避免检查数据没有变化的组件的不必要检查。

Signals API使用起来非常直接,但要注意你可能遇到的一些常见陷阱:

  • 在定义effects或computed signals时,当在条件块内读取源signals的值时要小心。
  • 当使用数组或对象signals时,避免直接改变signal的值。
  • 除非必要,不要过度使用提供你自己的相等性函数或进行手动effect清理等高级特性。默认行为在大多数情况下应该工作得很好。

我邀请你在你的应用中尝试使用Signals,亲自看看这种魔法!

相关推荐
To_OC3 小时前
别再串行写 await 了,Promise.all 才是并行请求的正确打开方式
前端·javascript·promise
vipbic3 小时前
中后台越做越乱后,我用插件化把它救回来了
前端·vue.js
Hyyy3 小时前
Computer Use 适合做什么,不适合做什么——一次真实使用后的思考
前端
小和尚同志4 小时前
前端 AI 单元测试思考与落地
前端·人工智能·aigc
invicinble5 小时前
c端系统,其实更像一个信息展示平台
前端
李姆斯6 小时前
管理是否可以被完全量化
前端·产品经理·团队管理
名字还没想好☜6 小时前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
广州灵眸科技有限公司7 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) INI文件操作
java·前端·javascript·网络·人工智能
江华森7 小时前
Web 开发基础:HTTP 与 REST
前端·网络协议·http
IT_陈寒8 小时前
Redis的KEYS命令把我搞崩溃了,改用SCAN才活过来
前端·人工智能·后端