Angular 新纪元: Signals RFC

2023-04-28 04:16:30 +08:00
 Char2s

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

Signals RFC 是近年来 Angular 团队提出的规模最大,也最具颠覆性的提案,其目标是解决数个在社区中留存数年的问题,如基于 Zone.js 的变更检测的性能问题,基于 RxJS 的状态派生较为繁琐,无法便捷地对特定 Input 的变化作出响应等。

Angular 团队根据过去几年的社区反馈制定了以下目标:

而 Signals RFC 则被视为是实现以上目标的基石。

Signal APIs

Signal 指的是具有显式变化语义的值。在 Angular 中,Signal 是由调用后立即返回当前值的零参数的 Getter 函数表示的:

interface Signal<T> {
  (): T;
  [SIGNAL]: unknown;
}

该 Getter 函数具有一个 [SIGNAL] 属性,框架可以用该属性辨识 signals 以用于内部优化。

Signal 本质上应当是只读的,只用于获取当前的值以及对值的变更做出响应。

Getter 函数用于访问当前值,同时将本次 Signal 的读取操作记录在一个 Reactive Context 中,用于构建响应式依赖关系图。

在 Reactive Context 外的 Signal 读取也是被允许的。这意味者非响应式的代码,如第三方库的代码,也可以读取 Signal 的值,而无需注意其响应式的本质。

Writable Signals

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);

Equality

可以选择性的为 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 版本。

Getter 函数

在 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 Signals

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 的算法对计算的时机和正确性具有强有力的保证:

Computed Signal 中的条件分支

Computed Signal 会跟踪在 computation 函数中读取了哪些 Signal ,以便知道何时需要重新计算。这个依赖集是动态的,并且会随着每次计算自我调整。因此,在该条件计算中:

const greeting = computed(() => showName() ? `Hello, ${name()}!` : 'Hello!');

如果 showName 的值改变了, greeting 总是会重新计算,但如果 showName 的值为 false,那么 name 就不会是 greeting 的依赖,其值的变化也就不会导致 greeting 重新计算。

Effect

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);

基于 Signal 的组件

剩下一大半因长度限制搬不过来,见知乎原文: https://www.zhihu.com/question/595093745/answer/2981152934

3499 次点击
所在节点    Angular
8 条回复
putaozhenhaochi
2023-04-28 05:45:44 +08:00
👏👏👏
joesonw
2023-04-28 07:03:04 +08:00
看着很“眼熟”呀。😄
effect 这个感觉没 useEffect 设计的好。这个 allowSignalWrites 不如把需要修改的 signal 和 react 一样做为 dependency 传进去,在这里就是没传进去的不让修改就好了。
yunyuyuan
2023-04-28 09:33:24 +08:00
啊这,看来 react hook 确实是最优解,vue 搬了,现在 angular 也搬了。
个人感觉这个特性应该很难推广,用 hook 心智负担重,@Inject 配合 rxjs 已经能覆盖所有需求了
WispZhan
2023-04-28 09:42:13 +08:00
@yunyuyuan +1
hook 一时爽,……后半句请大家补充。

---

有依赖注入还是爽不少。
lupkcd
2023-04-28 10:32:56 +08:00
这跟 hook 有啥关系,楼上一大堆拿 hook 说事,应该是 vue 化了
chnwillliu
2023-04-28 19:47:49 +08:00
@joesonw 这跟 React 的 useEffect 不一样,你不如说和 Vue 的 watch 相似,数据有更新了做一些别的事而已。 要说对比那还得拿 solidJS 里的 Signal 对比。
chnwillliu
2023-04-28 20:02:40 +08:00
@yunyuyuan 另外 preact 也引入了 signals 哈哈哈,因为 signals 可以做组件内的细粒度更新而不用考虑 hooks 随组件函数每次重复执行带来的心智负担,和闭包陷阱说再见。

技术永远在更新,但永远不可能存在银弹。
yunyuyuan
2023-04-29 08:23:20 +08:00
@lupkcd #5 就是 react hook 的逻辑,除了 computed 学的 vue

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/936085

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX