Angular 17+ 高级教程 – Signals
  8CgHZpOr8DYB 2024年02月29日 38 0

前言

在上一篇 Change Detection 中, 我们有提到 MVVM 监听 ViewModel 变化的难题.

当年 AngularJS 和 Knockout.js (下面简称 KO) 各自选了不同的道路.

但如今, 事过境迁, Angular 最终也走向了 KO 的道路. 这就是这篇的主角 Signal。

 

把 variables 变成 function 

在 JavaScript, 值类型 variable 无法被监听, Signal 的做法是把它们都变成函数。

看看 KO 的代码

const count = ko.observable('default value'); // 通过 observable 方法 delcare variable
const value = count(); // count 是一个 getter 方法
count('new value'); // count 也是一个 setter 方法

变成函数后,我们就可以把监听代码写到 setter 方法中。

虽然 KO 已经退出前端舞台多年,但这个 Signal 概念依然沿用至今,许多库/框架都可以看见。

缺点

Signal 最大的缺点是代码的读写。这也是为什么 Angular 一直坚守 Zone.js + tick

原本用 1 个 variable + variable assign operator 来描述的代码。

变成了 2 个 variable methods, read and assign 都变成了 method call。

// before
const value = 0; // declare variable
const value2 = value; // passing variable
value = 1; // assign value to variable
value++ // other assign operator

// after
const [getValue, setValue] = declare(0);
const value2 = getValue();
setValue(1);
setValue(getValue()++);

这种写法在其它语言都很少见,或者一看就感觉是为了性能优化特地改的写法。 

总之严重影响 DX。

与众不同的 Svelte 5

Svelte 5 的 Signal 应该是所有 framework 里 DX 最好的 (比 Angular 好很多)。

只需要在最源头做 declaration 就可以了,count 不会变成恶心的 getter 和 setter,它依然是 variable 的使用方式,但它却有了 Signal 的功能。

显然 Svelte 又在 compile 阶段加了很多黑魔法让其工作,但我觉得符合直觉也是很重要的,getter 和 setter 明显就是种妥协。

Angular 也爱搞黑魔法,也有 compiler,为什么不像 Svelte 那样呢?

答案在:Sub-RFC 2: Signal APIs

这段指出,Svelte 的黑魔法无法实现统一语法,在跨组件共享 Signals 的时候写法需要不一致,我不确定这是不是真的。

Angular 认为既然无法做到所有代码统一干净,那就干脆做到所有代码统一不干净吧,至少能统一嘛。这听上去就不太合理,我看他们就是想偷工😒。

  

Why Angular need Signal?

Zone.js + tick 是 DX 最好的。Signal 没有的比。

所以 Signal 比的是性能优化版本的 Zone.js + OnPush + markForCheck 或者 Zone.js + Rxjs Stream + AsyncPipe 方案。

  1. 比 DX,两者都不太好,可能 Zone.js 稍微好一点点。

  2. 比性能,两者都可以,但 Signal 还可以做到更极致(虽然目前可能没有这个需求)

  3. 比心智,Zone.js 特立独行,手法间接,复杂不直观。相反 Signal 已经相当普及,手法也简单直接。

总结:Signal 明显利大于弊,但 Angular 一定会保留 Zone.js 很长很长一段事件。因为 Zone.js + tick 真的是最好的 DX。

 

Signal

目前 v17.0,Signal 还不完整,只有部分功能可用。

Signal 可以单独使用, 不需要搭配任何 Angular 的东西(比如组件、依赖注入都不需要)

但是偌想用 Signal 取代 Zone.js + tick,那就需要结合 Signal-based Components,而这部分还没有开放测试。

所以这里只能先介绍部分功能。

declare, assign, set, update

const value = signal(0);
// 相等于
// let value = 0;

通过调用 signal 函数来 declare 一个 variable。0 是初始值。

返回的 value 是一个 signal  函数(同时它也是一个对象)

const value = signal(0);
console.log(value());
// 相等于
// console.log(value);

读取 value 的方式是函数调用。这个对初学者是很不习惯的,一不小心忘了放括弧,可能就出 bug 了。

const value = signal(0);
value.set(5);
// 相等于
// value = 5;

赋值需要通过 .set 方法。

const value = signal(0);
value.update(curr => curr + 5);
// 相等于
// value += 5;

update 和 set 都是用来修改 value 的, 区别是 update 带有一个 current value 的参数,方便我们做累加之类的操作。

set 和 update 都需要返回一个新的 value,而且这个 value 最好是 immuable 的。

const obj = { name: 'Derrick', age: 11 };
const objSignal = signal(obj);
obj.age = 20;
objSignal.set(obj);

如果返回的 value 是相同引用,Signal 将认为 value 没有改变,不会触发任何 trigger。

在创建 Signal 的时候,我们可以自定义它判断相同值的方式。

const objSignal = Signal(obj, { equal: (a, b) => a === b });

