前言
在上一篇 Change Detection 中, 我们有提到 MVVM 监听 ViewModel 变化的难题.
当年 AngularJS 和 Knockout.js (下面简称 KO) 各自选了不同的道路.
但如今, 事过境迁, Angular 最终也走向了 KO 的道路. 这就是这篇的主角 Signal。
文章来源地址https://www.toymoban.com/news/detail-837832.html
把 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 方案。
-
比 DX,两者都不太好,可能 Zone.js 稍微好一点点。
-
比性能,两者都可以,但 Signal 还可以做到更极致(虽然目前可能没有这个需求)
-
比心智,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 长的挺像的,而且它们好像本来就师出同门哦。
-
signal 像 BehaviorSubject
-
computed 像 combineLatest
-
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('defualt', { alias: 'inputOptionalAlias' });
inputOptionalWithTransform = input(undefined, { transform: booleanAttribute });
第一个参数是 initial value,一定要放,哪怕是放 undefined 也行,因为它只有三种重载。
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+ 高级教程 – 目录文章来源:https://www.toymoban.com/news/detail-837832.html
到了这里,关于Angular 17+ 高级教程 – Signals的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!