Angular 每半年推出一个大版本,以保持框架的持续更新。然而,自从 2020 年初 Angular 9 推出重大更新并将默认编译和渲染引擎从 View Engine 切换到 Ivy 后,Angular 的迭代似乎一直处于相当消极的状态。更新内容主要集中在跟进 TypeScript 和 RxJS 版本、小特性和性能优化方面,而一些陈年问题仍未解决。
有人认为这是 Angular 稳定成熟的表现,也有人认为这是 Google 放弃 Angular 的前兆。在社区内充斥着各种消极情绪时,Angular 团队在一年时间内接连提出了 Standalone APIs RFC 和 Signals RFC 这两个提案,推出了全新的应用组织方式和与过去完全不同的基于 Signal 的响应性和数据流方案。Angular 似乎正在步入一个新的纪元。
Signals RFC 分为主 RFC 和四个子 RFC:
本文将概述主 RFC 和所有子 RFC 的内容,不包含笔者个人观点。需要注意的是,这些只是该 RFC 当前阶段( 2023/04/12 )的内容,RFC 可能随时更新,最终的实现可能有较大出入。
Signals RFC 是近年来 Angular 团队提出的规模最大,也最具颠覆性的提案,其目标是解决数个在社区中留存数年的问题,如基于 Zone.js 的变更检测的性能问题,基于 RxJS 的状态派生较为繁琐,无法便捷地对特定 Input 的变化作出响应等。
Angular 团队根据过去几年的社区反馈制定了以下目标:
ExpressionChangedAfterItHasBeenChecked
错误而 Signals RFC 则被视为是实现以上目标的基石。
Signal 指的是具有显式变化语义的值。在 Angular 中,Signal 是由调用后立即返回当前值的零参数的 Getter 函数表示的:
interface Signal<T> {
(): T;
[SIGNAL]: unknown;
}
该 Getter 函数具有一个 [SIGNAL]
属性,框架可以用该属性辨识 signals 以用于内部优化。
Signal 本质上应当是只读的,只用于获取当前的值以及对值的变更做出响应。
Getter 函数用于访问当前值,同时将本次 Signal 的读取操作记录在一个 Reactive Context 中,用于构建响应式依赖关系图。
在 Reactive Context 外的 Signal 读取也是被允许的。这意味者非响应式的代码,如第三方库的代码,也可以读取 Signal 的值,而无需注意其响应式的本质。
Angular 的 Signal 库提供一个默认的 Writable Signal 的实现,用于通过其内置的方法变更 Signal 的值:
interface WritableSignal<T> extends Signal<T> {
/**
* 将 Signal 直接设置到一个新的值。
*/
set(value: T): void;
/**
* 基于 Signal 的当前值以对值进行更新。
*/
update(updateFn: (value: T) => T): void;
/**
* 通过对 Signal 的当前值进行直接的变化来更新 Signal 的当前值(不改变对象)。
*/
mutate(mutatorFn: (value: T) => void): void;
/**
* 返回一个非 Writable 的 Signal ,该 Signal 访问当前 WritableSignal 但不允许对值进行变更。
*/
asReadonly(): Signal<T>;
}
通过 signal
函数可以创建 WritableSignal
的实例:
function signal<T>(
initialValue: T,
options?: {equal?: (a: T, b: T) => boolean}
): WritableSignal<T>;
用法示例:
const counter = signal(0);
counter.set(5);
counter.update(currentValue => currentValue + 1);
可以选择性的为 WritableSignal
指定一个 Equality 比较函数,如果该 Equality 函数确定两个值是相等的,那么:
默认的 Equality 函数使用 ===
来比较基本类型值,但比较对象和数组将会永远返回不等。这允许 Signal 持有非基本类型的值的同时,仍然可以传播其值的变更,例如:
const todos = signal<Todo[]>([{todo: 'Open RFC', done: true}]);
// 不使用不可变数据时,我们仍然可以更新该列表并触发值的变更。
todos.mutate(todosList => {
todosList.push({todo: 'Respond to RFC comments', done: false});
});
Signal 概念的其他实现是可能的:只要实现相应的接口,Angular 或任何第三方库都可以创建自定义的 Signal 版本。
在 Angular 选择的这一实现中,Signal 是由一个 Getter 函数表示的。使用这一 API 的优势有:
然而,Getter 函数也有诸多弊病:
在模板中的函数调用
多年来,Angular 开发者已经学会了对在模板中调用函数保持警惕。其出现的原因是组件的变更检测运行频繁,而函数则很有可能隐藏具有高计算成本的逻辑。
这些担忧并不适用于 Signal 的 Getter 函数。Getter 函数是相当高效的访问器,计算成本极低,因此反复频繁地调用 Signal 的 Getter 函数并不是一个问题。
然而,在 Signal 读取中使用函数调用仍然可能在会在刚开始时让那些习惯于在模板中避免函数调用的开发者感到困惑。
与类型缩窄的交互
TypeScript 可以在条件语句中缩窄一个表达式的类型。即使 user.name
可以被赋值为 null
,以下代码依然可以通过类型检测,这正是因为 TypeScript 知道在 if
的主体中, user.name
不可能是 null
:
if (user.name) {
console.log(user.name.first);
}
然而,TypeScript 并不会缩窄函数返回值的类型,因为其不能保证每个函数都像 Signal 函数这样每次都会返回相同的值。因此,上面的示例并不适用于 Signal:
if (user.name()) {
console.log(user.name().first); // 类型错误
}
对于这个简单的示例,我们可以很简单地将 user.name()
提取为一个在 if
语句外的常量:
const name = user.name();
if (name) {
console.log(name.first);
}
然而在模板中,由于无法声明中间变量,这种方式将不在奏效。目前只能依赖一些 Workaround 来解决该问题。
替代语法
Angular 团队考虑了多种 Getter 函数的替代语法,包括 Vue 的 .value
语法,React 的 [value, setValue]
语法,Vue 的 Proxy
响应性,和 Marko/Svelte 的编译时响应性。详见 RFC 原文。
Computed Signal 是从一个或多个依赖值派生出的新的值,会在其依赖的值变更时更新。Computed Signal 也可以依赖其他 Computed Signals 。
示例:
const counter = signal(0);
const isEven = computed(() => counter() % 2 === 0);
const color = computed(() => isEven() ? 'red' : 'blue');
computed
函数的签名是:
function computed<T>(
computation: () => T,
options?: {equal?: (a: T, b: T) => boolean}
): Signal<T>;
computed
函数的参数中,名为 compuatation
的函数不应该有任何副作用 —— 对任何 Signal 的变更都应当被避免。Angular 的 Signal 库将会检测在 computation
函数中对其他 Signal 的变更并抛出异常。
与 Writable Signal 类似,Computed Signal 同样可以可选地指定一个 Equality 函数,用于在两个值被确定为相等时阻止更深层的依赖链的重新计算。
示例(默认的 Equality 函数):
const counter = signal(0);
const isEven = computed(() => counter() % 2 === 0);
const color = computed(() => isEven() ? 'red' : 'blue');
// 当将 counter 设置到一个不同的奇数值时:
// - isEven 将必定重新计算(其依赖的值变更了)
// - color 无需重新计算( isEven 的值没有变化)
counter.set(2);
Angular 选择的用于实现 Computed Signal 的算法对计算的时机和正确性具有强有力的保证:
computation
函数不会被调用computation
函数被调用的次数是最少的。computation
函数永远不会为过时的值或是中间值执行,并且对知名的 "Diamond Dependency Problem" 免疫。防抖计算无需任何显式的“事务”或“批量”操作。Computed Signal 中的条件分支
Computed Signal 会跟踪在 computation
函数中读取了哪些 Signal ,以便知道何时需要重新计算。这个依赖集是动态的,并且会随着每次计算自我调整。因此,在该条件计算中:
const greeting = computed(() => showName() ? `Hello, ${name()}!` : 'Hello!');
如果 showName
的值改变了, greeting
总是会重新计算,但如果 showName
的值为 false
,那么 name
就不会是 greeting
的依赖,其值的变化也就不会导致 greeting
重新计算。
Effect 是具有副作用的操作。每当其中读取的任何一个 Signal 的值更改时,Effect 会自动安排重新运行。
声明 Effect 的基本 API 签名如下:
function effect(
effectFn: (onCleanup: (fn: () => void) => void) => void,
options?: CreateEffectOptions
): EffectRef;
用法示例:
const firstName = signal('John');
const lastName = signal('Doe');
// 这个 Effect 会向控制台输出 firstName 和 lastName ,并且每当他们的值变更时都会重新向控制台输出。
effect(() => console.log(firstName(), lastName()));
Effect 具有多种用途,包括:
Effect 函数可以选择性地注册一个清理函数。清理函数会在下一次运行改 Effect 前被执行,可以用于“取消”上一次运行 Effect 时遗留的工作。例如:
effect((onCleanup) => {
const countValue = this.count();
let secsFromChange = 0;
const id = setInterval(() => {
console.log(
`${countValue} 在 ${++secsFromChange} 秒内没有变化`
);
}, 1000);
onCleanup(() => {
console.log('清理并重新安排 Effect');
clearInterval(id);
});
});
Effect 的运行时机
在 Angular Signal 库中,Effect 总会在更改 Signal 的操作完成后运行。
鉴于 Effect 用途的多样性,可能存在各种不同的运行时机。这就是实际的 Effect 运行时机不能被保证,并且 Angular 可能会选择不同策略的原因。应用程序开发人员不应依赖于任何观察到的执行时间。唯一可以保证的是:
终止 Effect
每当一个 Effect 的依赖项的值变更,该 Effect 都会被安排运行。从这个角度上看,Effect 似乎是“永生”的,其永远准备着对 Reactive Graph 中发生的变化做出响应。这样的无限寿命显然不是我们想要的,因为 Effect 应当在应用结束或是其他 Lifespan Scope 结束时被终止。
Angular Effect 的默认寿命是与框架中的 DestroyRef
相关联的。也就是说,Effect 会尝试注入当前的 DestroyRef
实例,然后在其中注册其终止函数。
对于需要更详细的寿命控制的情景,可以在创建 Effect 时启用 manualCleanup
选项:
effect(() => {...}, {manualCleanup?: boolean});
当该选项启用时,即使创建该 Effect 的组件或是指令已被销毁,该 Effect 也不会被终止。
可以使用创建 Effect 时返回的 EffectRef
实例手动终止该 Effect:
const effectRef = effect(() => {...});
effectRef.destroy();
在 Effect 中更改 Signal 的值
Angular 团队普遍认为在 Effect 中更改 Signal 的值可能会导致预料外的表现(如无限循环)和难以跟踪的数据流。因此,任何尝试从 Effect 中更改 Signal 的值的操作都会向控制台输出一个错误并被阻止。
可以通过在创建 Effect 时启用 allowSignalWrites
选项来覆写该默认行为,如:
const counter = signal(0);
const isBig = signal(false);
effect(() => {
if (counter() > 5) {
isBig.set(true);
} else {
isBig.set(false);
}
}, {allowSignalWrites: true});
注意,一般来说使用 Computed Signal 是一种更声明式,更直观,且更可预测的同步数据的方式:
const counter = signal(0);
const isBig = computed(() => counter() > 5);
剩下一大半因长度限制搬不过来,见知乎原文: https://www.zhihu.com/question/595093745/answer/2981152934
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.