computed

const firstName = signal('Derrick');
const lastName = signal('Yam');
const fullName = computed(() => firstName() + ' ' + lastName());
console.log(fullName()); // Derrick Yam
firstName.set('Richard');
console.log(fullName()); // Richard Yam

如果一个 variable 依赖其它 variable,可以用 computed 来维护这条关系连。

类似于 RxJS 的 combineLatest

注:computed 是在 fullName() 的时候去获取所有依赖的当前值,而不是像 RxJS combineLatest 那样搞缓存。

effect

export class AppComponent {
  constructor() {
    const firstName = signal('Derrick');
    effect(() => {
      console.log(firstName());
    });
    firstName.set('Stefanie');
  }
}

effect 比较特殊,它有点类似于 RxJS 的 combineLatest + subscribe。

当 effect 内任何一个 Signal value 变化时,整个 effect 会被触发(包括第一次 assign value)

另外 effect 的触发时机不是同步的, 它是 async micro task

所以像上面的例子,console 只会触发一次,value 是 Stefanie。

注: effect 只能用于 Injection Context

调用 effect 会返回 effectRef。

const value = signal(0);
const effectRef = effect(() => console.log(value()));
effectRef.destroy();

它让我们可以停止监听。类似于 RxJS 的 unsubscribe。

effect 内部也有 on dispose 功能。

const effectRef = effect((onCleanup) => {
  console.log(value());
onCleanup(()
=> { console.log('effect destroyed'); }); }); effectRef.destroy();

effect 内所有 Signal getter 都会被依赖追踪,如果我们想 skip 掉一些,可以这样声明

effect(() => {
  console.log(value1());
  untracked(() => {
    console.log(value2());
  });
});

当 value2 改变时,effect 不会触发,因为我们 untracked 了。

 

Signal & RxJS

上一篇的结尾,我们有提到,Signal 是用来取代 RxJS 的。

强调:只是取代在 Change Detection 范围下的 RxJS。而不是整个项目的 RxJS 哦。所以只是很小的一个范围而已。

另外,取代 RxJS 是因为它的 DX 和 LX (learning experience) 不够好,而不是它功能不够好。

正是因为这些原因,Signal 和 RxJS 长的挺像的,而且它们好像本来就师出同门哦。

  1. signal 像 BehaviorSubject

  2. computed 像 combineLatest

  3. effect 像 combineLatest + subscribe

强调:只是像而已。

switch between each other

既然长得像,那就可以转换咯。

toObservable

import { toObservable, toSignal } from '@angular/core/rxjs-interop';

const value = signal(0);
const obs = toObservable(value);
obs.subscribe(v => console.log(v)); // 5
value.set(5);

toSignal

constructor() {
  const firstName = new BehaviorSubject('Derrick');
  const lastName = signal('Yam');
  const firstNameSignal = toSignal(firstName);
  const fullName = computed(() => firstNameSignal() + ' ' + lastName());
  firstName.next('Richard');
  console.log(fullName()); // Richard Yam
}

注:toSignal 只能用于 Injection Context

 

Signal in Component

Angular v17 Signal-based Components 还在孕育中。但我们已经可以在组件里使用部分 Signal 功能了。

app.component.ts

export class AppComponent {
  firstName = signal('Derrick');
  lastName = signal('Yam');
  fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
}

app.component.html

<p>{{ fullName() }}</p>
<button (click)="firstName.set('Alex')">update first name</button>
<button (click)="lastName.set('Lee')">update last name</button>

效果

Signal and refreshView

Angular 文档有提到,Signal 是可以搭配 ChangeDetectionStrategy.OnPush 使用的。

但是有一点我要 highlight,当 Signal 值改变的时候,当前的 LView 并不会被 markForCheck。

Angular 用了另一套机制来处理 Signal 和 refresh LView 的关系。

逛一逛 Signal 和 refresh LView 的源码

如果你对 Angular TView,LView,bootstrapApplication 过程不熟悉的话,请先看 Change Detection 文章。

场景:

有一个组件,ChangeDetectionStrategy.OnPush,它有一个 Signal 属性,binding 到 Template。

组件内跑一个 setTimeout 后修改 Signal 的值,但不做 markForCheck,结果 DOM 依然被更新了。

提问:

1. Signal 值改变,Angular 怎么得知?

2. Angular 是怎样更新 DOM 的?使用 tick、detechChanges 还是 refreshView?

回答:

首先,不要误会,Angular 并没有暗地里替我们 markForCheck,它采用了另一套机制。

这套机制依然需要 NgZone,当 Zone.js 监听事件后,依然是跑 tick。

tick 会从 Root LView 开始往下遍历。到这里,按理说我们没有 markForCheck 任何 LView,遍历根本跑不下去。

所以 Angular 新加了一个往下遍历的条件。

change_detection.ts 源码

detectChangesInViewWhileDirty 是判断要不要往下遍历。

