Angular 与信号。你需要知道的一切。
2 月 15 日,Angular 团队提交了第一个 PR,将信号引入框架。这个早期的 PR 获得了巨大的推动力,并让 Angular 社区陷入了疯狂。每个人都在讨论信号,每个人都在发布关于信号的帖子,每个人都在用信号进行原型设计。
Angular 团队在推广和解释这个新的响应式原语方面做得非常出色。他们确保开发者能够获得所需的工具来掌握这个新奇的理念,这个理念将从根本上改变 Angular 应用未来的运作方式。在他们的官方Github 对话中,他们认真地回答问题并解决社区的担忧。在 Twitter 上,他们为公众提供了令人难以置信的信息。在直播中,他们与信号思想领袖进行了长达一小时的深入技术讨论。他们竭尽所能地分享他们对这个框架中令人惊叹的新成员的热情和知识。
到目前为止,我唯一缺少的就是一份汇集所有这些精彩信息的汇编:一份能够将所有可用资料整合成一个连贯故事的资源。一篇文章就能让我了解什么是信号,它们在 Angular 中如何运作,它们解决了框架的哪些问题,以及这一切对于我作为一名最终使用它们构建应用程序的 Angular 开发者来说意味着什么。
本文是我创建此类资源的尝试。
信号简介
那么,什么是信号?一个很好的起点是看看 Solid 是如何描述信号的。Solid 是一个相对较新且越来越流行的框架,它围绕信号构建。特别是,Angular 团队与其创始人兼 Signals 首席执行官 Ryan Carniato 密切合作,开发了 Angular 版本的信号:
信号是 Solid 中反应性的基石。它们包含随时间变化的值;当你改变信号的值时,它会自动更新所有使用它的内容。
这看起来相当直观。信号是对一个简单值的包装,它记录了依赖于该值的内容,并在该值发生变化时通知这些依赖者。
对于熟悉 RxJs 的人来说,经常用来描述它们的比较是 BehaviorSubjects,除了需要手动订阅/取消订阅。
信号总是有值的,信号没有副作用,并且信号是反应性的,使其依赖项保持同步。
所有这些共同构成了一个简单的模型,用于描述 Angular 应用程序中事物的变化。这在今天还不存在。让我们来解释一下我的意思。
这一切始于(大约)10年前
Angular 的信号之旅始于 10 年前。这听起来可能有点奇怪。我们讨论的是信号。启发 Angular 团队的框架 SolidJs (v1.0.0) 直到 2021 年 6 月 28 日才发布。Angular 的信号之旅怎么可能始于 10 年前呢?
好吧,这大概就是 Angular v1.0.0 发布的时候(2012 年 6 月 13 日),随之而来的是大量的设计选择。每种设计选择都有各自的权衡,并且通常会对开发者和用户体验产生重大影响。
这些设计决策是基于当时的技术和可用的信息做出的。经过十年的浏览器技术发展(直到 2015 年 ES6 最终确定后才有了箭头函数,2017 年 ES8 才添加了异步函数),以及数十万个在 Angular 上运行的实际应用程序,Angular 团队决定重新审视这些设计决策,看看它们的效果如何。正如技术发展(以及生活中的普遍现象)一样,有些决策比其他决策效果更好。
显然,最好的决策之一就是在 Typescript 之上构建 Angular。现在看来,这似乎是显而易见的选择,但在 2012 年却并非如此。当时 Typescript 才刚刚流行起来,许多前端开发人员之前从未考虑过类型安全,这似乎不必要地拖慢了开发速度。Angular 成为第一个基于 Typescript 构建的主流框架。如今,我们知道类型安全极大地提升了构建大规模应用程序的信心和速度。但我们先别跑题。
另一个决定是,视图状态不仅可以存活,还可以在应用程序的任何地方进行修改。无论它是组件中的简单布尔值,还是全局服务对象中深层嵌套的列表项,Angular 都会检测到这些变化并相应地更新 DOM。
这赋予了开发者极大的自由,让他们能够以自己认为合理的方式构建应用程序,并将更复杂的逻辑提取到服务中,而所有这些都只需付出极少的努力,也无需担心新数据如何在 DOM 中呈现。无论我们在哪里放置和更改状态,Angular 的自动全局变更检测功能都能识别出这些变化,并且 DOM 中的值会神奇地更新。
自动变化检测的挑战
ZoneJs 以及何时检查更改
不幸的是,现实情况并非如此简单。这些神奇的更新是有代价的。
为了实现自动变更检测,Angular 依赖于 ZoneJs。ZoneJs 是一个库,它会修补原生浏览器 API,并在发生重大事件时通知框架。这引出了我们的第一个权衡。在 Angular 应用程序中执行任何操作之前,ZoneJs 需要加载并运行,这意味着与采用不同方法同步模型更改和 DOM 的框架相比,Angular 在某种程度上存在内在的性能缺陷。
你可能还想知道我上面提到的那些重要事件是什么。这些事件包括事件监听器、setTimeouts、Promises 等等。事实证明,为了使任何地方的可变状态与 DOM 保持同步,几乎任何浏览器事件都是重要的。这意味着,即使我们根本不打算更新 DOM,Angular 通常也需要检查整个组件树,看看是否有任何数据绑定的值需要更新。
因此,一方面,Angular 倾向于过度检查,并进行不必要的工作来尝试检测模型的更改。另一方面,确定哪些绑定发生变化的算法也会带来重大影响。
单向数据流或 ExpressionChangedAfterItHasBeenCheckedError
我们了解到,有大量事件会触发 Angular 的变更检测机制。为了确保应用程序即使在 DOM 中有大量组件的情况下也能保持高性能,底层机制必须极其高效。
为了确保这一点,Angular 的变更检测按 DOM 顺序运行,并且只检查一次模型与 DOM 之间的每个绑定。同样,经过数年时间以及数百万行 Angular 代码的开发,我们清楚地认识到,这种检查变更的方式存在一些缺陷。
最重要的是,一旦父母的数据被删除,你就无法再更新它们了。
@Component({
selector: 'app-child',
standalone: true,
imports: [CommonModule],
template: `
<h1>Hello</h1>
`,
})
export class ChildComponent implements OnChanges {
@Input()
public changed = false;
private parent = inject(ParentComponent);
public ngOnChanges() {
if (this.changed) {
this.parent.text = 'from child';
}
}
}
@Component({
selector: 'my-app',
standalone: true,
imports: [ChildComponent],
template: `
{{text}}
<app-child [changed]='true'/>
`,
})
export class ParentComponent {
text = 'from parent';
}
这会导致著名的 ExpressionChangedAfterItHasBeenCheckedError:
ERROR
Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'from parent'. Current value: 'from child'. Find more at https://angular.io/errors/NG0100
在单个变化检测周期内,我们决定违背操作的自然流程,在 Angular 检查其值后更新父级。
你可能会说这个例子有点不自然,但有很多例子表明,应用程序的逻辑数据流并不符合 Angular 自上而下的变更检测特性。即使在 Angular 内部,我们也发现了同样的问题。
在框架的 FormsModule 代码中,父组件的有效性由子组件驱动。为了避免在更新父组件状态时出现 ExpressionChangedAfterItHasBeenCheckedError 错误,该操作必须用一个立即解析的 Promise 进行包装。这会安排一个微任务来更新父组件,完成后会触发另一个变更检测周期,最终按照允许的顺序检查组件树并更新 DOM。
我希望你仍然关注我。
当然,有一些技术,比如使用 Promise,可以解决变更检测当前的局限性。然而,这些技术看起来有点老套,更重要的是,需要深入了解 ZoneJs 中变更检测的工作原理。实际上,Angular 的全局自动变更检测并不总是“有效”。
OnPush、RxJs 以及为什么它不能解决我们所有的(钻石)问题
如果您不熟悉 OnPush 变化检测和/或想要复习一下 RxJs 的常用方法,请观看 Joshua Morony 制作的这个超级信息量很大的视频,其中涉及 OnPush 和 RxJs 以及它们对 Angular 开发的重要性。
OnPush 和异步管道。通过解决症状而不是解决根本原因来提高性能。
如上所述,Angular 多次触发变化检测,虽然这对于简单的应用程序来说不是问题,但一旦 DOM 中的组件和数据绑定数量增加,性能就会成为一个更重要的主题。
提高 Angular 应用程序性能的一种常见方法是使用 OnPush 变化检测策略并让 Angular 使用 AsyncPipe 处理订阅管理。
OnPush 变更检测策略会将标记为 OnPush 的组件及其所有子组件排除在默认变更检测机制之外。Angular University 有一篇很棒的文章,深入探讨了该机制。我强烈推荐大家阅读。
再次,OnPush 改变了 Angular 检测标记为 onPush 的组件及其所有子组件的变化的方式。
文档明确指出:
使用 CheckOnce 策略,这意味着自动变更检测将被停用,直到将策略设置为默认(CheckAlways)才能重新激活。变更检测仍然可以显式调用。此策略适用于所有子指令,并且无法被覆盖。
这项技术的影响非常巨大。如果你组件树中某个高级别的组件支持 OnPush,那么所有其他组件也必须支持 OnPush。这意味着 UI 库的作者必须构建兼容 OnPush 的库,因为如果你的组件不支持 OnPush 变更检测,你就无法保证 Angular 生态系统中的每个人都能使用你的组件。
RxJs. 功能强大、声明式、响应式的异步流编程
RxJS 是一个使用 Observables 进行反应式编程的库,可以更轻松地编写异步或基于回调的代码。
Mike Pearson 在这条推文中完美地描述了它的惊人威力:
RxJs 允许我们使用 Observable 对象构建声明式、响应式的管道,这些管道可以在代码中清晰地展示异步流程中将发生的一系列操作和变更。它避免了竞争条件,最终使你的代码更具可读性,更易于理解。
Angular 向我们许多人介绍了 RxJs。它应用于框架的许多部分。最著名的是 HttpClient,它通过 Observable 暴露 HTTP 调用的响应。此外,每个 Angular FormControl 都有一个名为 valueChanges 的属性。这是一个多播可观察对象,每当控件的值在 UI 中或以编程方式发生变化时,它都会发出一个事件。
结合 OnPush 和 Angular 的 AsyncPipe(一种将我们的 Observable 直接绑定到模板的实用程序),这些反应式管道已成为构建高性能、无竞争条件、声明式 Angular 应用程序的主要模式。
流并非行为。为什么 RxJs 不是解决方案?
然而,如果你仔细观察 Angular 如何使用 Observable,就会发现一个规律。Angular 使用 RxJs 来处理事件,更具体地说,是暴露事件流。这些事件流没有当前值。Alex Rickabaugh 在与 Ryan Carniato 的对话中,就此提出了一个很好的思考方式。
他具体谈论的是点击事件。
您不能问当前的点击事件是什么。这个问题毫无意义。您可能会问:最近的点击事件是什么?然而,这是一个根本不同的问题。行为(Angular 的信号也符合这种行为定义)始终具有值。您始终可以问:此行为(信号)的当前值是多少?这恰恰是 RxJs 流(Observables)作为 Angular 团队试图用其新的响应式原语解决的挑战的解决方案的最根本缺陷。
在同一次对话中,他们确实承认 RxJs 的 BehaviorSubject 是该库提供的最接近信号的东西。它始终具有一个值。它可以通知订阅者该值的变化,并且它公开了一种获取当前值和设置新值的方法。然而,它并没有很好地集成到 RxJs 中。一旦通过操作符(例如 map())将其传递,它就会变成一个 Observable,并失去它作为始终具有当前值的 BehaviorSubject 的联系。此外,RxJs 拥有大量强大的操作符,可用于映射、连接、防抖动等流。对于初学者来说,这种强大的功能带来了非常陡峭的学习曲线。Angular 团队需要一些更专注的东西来构建他们的响应式原语。
为您的(钻石)问题选择合适的工具
另外,让我们回顾一下团队最初的目标。他们希望引入一个响应式原语,以便与 Angular 的模板引擎集成,当绑定到视图的值发生变化并需要在 DOM 中更新时,通知框架。
这种原语的主要目标之一是无故障执行。无故障执行意味着绝不允许用户代码看到只有部分响应式元素更新的中间状态(运行响应式元素时,所有源都应该已更新)。
此定义归功于 Milo 那篇关于细粒度响应式性能的精彩文章。
再次强调,RxJs 提供的解决方案感觉很 hack。我们来看下面的例子:
@Component({
selector: 'normal',
standalone: true,
imports: [CommonModule],
template: `
<p>Hello from {{fullName$ | async}}!</p>
<p>{{fullNameCounter}}</p>
<button (click)="changeName()">Change Name</button>
`,
})
export class NormalComponent {
public firstName = new BehaviorSubject('Peter');
public lastName = new BehaviorSubject('Parker');
public fullNameCounter = 0;
public fullName$ = combineLatest([this.firstName, this.lastName]).pipe(
tap(() => {
this.fullNameCounter++;
}),
map(([firstName, lastName]) => `${firstName} ${lastName}`)
);
public changeName() {
this.firstName.next('Spider');
this.lastName.next('Man');
}
}
- 我们为
firstName
和声明两个 BehaviorSubjectslastName
。 - 我们将它们组合成一个可观察对象
fullName$
,简单地将两者连接起来。 - 我们声明一个
fullNameCounter
,每次我们的fullName$
Observable 发射时,我们都会递增它。 - 我们添加了一个
changeName
函数,可以同时触发设置firstName
和lastName
不同的值。
我们的组件最初显示以下内容
Hello from Peter Parker!
1
单击按钮后,UI 将更新为:
Hello from Spider Man!
3
这意味着我们的可观察对象实际上发射了两次。每次和fullName$
发生变化时发射一次。虽然它发生得太快以至于我们无法察觉,但这个组件实际上渲染了一个中间状态,然后立即被最终的正确状态所取代。firstName
lastName
debounceTime
为了避免这种情况并实现无故障执行的目标,我们需要在我们的中添加fullName$
以避免重复执行并最终实现无故障执行。
@Component({
selector: 'debounced',
standalone: true,
imports: [CommonModule],
template: `
<p>Hello from {{fullName$ | async}}!</p>
<p>{{fullNameCounter}}</p>
<button (click)="changeName()">Change Name</button>
`,
})
export class DebouncedComponent {
public firstName = new BehaviorSubject('Peter');
public lastName = new BehaviorSubject('Parker');
public fullNameCounter = 0;
public fullName$ = combineLatest([this.firstName, this.lastName]).pipe(
debounceTime(0),
tap(() => {
this.fullNameCounter++;
}),
map(([firstName, lastName]) => `${firstName} ${lastName}`)
);
public changeName() {
this.firstName.next('Debounced Spider');
this.lastName.next('Man');
}
}
现在我们看到计数器每次只增加一,这表明我们确实不会呈现中间状态。
Hello from Peter Parker!
1
单击按钮后,UI 将更新为:
Hello from Debounced Spider Man!
2
再次,我鼓励您查看工作示例。
一个重要的要点是,combineLatest
如果 Angular 决定将 s 设置为 observable,那么它所组合的 observable 每次发生变化时都会触发一次事件,这也同样适用@Input()
。虽然这是社区呼声最高的功能之一,但你会发现它带来了额外的复杂性。
那么,用信号原语实现同样的功能是什么样的呢?很高兴你问到这个问题。
@Component({
selector: 'my-app',
standalone: true,
template: `
<p>{{ fullName() }}</p>
<p>{{signalCounter}}</p>
<button (click)="changeName()">Increase</button>
`,
})
export class App {
firstName = signal('Peter');
lastName = signal('Parker');
signalCounter = 0;
fullName = computed(() => {
this.signalCounter++;
console.log('signal name change');
return `${this.firstName()} ${this.lastName()}`;
});
changeName() {
this.firstName.set('Signal Spider');
this.lastName.set('Man');
}
}
信号版本的代码不是更简单、更直接吗?
您是否注意到,即使您频繁点击 changeName 按钮,计数也永远不会超过 2?
Hello from Signal Spider Man!
2
我们稍后会更详细地介绍这一点,但信号仅在其值发生变化时才会通知消费者。为了在 RxJs 中获得相同的功能,我们必须在 Observable 中添加另一个操作符:distinctUnitlChanged
。
值得注意的是,这并不意味着 RxJs 的时代已经结束。它即将从 Angular 中消失,不再有用。RxJs 的亮点在于它允许开发者声明异步的、响应式的流。你可以监听输入的变化事件,对其值进行去抖动处理,将其切换为 HTTP 调用的参数,并将响应映射到视图中所需的准确模型,所有这些都可以在一个地方完成。而这些是信号无法实现的。
所有这些可以归结为一点:虽然 RxJs 和信号本质上都是响应式的,但它们解决的问题却不同。它们是互补的,而不是互相替代的。它们结合在一起,将使 Angular 应用程序更加强大、更加直观。
Mike Pearson 的这篇文章更详细地探讨了 RxJs 作为 Angular 响应式原语的缺点。它极大地帮助我理解了为什么 RxJs 和 Observable 输入并非 Angular 所需的解决方案。
新的反应原语:信号
现在我们了解了 Angular 团队发现的问题,并阐明了为什么 RxJs 不是解决这些挑战的方案,我们可以继续介绍今天文章的明星:信号。
让我们重新回顾一下本文开头快速介绍的 Solid 对信号的定义:
信号是 Solid 中反应性的基石。它们包含随时间变化的值;当你改变信号的值时,它会自动更新所有使用它的内容。
我们稍后会仔细研究信号是如何实现这一点的,但首先让我们关注一下 Angular 团队目前提供的 API。其中大部分内容直接复制自此处提供的 README 文件。这是一个非常棒的资源,绝对值得花时间阅读 1、2、3 遍:
Angular 信号是零参数函数(() => T
)。执行时,它们返回信号的当前值。执行信号不会触发副作用,但它可能会延迟重新计算中间值(延迟记忆)。
某些上下文(例如模板表达式)可以是响应式的。在此类上下文中,执行信号会返回其值,但也会将该信号注册为该上下文的依赖项。如果上下文的任何信号依赖项产生了新值(通常,这会导致重新执行这些表达式以使用这些新值),则上下文的所有者将收到通知。
注意:这正是信号作为变更检测和 DOM 同步机制如此强大的原因。受影响的模板会直接收到通知。无需遍历组件树并猜测何时重新检查所有内容!
这种上下文和 getter 函数机制允许自动隐式地跟踪上下文的信号依赖关系。用户无需声明依赖关系数组,特定上下文的依赖关系集也无需在执行过程中保持静态。
注意:将此combineLatest
与必须将每个可观察对象添加到依赖项数组中然后才能稍后访问它们的值进行比较。
可设置信号:signal()
signal() 函数会生成一种称为 SettableSignal 的特定信号类型。SettableSignals 除了作为 getter 函数之外,还提供了用于更改信号值(以及将更改通知给所有依赖者)的附加 API。这些 API 包括用于替换信号值的 .set 操作、用于获取新值的 .update 以及用于对当前值进行内部修改的 .mutate 操作。这些 API 都作为信号 getter 函数暴露出来。
const counter = signal(0);
counter.set(2);
counter.update(count => count + 1);
信号值也可以使用专用的 .mutate 方法进行就地更新:
const todoList = signal<Todo[]>([]);
todoList.mutate(list => {
list.push({title: 'One more task', completed: false});
});
注意:我们不需要信号的不变性来正确地通知其依赖者变化!!
平等
信号创建函数可以可选地指定一个相等比较器函数。该比较器用于判断新提供的值与当前信号的值是相同还是不同。
如果相等函数确定两个值相等,它将:
- 阻止信号值的更新;
- 跳过变更传播。
声明派生值:computed()
computed() 创建一个记忆信号,它根据一定数量的输入信号的值计算其值。
const counter = signal(0);
// Automatically updates when `counter` changes:
const isEven = computed(() => counter() % 2 === 0);
因为用于创建计算的计算函数是在反应式上下文中执行的,所以该计算读取的任何信号都将被跟踪为依赖项,并且只要任何依赖项发生变化,就会重新计算计算信号的值。
与信号类似,计算可以(可选)指定相等比较器函数。
副作用:effect()
effect() 在响应式上下文中调度并运行一个具有副作用的函数。该函数的信号依赖关系会被捕获,并且每当其任何依赖项产生新值时,副作用都会被重新执行。
const counter = signal(0);
effect(() => console.log('The counter is:', counter()));
// The counter is: 0
counter.set(1);
// The counter is: 1
效果器并非与集合同步执行(请参阅下文关于无故障执行的部分),而是由框架调度和解析。效果器的具体执行时间尚未明确。
注意:乍一听可能有点吓人。作为开发者,未指定的行为总是令人头疼。然而,在这种情况下,这赋予了 Angular 自由决定何时执行某个效果的自由,例如,它可以利用这一点来优化性能。
非反应式: untracked()
注意:这目前不是官方 README 的一部分。我添加它是为了给你一个完整的 API 概述。
这会阻止包装计算跟踪任何未跟踪信号的读取。这意味着即使信号发生变化,上下文也不会收到其变化的通知。
const counter0 = signal(0);
const counter1 = signal(0);
// Executes when `counter0` changes, not when `counter1` changes:
effect(() => console.log(counter0(), untracked(counter1));
counter0.set(1);
// logs 1 0
counter1.set(1);
// does not log
counter1.set(2);
// does not log
counter1.set(3);
// does not log
counter0.set(2);
// logs 2 3
但是,无论何时执行上下文,它都会使用未跟踪信号的最新值。
这是 API 的当前版本。再次强调,我建议您花时间阅读Github 上的README 文件。它绝对非常棒,对每个人来说都是非常宝贵的资源。
那么信号是如何工作的呢?
再次,我将主要引用 README 中的精彩解释。
生产者和消费者
信号内部依赖于两个抽象,Producer
和Consumer
。它们是由反应系统的各个部分实现的接口。
Producer
表示可以传递变化通知的值,例如各种类型的信号。Consumer
表示可能依赖于一定数量的生产者的反应性环境。
换句话说,生产者生产反应性,而消费者消费它。
有些概念既是生产者,又是消费者。例如,派生computed
表达式会消费其他信号来产生新的反应值。
Producer
和都Consumer
跟踪Edge
彼此的依赖关系。Producer
s 知道哪些Consumer
s 依赖于它们的值,而Consumer
s 知道Producer
它们所依赖的所有 s。这些引用始终是双向的。
它们共同构建了一个依赖图,清晰地展示了不同节点之间的关系。
变更如何通过依赖关系图传播
我们已经看到了 RxJs Observables 如何努力实现无故障执行。我们也看到了信号优雅地解决了这一挑战。
让我们来探究一下他们是如何做到的:
推/拉算法
Producer
Angular Signals 通过将对/Consumer
图的更新分为两个阶段来保证无故障执行:
第一阶段在 Producer 值发生更改时立即执行。此更改通知会在整个图中传播,并通知Consumer
依赖于 的 s 节点Producer
潜在的更新。
至关重要的是,在第一阶段,不会运行任何副作用,也不会重新计算中间值或派生值,只会使缓存值无效。
一旦此变更传播(同步)完成,第二阶段即可开始。在此第二阶段中,应用程序或框架可以读取信号值,从而触发对任何先前无效的所需派生值的重新计算。
我们将其称为“推/拉”算法:当源信号发生变化时,“脏污”会被急切地推送到图表中,但重新计算则会延迟执行,只有当通过读取信号来提取值时才会执行。
值版本控制
这可能是信号最复杂的部分。首先,我们来看一下 Angular 团队提供的解释。然后,我们来看一个例子。
Producer
s 跟踪一个单调递增的 valueVersion,表示其值的语义标识。当 生成一个语义上新的值时,valueVersion 会递增。当读取时,Producer
当前的 valueVersion 会保存到依赖结构中。Edge
Consumer
Producer
在消费者触发其响应式操作(例如,effects 的副作用函数,或 computeds 的重新计算)之前,它们会轮询其依赖项,并在需要时请求刷新 valueVersion。对于 computed 来说,如果值已过时,这将触发值的重新计算以及随后的相等性检查(这使得此轮询成为一个递归过程,因为 computed 也是一个 ,它Consumer
会轮询其自身的Producer
s)。如果此重新计算产生了语义上发生变化的值,则 valueVersion 会递增。
然后,可以Consumer
将新值的 valueVersion 与其依赖项 Edge 中缓存的 valueVersion 进行比较,以确定该依赖项是否确实发生了变化。通过对所有Producer
s 执行此操作,Consumer
如果所有 valueVersion 都匹配,则可以确定任何依赖项均未发生实际更改,并且可以跳过对该更改的响应(例如,跳过运行副作用函数)。
让我们看一个例子来更好地理解发生了什么。
假设我们有以下代码:
const counter = signal(0);
const isEven = computed(() => counter() % 2 === 0);
effect(() => console.log(isEven() ? 'even!' : 'odd!');
counter.set(1);
// logs odd!
counter.set(2);
// this is the change we are going to look at
事情是这样的:
- 对 SettableSignal 的改变启动了我们的推/拉算法。
- 我们
Producer
counter
降低它的肮脏程度并通知它的消费者isEven
它的价值已经过时了。 isEven
也是一个Producer
,这意味着它也会通知它的Consumer
s。在本例中是我们的console.logeffect
。这样就完成了推送阶段。effect
现在轮询的isEven
当前值。5.isEven
再次轮询的最新版本counter
。6.counter
已更新其值和valueVersion,通知isEven
其新状态。isEven
重新计算自己的值,确定其值已更改,并增加其值版本- 最后,
effect
识别新的版本值。拉取变为 true 的新值,使用新值执行,并将“even!”打印到控制台。
那么让我们看看当我们将计数器值设置为 4 时会发生什么。
counter.set(4);
事情是这样的:
- 再次,将 SettableSignal 更改为 4 会启动我们的推/拉算法。
- 我们
Producer
counter
降低它的肮脏程度并通知它的消费者isEven
它的价值已经过时了。 isEven
也是一个Producer
,这意味着它也会通知它的Consumer
s。在本例中是我们的console.logeffect
。这样就完成了推送阶段。effect
现在轮询的isEven
当前值。isEven
再次投票选出最新版本的counter
。counter
已将其值更新为 4,并将其 valueVersion 更新为 3,isEven
以通知其新状态。isEven
重新计算自身的值,确定其值实际上没有改变。因此,它保持其值版本不变。- 最后,
effect
确认isEven
的 valueVersion 没有改变。因此无需执行。
通过将脏东西急切地推入依赖图并懒惰地拉取Consumer
依赖于信号的值来实现我们想要的结果:无故障执行。
类似的机制也用于跟踪生产者的依赖项是否超出范围并被垃圾回收。
这意味着您永远不必担心取消信号订阅。这种依赖项跟踪方式完全可以避免内存泄漏!
细粒度反应性=细粒度变化检测=性能爆炸
凭借其自身的反应原语,Angular 具有一流的集成度,并且可以利用这种新的反应方式为变化检测带来的所有好处。
我认为将模板渲染为effect
。几乎是这样的:
const counter = signal(0);
const doubleCounter = computed(() => counter() * 2);
effect(() => renderTemplate(`
<div>My counter is: ${counter()}</div>
<div>My double counter is ${doubleCounter()}</div>
`));
我们用 effect 来渲染模板,effect 会接收模板中用到的每个信号。这些信号会在发生变化时通知我们,这样我们就能准确地知道何时重新渲染模板。
当我们比较当前的变化检测机制和新的信号启用机制时,我们就能更好地理解细粒度反应性和信号在保持 DOM 和模型同步方面有多么高效。
Angular 目前如何检测变更
假设我们有一个包含多个组件的应用程序。其中一些组件的模型在逻辑上相互依赖,而另一些则没有。它们的视图都通过 DOM 树中的父子关系连接起来。
Angular 通过其自上而下的脏检查变更检测算法创建了一棵类似的树。无论组件之间的数据如何依赖,框架都会在不同组件之间创建父/子关系。
然后,在每个变更检测周期中,它会遍历树一次,并将绑定的旧值与模型的新值进行比较。每个绑定只会被检查一次。
我们看到组件树内部的一个模型发生了变化,用橙色圆圈表示。该模型本身对任何其他组件没有逻辑依赖。当我们进入新的变更检测周期时,会发生以下情况:
- Angular 从树的顶部开始检查该节点
- 然后它继续沿着树走,以确定哪些组件需要更新
- 最后,它到达模型发生变化的组件。
- 相等性检查失败,DOM 被更新。
每次触发变化检测时都会发生这些步骤。
让我们看看基于信号的变化检测是如何工作的。
信号驱动变化检测
我们有同一个应用程序,其中包含多个组件。有些模型在逻辑上相互依赖,而有些则不依赖。这些模型的视图都通过 DOM 树中的父子关系连接在一起。
有了信号,就无需创建支持变更检测的树了。模板中使用的信号会将其变更通知给它。这就是为什么在这个抽象中,变更检测箭头直接连接到 DOM 节点。
那么当模型改变时会发生什么?
模板会收到此更改的通知并且 DOM 会更新。
就是这样。
令人震惊
不再需要自上而下地遍历图表。
不再进行不必要的比较。
通知框架何时更新视图的通知机制内置于信号中。
我们只能想象这种细粒度的反应性所带来的性能改进。
信号 + RxJs = <3
我希望此时你和我一样对信号感到兴奋!
在本文的最后,我想重申一下信号和 RxJs 是相辅相成的。
许多使用 OnPush、RxJs 和异步管道构建的应用程序已经设置为充分利用信号带来的性能提升。异步管道将被信号取代。OnPush 将完全消失,因为每当需要更新 DOM 时,信号都会通知框架。RxJs 将专注于其最擅长的领域:以声明式的方式建模复杂的异步流。
它们将共同为未来的 Angular 应用程序提供动力。
预备。开始。
这确实是 Angular 社区激动人心的时刻。我百分之百确信信号将显著提升 Angular 应用的开发者和用户体验。它们提供了一个简单的模型来更新 Angular 的视图。它们性能卓越、响应迅速,很快就会成为 Angular 不可或缺的一部分。
我希望您现在已经掌握了足够的知识,以便在今年晚些时候信号到来时充分利用它们。
和往常一样,您对博客文章还有其他问题或建议吗?您对信号感兴趣吗?还是您仍然认为团队需要解决一些问题?我很想听听您的想法。请随时发表评论或给我留言。
最后,如果你喜欢这篇文章,欢迎点赞并与他人分享。如果你喜欢我的内容,请在Twitter或Github上关注我。
文章来源:https://dev.to/this-is-angular/angular-signals-everything-you-need-to-know-2b7g