HasChildViewsToRefresh 意思是当前 LView 或许不需要 refresh,但是其子孙 LView 需要,所以得继续往下遍历。

那这个 HasChildViewsToRefresh 是谁去做设定的呢?自然是 Signal 咯。

我们先大概熟悉一下 Signal 源码。

创建 Signal 我们会得到一个 getter 函数(当然它也是一个对象,里面存了维护一切的 SignalNode)。

当 getter 被调用的时候,会跑 producerAccessed 函数。

它会把自己的 SignalNode 添加给 activeConsumer。

这个 activeConsumer 类似一个依赖收集器。它是一个全局变量。

比如说,我现在想要收集依赖

那我就 set 一个 activeConsumer。然后跑代码,代码中会调用多个 Signal 的 getter。

那这些 getter 就会把各自的 SignalNode 添加进去 activeConsumer。

当代码跑完以后,这个 activeConsumer 就收集完它依赖的所有 SignalNode 了,然后就可以监听这些 SignalNode 值变化,来做各种事情。

好,回到来 LView。当 Angular 在 refreshView 时

在 enterView 以后,它 set 了 activeConsumer,这时开始收集依赖。

在 leaveView 之前,它把 activeConsumer set 回去。停止依赖收集。

所以,如果模板 binding 中有执行任何 Signal getter,都会被 LView 收集起来。

那当这些 Signal 值改变后,它会执行 markAncestorsForTraversal

顾名思义,就是把祖先 mark as HasChildViewsToRefresh。

总结

Angular 不是通过 markForCheck 来让 Signal 同步模板的。它搞了一个新机制。

新机制比 markForCheck 好,markForCheck 会造成祖先一定要 refreshView,但 HasChildViewsToRefresh 则不会。

 

Signal-based Input (a.k.a Signal Inputs)

Angular v17.1.0 版本 release 了 Signal-based Input,只有 Input 而且,Output 还没有推出。

Input Signal 的作用就是自动把 @Input 转换成 Signal,这样既可以利用 Signal Change Detection 机制,也可以用来做 Signal Computed 等等,非常方便。

下面是一个 Input Signal

export class SayHiComponent implements OnInit {
  inputWithDefaultValue = input('default value');

  computedValue = computed(() => this.inputWithDefaultValue() + ' extra value');

  ngOnInit(): void {
    console.log(this.inputWithDefaultValue()); // 'default value'
    console.log(this.computedValue()); // 'default value extra value'
  }
}

除了变成 Signal 以外,其它机制和传统的 @Input 没有太多区别,比如一样是在 OnInit Hook 时才可用。

还有一点要注意,这个 Input Signal 是 readonly 的,不是 WritableSignal,这其实是合理的,以前 @Input 可以被修改反而很危险。

required 的写法

inputRequired = input.required<string>();

为了更好的支持 TypeScript 类型提示,Angular 把 requried 做成了另一个方法调用,而不是通过 options。

如果它是 required 那就不需要 default value,相反如果它不是 required 那就一定要放 default value。

也因为 required 没有 default value 所以需要通过泛型声明类型。

alias 和 transform 的写法

inputRequiredWithAlias = input.required<string>({ alias: 'inputRequiredAlias' });
inputRequiredWithTransform = input.required({
  transform: booleanAttribute,
});

transform 之所以不需要提供类型是因为它从 boolAttribute 中推断出来了。

我们要声明也是可以的

inputWithTransform = input.required<unknown, boolean>({
  transform: booleanAttribute,
});

optional alias 和 transform 的写法

inputOptionalWithAlias = input('default', { alias: 'inputOptionalAlias' });
inputOptionalWithTransform = input(false, { transform: booleanAttribute });

一定要提供 default value 哦。

 

Signal-based Two-way Binding (a.k.a Signal Models)

Angular v17.2.0 版本 release 了 Signal-based Two-way Binding,请看这篇 Component 组件 の Template Binding Syntax # Signal-based Two-way Binding

 

Signal-based Query (a.k.a Signal Queries)

Angular v17.2.0 版本 release 了 Signal-based Query,请看这篇 Component 组件 の Query Elements Query Elements # Signal-based Query

 

Signal-based Component

TODO... 目前还不能测试...

 

目录

上一篇 Angular 17+ 高级教程 – Change Detection

下一篇 Angular 17+ 高级教程 – Component 组件 の Dependency Injection & NodeInjector

想查看目录,请移步 Angular 17+ 高级教程 – 目录

 

【版权声明】本文内容来自摩杜云社区用户原创、第三方投稿、转载,内容版权归原作者所有。本网站的目的在于传递更多信息,不拥有版权,亦不承担相应法律责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@moduyun.com

  1. 分享:
最后一次编辑于 2024年02月29日 0

暂无评论

推荐阅读
  mYwXEFqSO0K7   2024年01月09日   14   0   0 Angular
8CgHZpOr8DYB