Angular 18+ 高级教程 – Signals
公告 2024-10-29:
本文写于 Angular v18.0.0,而即将推出的 Angualr v19 对 Signal 有一些 break changes,在 effect execution timing 的部分。
我会在 v19 正式发布后修改本篇部分的内容。
在这段期间,大家可以先读完这篇,接着读这篇 -- Angular 19 "要" 来了⚡,里面有介绍 v19 Signal 的新功能和新的 effect execution timing。
前言
首先,我必须先说明清楚。Signal 目前不是 Angular 的必备知识。
你的项目不使用 Signal 也不会少了条腿,断了胳膊。
Angular 官方维护的 UI 组件库 Angular Material 源码里就完全没有 Signal 的代码😱。
虽然它不是必须的,但是学习它还是有很多好处的,况且技多不压身嘛💪。
在 Change Detection 文章中,我们提到了 MVVM 监听 ViewModel 变化的难题。
当年 AngularJS 和 Knockout.js (下面简称 KO) 各自选了不同的道路。
但如今,事过境迁,Angular 最终也走向了 KO 的道路,这就是本篇的主角 Signal。
把 variable 变成 getter setter
在 JavaScript,值类型 variable 是无法被监听的,而 Signal 的做法是把它们都变成函数。
看看 KO 的代码
const count = ko.observable('default value'); // 通过 observable 函数 delcare variable const value = count(); // count 是一个 getter 方法 count('new value'); // 同时 count 也是一个 setter 方法
变成函数后,我们就可以把监听代码写到 getter setter 函数中。
虽然 KO 已经退出前端舞台多年,但这个 Signal 概念依然沿用至今,许多库/框架都可以看见。
虽然大家实现的方式不太一样,但是用 getter setter 替代 variable 这个概念都是一样的。
缺点
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()++);
这种写法在其它语言都很少见,或者一看就感觉是为了性能优化特地改的写法。
总之严重影响 code reading。
与众不同的 Svelte 5
Svelte 5 的 Signal 应该是所有 framework 里 DX (Developer Experience) 最好的,比 Angular 好很多。
只需要在最源头做 declaration 就可以了,count 不会变成恶心的 getter 和 setter,它依然是 variable 的使用方式,但它背地里却是 getter setter 的功能。
显然 Svelte 又在 compile 阶段加了很多黑魔法让其工作,但我觉得符合直觉也是很重要的,getter 和 setter 明显就是种妥协。
Angular 也爱搞黑魔法,也有 compiler,为什么不像 Svelte 那样呢?
这段指出,Svelte 的黑魔法无法实现统一语法,在跨组件共享 Signals 的时候写法需要不一致,我不确定这是不是真的。
Angular 认为既然无法做到所有代码统一干净,那就干脆做到所有代码统一不干净吧,至少能统一嘛。这听上去就不太合理,我看他们就是想偷工😒。
Why Angular need Signal?
Zone.js + tick 是 DX 最好的。Signal 没有的比。
所以 Signal 比的是性能优化版本的 Zone.js + OnPush + markForCheck 或者 Zone.js + RxJS + AsyncPipe 方案。
-
比 DX,两者都不太好,可能 Zone.js 稍微好一点点。
-
比性能,两者都可以,但 Signal 还可以做到更极致 (虽然目前可能没有这个需求)
-
比心智,Zone.js 特立独行,手法间接,复杂不直观。相反 Signal 已经相当普及,手法也简单直接。
总结:Signal 明显利大于弊,但 Angular 一定会保留 Zone.js 很长很长一段事件,因为 Zone.js + tick 真的是最好的 DX。
The basics usage of Signal
提醒:目前 v18.0,Signal 还属于 preview 阶段,虽然大部分功能都已经可以使用了,但往后依然可能会被修改 (breaking changes without notification)。比如:rework effect scheduling
Signal 有部分功能是可以脱离 Angular 使用的 (比如你可以直接写在 main.ts),但也有部分功能是依赖 Angular DI 和 build-in Service 的,这些功能通常只会在组件内使用。
我们先介绍那些不依赖 Angular 的功能。
signal 函数 の declare, get, set, update
main.ts
// 1. import signal 函数 import { signal } from '@angular/core'; import { bootstrapApplication } from '@angular/platform-browser'; import { AppComponent } from './app/app.component'; import { appConfig } from './app/app.config'; bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err)); // 2. declare a Signal variable const value = signal(0); // 类似于 // let value = 0;
注:signal 函数不依赖 Angular DI 那些,所以在哪里都可以 import 使用,我放在 main.ts 只是一个举例。
通过调用 signal 函数来 declare 一个 variable,0 是初始值。
返回的 value 是一个 getter 函数 (同时它也是一个对象哦)
读取 value 的方式是调用这个 getter 函数。
const value = signal(0); console.log(value()); // read value // 类似于 // console.log(value);
提醒:这个对初学者可能不太好适应,一不小心忘了放括弧,可能就出 bug 了,要小心哦。
赋值是通过 set 方法。
const value = signal(0); value.set(5);
// 类似于 // value = 5;
还有一个方式是用 update 方法
const value = signal(0); value.update(curr => curr + 5);
// 类似于 // value += 5;
update 和 set 都是用来修改 value 的, 区别是 update 带有一个 current value 的参数,方便我们做累加之类的操作。
你要改成 set 也可以
value.set(value() + 5);
因为 update 底层也是调用了 set 方法。
computed 函数
computed variable 指的是一个 variable,它的值是透过计算其它 variable 而得出来的。
一个经典的例子就是 fullName = firstName + lastName。
fullName 就是 computed variable,
firstName 和 lastName 则是它的依赖,
firstName + lastName 这整句可以称之为 formula 或者 computation。
JavaScript 要维护这种关系,通常会使用 getter 函数,像这样
let firstName = 'Derrick'; let lastName = 'Yam'; const getFullName = () => firstName + ' ' + lastName;
这样做有 2 个缺陷:
-
用了 getter
这不是走向 Signal 了吗?
-
没有缓存能力
我们没有办法缓存 fullName 的值,因为我们不知道什么时候它的依赖 firstName 和 lastName 会变更,
所以每一次调用 getFullName,formula 都必须执行一遍。
Angular Signal 的 computed 函数解决了上述缓存的问题。
import { computed, signal } from '@angular/core'; const firstName = signal('Derrick'); const lastName = signal('Yam'); // 1. 调用 computed 函数,并且提供一个 formula 计算出最终值 const fullName = computed(() => firstName() + ' ' + lastName()); // 2. 调用 fullName getter // 它会执行了 formula 返回 'Derrick Yam' 并且把这个值缓存起来 console.log(fullName()); // 3. 再次调用 fullName getter // 这一次不会执行 formula,而是直接返回缓存结果 'Derrick Yam' console.log(fullName()); // 4. 修改 fullName 的依赖 // 这时不会执行 formula firstName.set('Richard'); // 5. 修改 fullName 的依赖 // 这时不会执行 formula lastName.set('Lee'); // 6. 再次调用 fullName getter // fullName 有办法判断依赖是否已经变更了(具体如何判断,下面会教),这一次会执行 formula 返回 'Richard Lee' 并且把这个值缓存起来 console.log(fullName()); // 7. 再次调用 fullName getter // 这一次不会执行 formula,而是直接返回缓存结果 'Richard Lee' console.log(fullName());
lazy execution & cache
具体的说 computed 有 lazy execution + cache 的概念。
如果没有人 get value,那 formula 永远都不会被执行,这叫 lazy execution。
当有人连续 get value,formula 只会执行第一次,后续会用缓存的值,这叫 cache。
那 cache 什么时候过期呢?
当依赖变更的时候,cache 就算过期了,但注意,即便 cache 过期了,它也不会马上执行 formula 获取新值,而是继续等待下一次有人要 get value 时才会执行 formula。
从上面这几个行为可以看出 computed 对性能是很在意的,绝不多浪费一丝力气👍
逛一逛 signal 和 computed 源码
想要深入理解 Signal,最好的方法自然是...逛源码😱。
虽然之前的 Change Detection,NodeInjector,Lifecycle Hooks,Query Elements,Dynamic Component 五篇文章里,我们逛了非常多的源码,
但我必须说 Signal 的源码非常绕,所以...大家要跟紧了🚀。
WritableSignal, Signal and SignalNode
上面我们有提到,signal 函数返回的值是一个 getter 函数,同时它也是一个对象 (它有 set, update 方法)
const firstName = signal('Derrick'); console.log(firstName()); // firstName 是一个方法 firstName.set('Richar'); // firstName 也是一个对象
signal 函数返回的是 WritableSignal interface
WritableSignal 继承自 type Signal (顾名思义,WritableSignal 是支持写入的 Signal,而 Signal 则只是 readonly)
type Signal 是一个函数,同时也是一个对象
而隐藏在这个 Signal 对象深处还有一个叫 SignalNode 的对象。
import { signal } from '@angular/core'; import { SIGNAL, type SignalNode } from '@angular/core/primitives/signals'; const firstName = signal('Derrick'); // 1. 用 SIGNAL symbol 获取隐藏的 SignalNode 对象 const firstNameSignalNode = firstName[SIGNAL] as SignalNode<string>; console.log('firstNameSignalNode', firstNameSignalNode);
我们可以用 symbol SIGNAL 从 Signal 对象里取出 SignalNode 对象。
这个 SignalNode 下面会详细讲,她绝对是女一号。
ComputedNode and ReactiveNode
computed 函数返回的类型是 Signal 而不是 WritableSignal,因为 computed 是通过计算得来的,它自然是 readonly 不能被写入的。
这个 Signal 里头也有隐藏的 Node,叫 ComputedNode。
const fullName = computed(() => firstName() + ' ' + lastName()); // 1. 用 SIGNAL symbol 获取隐藏的 ComputedNode 对象 const fullNameComputedNode = fullName[SIGNAL] as ComputedNode<string>;
SignalNode 和 ComputedNode 有点区别,但它们都继承自 ReactiveNode。
抽象理解就是 signal 和 computed 会创建出 Signal 对象,Signal 对象里隐藏了 ReactiveNode 对象,ReactiveNode 是 Signal 机制的核心。
How to know if a Signal value has changed?
要知道一个 Signal value 是否已经变更有许多方法,这里先教第一招 -- 看 ReactiveNode 的 version。
const firstName = signal('Derrick'); const firstNameNode = firstName[SIGNAL] as SignalNode<string>; console.log(firstNameNode.version); // 0 firstName.set('Alex'); console.log(firstNameNode.version); // 1 firstName.set('David'); firstName.set('Richard'); console.log(firstNameNode.version); // 3
每一次调用 WritableSignal.set 修改值,ReactiveNode 的 version 就会累加 1。
只要我们把 version 记录起来,之后再拿来对比最新的 version 就可以知道它的值是否变更了。
equal options
假如连续 set 相同的值
firstName.set('Alex'); firstName.set('Alex'); firstName.set('Alex');
ReactiveNode 的 version 是不会累加的,WritableSignal 内部会先判断 set 进来的新值是否和旧值相同,如果相同那等于没有变更,version 保持,不会累加。
它 compare 新旧值的方式是 Object.is,也就是说对象是看 reference 而不是 value。
const person = signal({ firstName: 'Derrick' }); const personNode = person[SIGNAL] as SignalNode<string>; // 1. 换了对象引用,但是值是相同的 person.set({ firstName: 'Derrick' }); console.log(personNode.version); // 2. version 是 1,已经累加了,因为 compare 方式是 ===,对象的 reference 不同
如果我们想改变它 compare 的方式可以通过 equal options
const person = signal( { firstName: 'Derrick' }, { // 1. 把 compare 的方式换成 compare firstName equal: (prev, curr) => prev.firstName === curr.firstName, }, ); const personNode = person[SIGNAL] as SignalNode<string>; // 2. 换了对象引用,但是值是相同的 person.set({ firstName: 'Derrick' }); console.log(personNode.version); // 3. version 依然是 0
Best Practice:Signal value 建议使用 immutable,这样变更会比较简单直观,debug 起来会比较容易。
WritableSignal.set and ReactiveNode.version
WritableSignal.set 的源码在 signal.ts
signalSetFn 函数源码在 signal.ts
以上就是调用 WritableSignal.set 后,累加 SignalNode.version 的相关源码。
computed 的原理?
我们来研究一下 computed 的原理
const firstName = signal('Derrick'); const lastName = signal('Yam'); const fullName = computed(() => firstName() + ' ' + lastName());
调用 fullName 会执行 formula 获取值。这个没有问题。
再次调用 fullName,不会执行 formula,会直接返回缓存值。这个不难,第一次执行完 formula 把值缓存起来就可以了。
修改 firstName,再调用 fullName,判断缓存失效,再执行 formual 获取值。
要判断缓存是否失效,就要在第一次执行 formula 时记录下 firstName 的 version (不只 firstName,所有依赖都需要记录下来),
在后续调用 fullName 时,要先拿所有依赖 (比如 firstName) 当前的 version 对比之前记录的 version,如果 version 不一样,表示 firstName 变更了,缓存失效,要重跑 formula,
如果所有依赖 version 都和之前一样,表示没有变更,缓存依然有效。
对比 version 没有问题,但是要怎样在执行 formula 的时候把所有依赖的 version 记录下来呢?
要解答这题就要先认知几个小配角...let's check it out🚀
Producer
producer 中文叫制作人,fullName 是由 firstName 和 lastName 联合创作出来的,所以 fullName 的制作人是 firstName 和 lastName。
const firstName = signal('Derrick'); const firstNameNode = firstName[SIGNAL] as SignalNode<string>; const lastName = signal('Yam'); const lastNameNode = lastName[SIGNAL] as SignalNode<string>; const fullName = computed(() => firstName() + ' ' + lastName()); const fullNameNode = fullName[SIGNAL] as ComputedNode<string>; console.log(fullName()); // 'Derrick Yam' // 1. 在 fullName ReactiveNode 里记录了它所有的依赖 (a.k.a producer) console.log( // 2. 第一个 producer 是 firstName ReactiveNode fullNameNode.producerNode![0] === firstNameNode, // true ); console.log( // 3. 第二个 producer 是 firstName ReactiveNode fullNameNode.producerNode![1] === lastNameNode, // true );
这些 producer 并不是一开始就在 fullName ReactiveNode 里面的。
console.log(fullNameNode.producerNode === undefined); // true console.log(fullName()); // 'Derrick Yam'
它们是在执行 fullName formula 以后才被记录进来的。
也就是说执行下面这句代码后
() => firstName() + ' ' + lastName()
fullName ReactiveNode 里多出了 firstName 和 lastName 两个 producer。
那关键就在 firstName 和 lastName getter 函数里咯。
这里我们先打住,不急着挖 getter 函数,先认识其它小配角。
Consumer
consumer 中文叫消费者,它和 producer 有点反过来的意思。
我们可以这样理解,
fullName 是依赖 firstName 和 lastName 创建出来的,所以 fullName 的 producer (制作它出来的人) 是 firstName 和 lastName。
与此同时,fullName 本身将作为一个 consumer 消费者,因为它消费 (使用) 了 firstName 和 lastName。
好,有点绕,大概就是观察者模式中 Subject 和 Subscriber 的关系啦。
Create a ReactiveNode
SignalNode 和 ComputedNode 是 Angular 封装的上层接口,我们能不能自己创建一个底层的 ReactiveNode?
当然可以!
我们参考一下 signal 函数,看看它是如何创建出 WritableSignal 和 SignalNode 的。
signal 函数的源码在 signal.ts
createSignal 函数的源码在 signal.ts
SIGNAL_NODE 长这样
类型在 graph.ts
其实 ReactiveNode 有点像 LView,它就是一个简单的对象,里面装了很多资料,职责就是维护一堆乱七八糟的东西。
好,那我们也简单的创建一个 ReactiveNode 来看看
import { REACTIVE_NODE, type ReactiveNode } from '@angular/core/primitives/signals'; const myReactiveNode = Object.create(REACTIVE_NODE) as ReactiveNode; console.log(myReactiveNode.version); // 0
就像我说的,它只是一个简单的对象,version 要如何维护,完全要依靠我们自己。
另外,SignalNode 和 ComputedNode 都是透过 Object.create 创建出来的,也就是说 SIGNAL_NODE 是 SignalNode 的 Prototype
非常古老的 new instance 手法。
替 ReactiveNode 收集 producer
ReactiveNode 有一个属性是 producerNode,它是一个 ReactiveNode Array,上面我们介绍 Producer 时提到过。
假如这个 ReactiveNode 是由其它 ReactiveNode 构造而成,那这个 ReactiveNode 的 producerNode 属性就应该要记录它的 producer 们。
要如何收集 producer 呢?
const firstName = signal('Derrick'); const firstNameNode = firstName[SIGNAL] as SignalNode<string>; const lastName = signal('Yam'); const lastNameNode = lastName[SIGNAL] as SignalNode<string>; // 1. 创建 ReactiveNode const fullNameNode = Object.create(REACTIVE_NODE) as ReactiveNode; // 2. 把 ReactiveNode 设置成全局 Consumer setActiveConsumer(fullNameNode); console.log(fullNameNode.producerNode); // 3. 此时 producerNode 是 undefined firstName(); // 4. 调用 firstName getter lastName(); // 5. 调用 lastName getter console.log(fullNameNode.producerNode![0] === firstNameNode); // true, 此时 producerNode 已经有资料了 console.log(fullNameNode.producerNode![1] === lastNameNode); // true, 此时 producerNode 已经有资料了
上面最关键的是 setActiveConsumer 和调用 firstName, lastName getter 函数。
setActiveConsumer 函数
setActiveConsumer 的源码在 graph.ts
没什么特别的,它只是把传入的 consumer 设置成全局变量。
这个招数我们不是第一次看见了,DI 里也有一个叫 setCurrentInjector 的函数,同样也是设置全局变量。
为什么要设置成全局变量?自然让其它人可以和它链接上咯,谁呢?往下
Signal getter 函数
上面我们有提到,执行 computed formula 会收集 producer
() => firstName() + ' ' + lastName()
而我们创建的 ReactiveNode 也在调用 firstName() lastName() 后就收集到了 producer。
所有矛头都指向了 Signal getter 函数。
源码在 signal.ts
producerAccessed 函数源码在 graph.ts
注意:除了记录了 producerNode,结尾出也记录了 producerNode 当前的 version。
整个过程大概是:
-
setActiveConsumer(fullNameNode)
把 fullNameNode 设置到全局
-
调用 firstName getter
-
firstName getter 里面会调用 producerAccessed
-
producerAccessed 里面会把 firstNameNode push 到 fullNameNode.producerNode array 里,
还有把 firstNameNode.version push 到 fullNameNode.producerLastReadVersion array 里。
computed 的原理
computed 在执行 formula 前会先调用 setActiveConsumer。
相关源码在 computed.ts
这样就把 producer (firstNameNode, lastNameNode) 收集完了,同时也记录了它们当前的 version。
接着在调用 fullName getter 函数时,它会先拿出所有 producer 和之前它们的 version,然后一一对比检查,如果所有 version 和之前记录的一样,那表示这些 producer 没有变更,
缓存依然有效,直接返回缓存。如果任何一个 version 对不上,那就代表 producer 变更了,缓存失效,重新执行 formula 获取新值。
相关源码在 computed.ts
producerUpdateValueVersion 函数源码在 graph.ts
有一些小而快的判断,我们就不细讲了,像 epoch 它是一个全局 version,如果全世界的 Signal 都没有变更,那 producer 自然也不可能变更,所以可以直接 return。
好,大致上这就是 computed 背后的机制。
总结
以上都是 The basics usage of Signal 涉及到的相关源码。
signal 和 computed 函数只是 Signal 知识的一小部分,下面我们会继续介绍其它 Signal 的功能和...逛它们的源码😀。
effect 函数
effect 函数和 signal, computed 不同,它依赖 Angular Dependancy Injection 机制。
effect depends on Root Injector
main.ts
import { effect, signal } from '@angular/core'; import { bootstrapApplication } from '@angular/platform-browser'; import { AppComponent } from './app/app.component'; import { appConfig } from './app/app.config'; bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err)); const firstName = signal('Derrick'); effect(() => console.log(firstName())); // 1. 在 main.ts 调用 effect 函数
直接报错
意思是 effect 需要在 injection context 里才能调用,它和 inject 函数一样,需要 Injector。
它要,就给它一个呗。
const firstName = signal('Derrick'); // 1. 创建一个空 Injector const injector = Injector.create({ providers: [], }); // 2. 调用 effect 时传入 injector effect(() => console.log(firstName()), { injector });
还是报错🤔
error 不同了,这回是说 Injector inject 不到 APP_EFFECT_SCHEDULER token
合理,毕竟我们传入的是一个空 Injector,没有任何 providers。
它要,就给它一个呗。
providers: [
{ provide: APP_EFFECT_SCHEDULER, useClass: ... }
],
什么 ?!😱
APP_EFFECT_SCHEDULER 不是公开的?
是的,Angular 没有 export 它...😒,它是一个 built-in Service,provide to Root Injector。
也就是说,我们没有任何可以提供 APP_EFFECT_SCHEDULER 给 effect 函数的办法,effect 需要 Root Injector 才能调用。
Call effect in Root Injector Context
组件内可以 inject 到 Root Injector,所以在组件内可以调用 effect。
但是,我目前还不打算在组件内调用 effect,因为 effect + 组件会有其它化学反应,我们先避开组件,找个更单纯的地方。
app.config.ts
import { APP_INITIALIZER, effect, ɵprovideZonelessChangeDetection, signal, type ApplicationConfig, } from '@angular/core'; export const appConfig: ApplicationConfig = { providers: [ ɵprovideZonelessChangeDetection(), { provide: APP_INITIALIZER, useFactory: () => { const firstName = signal('Derrick'); effect(() => console.log(firstName())); // 没有 error 了,而且还 console 出了 'Derrick' }, }, ], };
APP_INITIALIZER 发生在 Root Injector 创建之后,App 组件创建之前,它既满足了 Root Injector Context 需求,又避开了组件,最佳人选👍
effect 能干啥?
effect 的作用是监听 Signal 变更,然后触发 callback。
需求很简单,有一个 firstName Signal
const firstName = signal('Derrick');
每当 firstName 变更我想触发一个 callback 函数,
一个典型的观察者模式,像 addEventListener 那样。
但就这样一个简单的需求,目前我们掌握的知识却做不出来...😅。
看一看 effect 如何解决这个需求
const firstName = signal('Derrick'); // 1. 一秒钟后 set firstName to 'Alex' window.setTimeout(() => { firstName.set('Alex'); }, 1000); // 2. 会触发 2次 console // 第一次是 'Derrick' // 第二次是 'Alex' effect(() => console.log(firstName()));
effect 和 computed 的相似与区别
effect 和 computed 在调用上有几分相似。
它们在调用时都需要提供一个匿名函数。
computed 的匿名函数被称为 formula 或 computation,这个函数必须有返回值。
effect 的匿名函数被称为 callback,这个函数没有返回值。
computed 有一种 pull value 的感觉。
我如果没有想去拉,即便依赖变更了,我也不管。
effect 则是一种被 push value 的感觉。
只要依赖变更了,你就 call 我。
所以在使用上,两者有完全不同的目的,无替代性。
effect 的底层原理?
我们先不急着去了解 effect 的底层原理:
- effect 返回的也是 ReactiveNode 吗?
-
effect 如何收集依赖?像 computed 那样吗?也叫 producer 吗?
-
Signal 变更时是如何通知 effect 的?
像 computed 就从来不会被通知,它是在执行 getter 时去检查依赖是否变更而已。
- 我们也可以模拟一个 effect?像我们模拟 computed 那样?
hold 住,下一 part 才逛这部分的源码,我们先看完 effect 表层的各个功能。
effect callback 的触发机制与时机
首先,effect callback 最少会触发一次。
因为 effect 的职责是监听 Signal 然后触发 callback,但是要监听 Signal 前提是它要知道哪些 Signal 是需要被监听的,
而这些 "依赖" 全在 callback 函数里,所以最少也要执行一次 callback 才能收集依赖做监听。
effect callback 在执行时会被 wrap 上一层 queueMicrotask,所以它会延后执行
const firstName = signal('Derrick'); console.log('before call effect'); // 1 effect(() => { console.log('effect callback'); // 3 console.log(firstName()); }); console.log('after call effect'); // 2
"effect callback" 后于 "after call effect"。
此外,这个延后执行还有一个类似 RxJS debounceTime 的作用
const firstName = signal('Derrick'); console.log('before call effect'); effect(() => { console.log(firstName()); // 之处 console 1 次 value 是 'Alex 3' }); firstName.set('Alex1'); firstName.set('Alex2'); firstName.set('Alex3');
虽然 firstName 变更了很多次,但是 effect callback 只触发了一次,并且获取了最新的值。
只要在 async microtask 这段期间,不管 Signal 变更多少次,最终只会触发 callback 一次。
它和 computed 一样很在意性能,绝不多浪费一丝力气👍。
effectRef, autoCleanup, manualCleanup
调用 effect 会返回一个 effectRef 对象。
const firstName = signal('Derrick'); const effectRef = effect(() => console.log(firstName())); effectRef.destroy();
effectRef 对象有一个 destroy 方法,它可以让我们停止监听。
这个机制和 RxJS 调用 Observable.subscribe 后返回 Subscription 非常相似。
destroy 以后,effect callback 就再也不会被 call 了。
默认情况下,我们不需要自己手动 destroy。
因为 effect 会从 Injector inject DestroyRef 对象
我们这个例子是 Root Injector,它 inject 的 DestroyRef 会是 Root Injector 本身,所以当 Root Injector 被 destroy 时,effect 也会自动被 destroy 掉。
如果我们想完全自己控制 destroy 的时机也行,只要设置 manualCleanup options 就可以了。
const firstName = signal('Derrick'); const effectRef = effect(() => console.log(firstName()), { manualCleanup: true });
这样 Angular 就不会自动替我们 destroy 了,但自己不要忘记 destroy 哦,不然 memory leak 就糟了。
onCleanup
effect callback 参数是一个 onCleanup 方法。
我们可以注册一些释放资源的函数进去。
这些函数会在 effect destroy 时被调用。
const firstName = signal('Derrick'); const effectRef = effect(onCleanup => { console.log(firstName()); onCleanup(() => console.log('effect destroyed')); }); effectRef.destroy();
这个机制和 RxJS new Observable callback 返回 displose 函数非常相似。
untracked
effect callback 内调用的所有 Signal getter 都会被收集依赖 (类似 computed 收集 producer 那样),
任何一个 Signal 变更都会触发 effect callback。
如果我们想 skip 掉一些依赖收集,可以使用 untracked 函数
const firstName = signal('Derrick'); const lastName = signal('Yam'); effect(() => { console.log(firstName()); // 会监听 firstName 变更 untracked(() => console.log(lastName())); // 但不会监听 lastName 变更 });
只有 firstName 变更才会触发 callback,lastName 则不会,因为 lastName 在 untracked 里。
allowSignalWrites
默认情况下,在 effect callback 内是不允许修改 Signal 的 (不管是依赖的 Signal 还是其它 Signal),因为怕死循环。
const firstName = signal('Derrick'); effect(() => { console.log(firstName()); firstName.set('Alex'); // 在 effect 内修改 firstName });
确实是有死循环的机率,但机率也不高啦,像上面的例子,callback 触发 2 次也就稳定了丫。除非你在 effect 里面搞累加什么的,那才会死循环。
也因为这样,Angular 允许我们通过 options allowSignalWrites 解除这个限制。
const firstName = signal('Derrick'); effect( () => { console.log(firstName()); // 会触发 2 次,第一次是 'Derrick',第二次是 'Alex' firstName.set('Alex'); // 在 effect 内修改 firstName }, { allowSignalWrites: true }, );
设置后就不会再出现 error 了。
逛一逛 effect 源码
上一 part 我们提到 "effect 的底层原理",有四道问题:
-
effect 返回的也是 ReactiveNode 吗?
可以算是。
-
effect 如何收集依赖?像 computed 那样吗?也叫 producer 吗?
是,而且多了一个 consumer 概念。
- Signal 变更时是如何通知 effect 的?
像 computed 就从来不会被通知,它是在执行 getter 时去检查依赖是否变更而已。
effect 多了一个 consumer 机制,变更时它会通知 effect。
- 我们也可以模拟一个 effect?像我们模拟 computed 那样?
可以模拟。
EffectHandle, Watch and WatchNode
effect 具体返回的是 EffectHandle
源码在 effect.ts
EffectHandle 实现了 EffectRef 接口
EffectHandle 里有个 watcher 属性
watcher 对象里隐藏了一个 WatchNode,源码在 watch.ts
而这个 WatchNode 继承了 ReactiveNode
结论:SignalNode, ComputedNode, WatchNode 都继承自 ReactiveNode,而 ReactiveNode 就是 Singal 的核心 (类似于 LView 在 Ivy rendering engine 中的地位)。
effect 的依赖 (a.k.a producer) 收集
effect 的依赖 (a.k.a producer) 收集和 computed 几乎是一样的
const firstName = signal('Derrick'); const firstNameNode = firstName[SIGNAL] as SignalNode<string>; // 1. Angular 没有公开 class EffectHandle, 我们只能强转类型 const effectHandle = effect(() => console.log(firstName())) as EffectRef & { watcher: Watch }; // 2. 从 watcher 里拿出 WatchNode const effectNode = effectHandle.watcher[SIGNAL]; // 3. effect callback 是 async microtask // 所以这里我们也需要延后执行 console, // 在 effect callback 后 // effectWatchNode.producerNode 就收集到 producer 了 // 这个和 computed 机制是一样的 queueMicrotask( () => console.log(effectNode.producerNode![0] === firstNameNode), // true );
执行 effect callback 后,effectWatchNode.producerNode 就收集到依赖 (a.k.a producer) 了。
effect 的 Consumer 概念
computed 当然也有 consumer 概念,但是 effect 比 computed 更丰富一些。
我们用 fullName = firstName + lastName 来举例。
fullName 是 consumer,firstName 和 lastName (下面简称 firstName) 是 fullName 的 producer。
fullName 和 firstName 的关系是单向的,fullName 知道有 firstName 的存在,
因为 fullNameNode.producerNode 记录了 firstNameNode,
但反过来,firstName 不知道有 fullName 的存在,因为 firstNameNode 里面没有任何关于 fullNameNode 的记录。
结论:fullName 知道 firstName 和 lastName,但 firstName 和 lastName 不知道 fullName,这叫单向关系。
effect 和 computed 一模一样,除了它是双向关系。
queueMicrotask(() => { console.log(effectNode.producerNode![0] === firstNameNode); // true console.log(firstNameNode.liveConsumerNode![0] === effectNode); // true });
除了 effectNode.producerNode 记录了 firstNameNode,反过来 firstNameNode.liveConsumerNode 也记录了 effectNode。
这也是为什么当 Signal 变更以后,有能力去通知 effectNode,因为它记录了所有的 consumer。
模拟 effectWatchNode の 收集 producer & consumer
我们用回上一 part 学过的方式
const firstName = signal('Derrick'); const firstNameNode = firstName[SIGNAL] as SignalNode<string>; // 模拟 effectWatchNode const myReactiveNode = Object.create(REACTIVE_NODE) as ReactiveNode; // 设置全局 consumer,开始收集 producer setActiveConsumer(myReactiveNode); firstName(); console.log(myReactiveNode.producerNode![0] === firstNameNode); // true,成功收集到了 producer console.log(firstNameNode.liveConsumerNode?.at(0) === myReactiveNode); // false, 没有收集到 consumer
只收集到了 producer,但是没有收集到 consumer。
关键在这
effect 用的是 WatchNode,它的 consumerIsAlwaysLive 是 true
而 ReactiveNode 默认的 consumerIsAlwaysLive 是 false
在依赖收集期间,Signal getter 会被调用,里面又会调用 producerAccessed 函数 (上面逛过源码了),
它里头还有一个步骤上面我们没提到,而这个步骤便是收集 consumer。
如果 consumerIsLive 等于 true 就会收集 consumer。
判断是不是 consumerIsLive 有 2 个条件。
- 条件一是看 consumerIsAlwaysLive 属性
SignalNode 和 ComputedNode 的 consumerIsAlwaysLive 是 false,不满足条件一。
effect 是 WatchNode,它的 consumerIsAlwaysLive 是 true,满足条件一。
所以 effect 一定会收集 consumer。而 SignalNode 和 ComputedNode 则不一定,要看是否满足条件二
- 条件二是看 liveConsumerNode array length 是否大过 0
这个比较绕,我给一个复杂点的例子
有一个 firstNameSignalNode 和一个 fullNameComputedNode
fullName 虽然依赖 firstName,但它的 consumerIsAlwaysLive 是 false 不满足条件一,同时 fullnameNode.liveConsumerNode.length 是 0 (更严格讲是 undefined)也不满足条件二,所以不会收集 consumer。
最后 firstNameSignalNode.liveConsumerNode 是 undefined,因为没有收集 consumer。
好,我们再加上一个 effectWatchNode
effect(() => fullName())
effectWatchNode 的 consumerIsAlwaysLive 是 true,满足条件一,所以它会收集 consumer。
收集完 consumer 后,fullNameComputedNode.liveConsumerNode = [effectNode]。
此时 fullNameComputedNode 就变成满足条件二 (liveConsumerNode.length > 0) 了。
所以它变成需要收集 consumer,收集 consumer 后,最终 firstNameSignalNode.liveConsumerNode = [fullNameComputed]。
producerAddLiveConsumer 函数
简单例子是这样
复杂例子 firstNameSignalNode, fullNameComputedNode, effectWatchNode 是这样:
最终是
firstNameSignaNode.liveConsumerNode = [fullNameComputedNode]
fullNameComputed.liveConsumerNode = [effectWatchNode]
两个都会收集到 consumer。
我们修改一下之前的代码,再试试
const firstName = signal('Derrick'); const firstNameNode = firstName[SIGNAL] as SignalNode<string>; const myReactiveNode = Object.create({ ...REACTIVE_NODE, consumerIsAlwaysLive: true, // 设置 consumerIsAlwaysLive }) as ReactiveNode; setActiveConsumer(myReactiveNode); firstName(); console.log(myReactiveNode.producerNode![0] === firstNameNode); // true,成功收集到了 producer console.log(firstNameNode.liveConsumerNode?.at(0) === myReactiveNode); // true, 成功收集到了 consumer
成功收集到了 consumer。
clear producer and consumer
假如我们想清除所有相关的 producer and consumer,可以使用 consumerDestroy 函数。
setActiveConsumer(myReactiveNode); // 开启依赖收集 firstName(); setActiveConsumer(null); // 关闭依赖收集 console.log(myReactiveNode.producerNode![0] === firstNameNode); // true,成功收集到了 producer console.log(firstNameNode.liveConsumerNode?.at(0) === myReactiveNode); // true, 成功收集到了 consumer consumerDestroy(myReactiveNode); // 清除相关的 producer 和 consumer console.log(myReactiveNode.producerNode!.length === 0); // true,成功清除了 producer console.log(firstNameNode.liveConsumerNode!.length === 0); // true, 成功清除了 consumer
它会把所有在依赖收集过程中创造出来的关系通通清除干净,等同于还原到了一开始依赖收集的阶段。
模拟 effectWatchNode の 调用 effect callback
虽然 firstNameNode 记录了 effectNode,但 firstName 变更时,它是怎样去调用 effect callback 的呢?
我们回看上一 part 逛过的 signalValueChanged 函数源码
producerNotifyConsumers 函数的源码在 graph.ts
如果 consumer 已经 dirty 就 skip,如果还没有 dirty 就 mark dirty。
已经 dirty 就 skip 是因为假如有多个 producer 同时变更,effect callback 也只会执行一次而已。
第一句就是 mark dirty (dirty = true)。那什么时候会 mark 回 non dirty (dirty = false) 呢?我们继续看下去。
ReactiveNode 默认是没有设置 consumerMarkedDirty 方法的
但 effectWatchNode 是有的
里面跑了一个 schedule,也就是上面提到过的 wrap queueMicrotask to effect callback。(在 queueMicrotask 后它才会 mark dirty = false)
总结:Signal (firstNameNode) setter 的时候会取出所有 consumer (effectNode),然后调用它们的 callback。
整个过程大概是这样
const firstName = signal('Derrick'); const myReactiveNode = Object.create({ ...REACTIVE_NODE, consumerIsAlwaysLive: true, // 1. 设置 callback consumerMarkedDirty: () => { // 3. 变更后会触发 callback console.log('callback'); }, }) as ReactiveNode; setActiveConsumer(myReactiveNode); firstName(); setActiveConsumer(null); // 收集 producer & consumer 结束 // 2. 一秒后变更 window.setTimeout(() => firstName.set('Alex'), 1000);
提醒,关于 reactiveNode.dirty 的管理:
一秒后 firstName.set('Alex') > myReactiveNode.dirty 被设置成 true > consumerMarkedDirty 被调用 > console.log('callback')。
假如我们继续同步调用 firstName.set('Alex2'),由于 myReactiveNode 已经 dirty 了,因此 consumerMarkedDirty 就不会再被调用了,不再有 'callback'。
假如我们继续异步 (timeout 5 秒后) 调用 firstName.set('Alex3'),此时依然不会有 'callback',因为 myReactiveNode 任然是 dirty 的。
consumerMarkedDirty 负责设置 dirty = false,timing 我们自己决定,比如像 effectWatchNode 它会在 queueMicrotask 后把 dirty 设置回 false (这个下面源码会看到)
ZoneAwareEffectScheduler
上一 part 的结尾,我们看到 WatchNode.consumerMarkedDirty 方法中调用了 WatchNode.schedule,
这个 schedule 是啥?它如何和我们传入的 effect callback 连上?
createWatch 函数的源码在 watch.ts
schedule 是外面传进来的。
调用 createWatch 的地方是 EffectHandle constructor,源码在 effect.ts
没什么知识,我们继续往外看。
谜底揭晓,原来 scheduler 来自 Injector。
最终是 ZoneAwareEffectScheduler
enqueue 方法
简单说就是确保 EffectHandle 不管被 schedule 进来多少次,最后只记录一个在 queue 里头。
比如说,firstName.set,lastName.set,那 effect 会被 schedule 2 次,但是 queue 只记录一个。
我们接着看这个 queue 什么时候被 clear 掉。
重点就是 wrap 了一层 queueMicrotask 延后执行。
flush 方法
handle.run 方法
里面调用的是 watcher.run
注:第 77 行 node.dirty = false 就是把 ReactiveNode.dirty 设置回 false,这样往后的变更又会调用 consumerMarkedDirty 了。
当 effect 依赖收集遇上 async
再仔细看 watcher.run
依赖收集开始和结束是一个同步过程,也就是说如果我们的 effect callback 里面有异步程序,它是不管的。
firstName 不会被依赖收集,因为它在异步程序里,等它执行 getter 的时候,effect 的依赖收集已经结束掉了。
总结 + effect callback 的第一次执行
so far,我们总算是搞清楚了 effect 的来龙去脉。
-
effect 返回的是 EffectHandle
-
EffectHandle.watcher 是一个 Watch 对象
-
watcher[SIGNAL] 是隐藏的 WatchNode,它继承自 ReactiveNode
-
当 effect callback 执行时,WatchNode 会收集到 producer,同时 producer 也会反向记录 WatchNode as consumer。
-
当 producer 变更,它会拿出所有的 consumer 执行 consumerMarkDirty 方法。
-
consumerMarkDirty 会 schedule EffectHandle 到 queue
-
scheduler 会防止 queue duplicated,同时会尝试 flush (execute effect callback and clear queue),但是会做一个 async microtask 延后执行。
有一个谜题还没有解开,那就是 effect 的第一次执行,谁来执行?
不执行,哪知道要依赖谁?要监听谁?那不就一辈子不会 callback 了吗?
effect 函数的源码在 effect.ts
notify 方法的源码在 watch.ts
consumerMarkDiry 函数上面逛过了,这就是第一次执行 effect calblack。
在组件里使用 effect
上一 part 我刻意避开了在组件内使用 effect (躲到了 APP_INITIALIZER 里头用😅),因为我说组件内用 effect 会有化学反应。
这里就讲讲这些化学反应。
DestroyRef 不同
effect 会用 Injector inject DestroyRef 做 autoCleanup,Root Injector inject 的 DestroyRef 是 Root Injector 本身。
而换到组件里就不同了,组件的 Injector 是 NodeInjector,inject 的 DestroyRef 是依据组件的生命周期,当组件 destroy 时 effect 也同时被 destroy。
第一次执行 effect callback 的时机不同
组件内调用 effect,callback 不会立刻被 schedule to queue,而是先把 notify 方法寄存在 LView[22 EFFECTS_TO_SCHEDULE] 里。
一直等到当前 LView 被 refresh
AfterViewInit 后,afterNextRender 前,notify 方法被执行,effect callback 被 schedule to queue。
注意,只是 schedule to queue 而已,effect callback 正真被调用是在 afterNextRender 之后。
另外,假如我们在 afterNextRender 里面调用 effect 它会立刻 schedule to queue。
因为这个阶段 LView 已经完成了第一次的 create 和 update 满足 FirstLViewPass 条件。
好,以上就是在组件内使用 effect 和在组件外使用 effect 的两个区别,好像区别也没有很大...😂
Signal & RxJS
在 Change Detection 文章的结尾,我们有提到,Signal 是用来取代 RxJS 的。
注:虽然目前 Signal 只能取代 Change Detection 相关部分的 RxJS (Router, HttpClient 依然是 RxJS first),但 Angular 团队的方向确实是想 Optional RxJS。
从上面使用 signal, computed, effect 的感受来说,Signal 和 RxJS 确实有几分相似,而且它们好像本来就师出同门哦 (RxJS 的前生是微软 .NET 的 LINQ,Signal 的前生是微软的 KO)。
-
signal 像 BehaviorSubject
-
computed 像 combineLatest
-
effect 像 combineLatest + subscribe
强调:只是像而已。
toObservable
既然长得像,那就一定可以转换咯。
import { toObservable } from '@angular/core/rxjs-interop'; constructor() { const firstName = signal('Derrick'); const firstName$ = toObservable(firstName); // 最少会触发一次 firstName$.subscribe(firstName => console.log(firstName)); // 'Derrick' }
toObservable 函数的源码在 to_observable.ts
原理很简单,effect + ReplaySubject。
利用 effect 监听 Signal 然后转发给 ReplaySubject。
提醒:由于 toObservable 底层是用 effect 做监听,所以它间接也依赖 Injector,它的触发也是 async microtask,总之所有 effect 的习性它都有就是了。
另外,这个 Observable 没有 lazy execution 概念,即使没有人 subscribe Observable effect 依然会跑,还有 unsubscribe Observable 也不会停止 effect 的监听,只有 Injector inject 的 DestroyRef 能停止 effect 的监听。
const number = signal(0); toObservable(number); window.setTimeout(() => { const signalNode = number[SIGNAL] as SignalNode<number>; // 1. 等 effect 执行后,查看 liveConsumerNode console.log(signalNode.liveConsumerNode?.length); // 1 });
看,toObservable 后并没有 subscribe,但 liveConsumerNode 依然多了一个监听。
如果我们希望它更符合 RxJS 概念 (lazy execution & sync & unsubscribe destroy) 的话,我们可以模仿它,封装一个 customize toObservable 函数。
// note 解释: // 和 Angular 的 ToObservable 有 3 个不同 // 1. 有 subscribe 才有 effect // 2. unsubscribe 和 error 都会 destroy effect // 3. subscribe 的第一次 effect 是同步的,第二次才 based on effect schedule function myToObservable<T>(source: Signal<T>, options?: { injector: Injector }): Observable<T> { const injector = options?.injector ?? inject(Injector); const destroyRef = injector.get(DestroyRef); // 1. 不要一开始就执行 effect,把它放到 Observable callback 里执行,这样才能 lazy execution return new Observable<T>(subscriber => { const tryGetValue = (): [succeeded: true, value: T] | [succeeded: false, error: unknown] => { try { return [true, source()]; } catch (error) { return [false, error]; } }; // 2. subscribe 后立刻同步 emit signal value,不等 effect const [succeeded, valueOrError] = tryGetValue(); succeeded && subscriber.next(valueOrError); if (!succeeded) { subscriber.error(valueOrError); // 3. 假如一开始就 error,那就不用执行 effect 了。 return; } let firstTime = true; const firstTimeValue = valueOrError; const watcher = effect( () => { const [succeeded, valueOrError] = tryGetValue(); if (firstTime) { // 4. 由于上面我们已经同步 emit 了第一次的 signal value // 这里 effect 的第一次有可能是多余的 // 之所以是可能,而不是一定,是因为 signal 也有可能会在这短短的期间变更,所以我们最好 compare 一下它们的值。 firstTime = false; const signalNode = source[SIGNAL] as SignalNode<T>; if (succeeded && signalNode.equal(valueOrError, firstTimeValue)) { return; // skip } } untracked(() => { succeeded && subscriber.next(valueOrError); if (!succeeded) { watcher.destroy(); subscriber.error(valueOrError); } }); }, { injector, manualCleanup: true }, ); destroyRef.onDestroy(() => { watcher.destroy(); subscriber.complete(); }); return () => watcher.destroy(); // 5. unsubscribe destroy }).pipe(shareReplay({ bufferSize: 1, refCount: true })); }
综上,Signal 和 BehaviourSubject 还是有区别的,尤其 Signal 是 async microtask,BehaviourSubject 是 sync,所以如果想替换它们俩,一定要检查清楚逻辑,不然一不小心就掉坑里了。
toSignal
constructor() { const firstNameBS = new BehaviorSubject('Derrick'); const firstName = toSignal(firstNameBS); console.log(firstName()); // 'Derrick' }
toSignal 源码我懒的逛了,简单说一下就好呗。
首先 subscribe Observable 然后调用 WritableSignal.set 方法赋值,之后在用 computed wrap 这个 WritableSignal (让它变成 readonly),最终返回 Signal (readonly)。
有几个小知识可以额外了解一下:
requireSync
const firstNameBS = new BehaviorSubject('Derrick'); const firstName: Signal<string | undefined> = toSignal(firstNameBS);
默认情况下,toSignal 返回的 string | undefined 类型,因为 Observable 不一定会有同步 value。
上面例子用的是 BehaviorSubject,那它会有同步 value。
下面这里例子就没有同步 value
const firstName$ = new Observable(subscriber => queueMicrotask(() => subscriber.next('Derrick'))); const firstName = toSignal(firstName$); console.log(firstName()); // undefined
toSignal 无法做出判断,所以责任在我方。
如果我们确定 Observable 有同步 value,那我们可以设置 requireSync
const firstName: Signal<string> = toSignal(firstNameBS, { requireSync: true });
这样返回的类型就不包含 undefined 了。
initialValue
还有一个方式是设置 default value。
const firstName = toSignal(firstName$, { initialValue: 'Default Name' }); console.log(firstName()); // 'Default Name'
toSignal 也依赖 Injector?
main.ts
const firstNameBS = new BehaviorSubject('Derrick'); const firstName = toSignal(firstNameBS);
在 main.ts 里调用 toSignal 会报错。
没道理丫🤔。toObservable 需要用 effect 监听 Signal,effect 需要 Injector 所以 toObservable 也需要,这个合理。
toSignal 只需要 subscribe Observable,不需要用到 effect 丫,怎么它也需要 Injector?
toSignal 的源码在 to_signal.ts
原来它里面尝试用 Injector 去 inject DestroyRef。
它的用意是当组件 destroy 的时候,自动去 unsubscrube Observable。哇呜...挺贴心的设计呢😐
如果我们不希望它自作主张去 auto unsubscrube,那我们可以设置 manualCleanup
const firstNameBS = new BehaviorSubject('Derrick'); const firstName = toSignal(firstNameBS, { manualCleanup: true });
关掉 auto 后,它就不会去 inject DestroyRef,也就不会再报错了。
toSignal 会立刻 subscribe
假如 Observable 是来自 fromEvent 这类和 DOM 相关的要注意哦,因为所有事件监听应该在 afterNextRender 才执行 (要考虑到 Server-side Rendering 的情况)。
Signal vs RxJS 对比例子
这里给一个例子,看看它俩的区别。
signal + effect
首先出场的是 signal + effect。
const $1 = signal(1); const $2 = signal(2); effect(() => { console.log([$1(), $2()]); }); window.setTimeout(() => { $1.set(3); $2.set(4); }, 1000);
效果
一开始 console 了一次 [1, 2]
接着 timeout 1 秒后,虽然 $1 和 $2 各自都 set 了一次 value,但是 effect 只 console 了一次 [3, 4]。
合理,因为 Signal 是异步 Microtask。
假如我们加上一个 queueMicrotask,那结局就不一样了
window.setTimeout(() => { console.log('1 秒后') $1.set(3); queueMicrotask(() => $2.set(4)) }, 1000);
效果
多了一个 console [3, 2],因为我们压后了 set(4) 所以 effect 就先触发了。
BehaviorSubject + combineLatest
好,下一位登场的是 RxJS -- BehaviorSubject + combineLatest
const s1 = new BehaviorSubject(1); const s2 = new BehaviorSubject(2); combineLatest([s1, s2]).subscribe(() => console.log([s1.value, s2.value])); window.setTimeout(() => { console.log('1 秒后') s1.next(3); s2.next(4); }, 1000);
效果
RxJS 是同步执行的,next(3) 之后 combineLatest 立刻就触发了 [3, 2]。
用 RxJS 模拟 Signal
假如我们想用 RxJS 做出类似 Signal 的效果,大概会是这样
const s1 = new BehaviorSubject(1); const s2 = new BehaviorSubject(2); const s1$ = s1.pipe(distinctUntilChanged()); // signal 遇到相同值时不会发布 const s2$ = s2.pipe(distinctUntilChanged()); combineLatest([s1$, s2$]).pipe( // 用 audit + queueMicrotask 延后发布最新值 audit(v => new Observable(subscriber => queueMicrotask(() => subscriber.next(v)))) ).subscribe(() => { console.log([s1.value, s2.value]) }) window.setTimeout(() => { console.log('1 秒后') s1.next(3); s2.next(4); }, 1000);
两个重点:
-
加了 distinctUntilChanged
-
加了 audit 和 queueMicrotask
效果
压后 next(4)
window.setTimeout(() => { console.log('1 秒后') s1.next(3); queueMicrotask(() => s2.next(4)); }, 1000);
效果
Signal as ViewModel
上面的一些例子已经有在组件内使用 Signal 了,但它们都没用用于 Template Binding Syntax。
接下来我们看看 Signal 如何作为 ViewModel。
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')">set first name</button> <button (click)="lastName.set('Lee')">set 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。
v17.1.0 后,markForCheck 和这套机制都会触发 tick 功能,不需要再依赖 Zonje.js 触发 tick 了。
tick 会从 Root LView 开始往下遍历。到这里,按理说我们没有 markForCheck 任何 LView,遍历根本跑不下去。
所以 Angular 新加了一个往下遍历的条件。
detectChangesInViewWhileDirty 是判断要不要往下遍历。
HasChildViewsToRefresh 意思是当前 LView 或许不需要 refresh,但是其子孙 LView 需要,所以得继续往下遍历。
那这个 HasChildViewsToRefresh 是谁去做设定的呢?自然是 Signal 咯。
当 Angular 在 refreshView 时
consumerBeforeComputation 函数的源码在 graph.ts
里面调用了 setActiveConsumer 把 node 设置成全局 consumer。
这个 node 是一个 ReactiveNode,具体类型是 ReactiveLViewConsumer。(源码在 reactive_lview_consumer.ts)
我想你也已经看出来了,它在搞什么鬼。
每一个 LView 都有一个 ReactiveLViewConsumer,它用来收集依赖 (a.k.a producer) 的。
在 LView refresh 之前,它会把 LView 的 ReactiveLViewConsumer (ReactiveNode 来的) 设置成全局 consumer,
refreshView 执行的时候,LView 的 Template Binding Syntax (compile 后是一堆函数调用) 会被执行,这些函数中就包含了 Signal getter。
全局 consumer + Signal getter = 收集 producer 和 consumer (这就是 effect 的机制嘛)
接下来就等 Signal 变更后执行 markAncestorsForTraversal
顾名思义,就是把祖先 mark as HasChildViewsToRefresh,源码在 view_utils.ts
总结:
LView 用了和 effect 类似的手法收集 producer 和 consumer,当 producer 变更它 markAncestorsForTraversal (新招数),markAncestorsForTraversal 会触发 tick,然后 refreshView,这样就更新 DOM 了。
另外一点,markAncestorsForTraversal 比 markForCheck 好,因为 markForCheck 会造成祖先一定会 refreshView,而 markAncestorsForTraversal 只是把祖先 mark 成 HasChildViewsToRefresh,
意思是只有子孙要需要 refreshView,自己是不需要 refreshView 的。希望未来 Angular 会公开这个 markAncestorsForTraversal 功能。
AfterNextRender + effect + signal view model 面试题
export class SimpleTestComponent { // 1. 这是一个 Signal view model name = signal('derrick'); constructor() { const injector = inject(Injector); // 2. 这里注册一个 after render callback afterNextRender(() => { // 3. 里面执行 effect effect( () => { if (this.name() === 'derrick') { // 4. effect 里面修改 Signal view model this.name.set('new name'); } }, { allowSignalWrites: true, injector }, ); }); } }
面试官:依据上面的理解,讲解一下你了解的 Angular 执行过程。
你:Angular bootstrapApplication 会执行 renderView 和 tick > refreshView。
renderView 会执行 SimpleTest 组件的 constructor,然后会注册 after render callback。
等到 refreshView 结束后会执行 after render callback。
这时会执行 effect。由于已经过了 LView 第一轮的 render 和 refresh 所以 effect callback 会直接 schedule to queue。
此时第一轮的 tick 就结束了,但是还没有到 browser 渲染哦,因为 effect schedule 是 microtask level 而已,所以 tick 结束后就会接着执行 effect callback。
callback 里面会修改 signal view model,LView (ReactiveLViewConsumer) 监听了这个 view model 的变更,一旦变更就会执行 markAncestorsForTraversal,然后会触发一个 tick。
于是又一轮 refreshView,修改 DOM,tick 结束,browser 渲染。
Signal 新手常掉的坑
刚开始使用 Signal 可能会不适应它的一些机制,一不小心就会掉坑了,这里给大家提个醒。
effect 没有执行
afterNextRender(() => { const classSelector = signal('item'); effect(() => { const elements = Array.from(document.querySelectorAll('.container')).filter(el => el.matches(classSelector())); console.log('elements', elements); }); });
假如第一次执行 effect callback 的时候,querySelectorAll('.container') 返回的是 empty array,那后面的 filter 就不会跑,classSelector getter 也不会被调用。
这样依赖就没有被收集到,从此这个 effect callback 就不会再触发了。
下面这样写就完全不同了
effect(() => { const selector = classSelector(); const elements = Array.from(document.querySelectorAll('.container')).filter(el => el.matches(selector)); console.log('elements', elements); });
classSelector 会被依赖收集,每当它变更,querySelectorAll 和后续的逻辑都会执行。
具体你是要哪一种效果,我不知道,我只是告诉你它们的区别。
Signal-based Input (a.k.a Signal Inputs)
Angular v17.1.0 版本 release 了 Signal-based Input。
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 也行,因为它只有三种重载。
set readonly Input Signal
Input Signal 对内是 readonly 合理,但是对外是 readonly 就不合理了。
Message 组件
@Component({ selector: 'app-message', standalone: true, template: `<h1>{{ message() }}</h1>`, changeDetection: ChangeDetectionStrategy.OnPush, }) export class MessageComponent { readonly message = input.required<string>(); }
App 组件
@Component({ selector: 'app-root', standalone: true, template: `<app-message message="hello world" />`, changeDetection: ChangeDetectionStrategy.OnPush, imports: [MessageComponent], }) export class AppComponent {}
如果我们想在 App 组件 query Message 组件,然后直接 set message 进去可以吗?
答案是不可以,因为 InputSignal 没有 set 或 update 方法
这就非常不方便,而且也和之前的 @Input 不兼容。
那有没有黑科技,或者 workaround?还真有😏
constructor() { window.setTimeout(() => { const messageSignal = this.messageComponent().message[SIGNAL]; messageSignal.applyValueToInputSignal(messageSignal, 'new message'); }, 2000); }
直接拿 InputSignalNode 出来操作就可以了。
如果 input 有配置 transform 可以先调用 transformFn 获取 transform 后的值再调用 applyValueToInputSignal
const numberValue = messageSignal.transformFn!.('100');
messageSignal.applyValueToInputSignal(messageSignal, numberValue);
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)
温馨提醒:忘记了 Query Elements 的朋友,可以先回去复习。
Signal-based Query 是 Angular v17.2.0 推出的新 Query View 和 Query Content 写法。
大家先别慌,它只是上层写法换了,底层逻辑还是 Query Elements 文章教的那一套。
viewChild
before Signal
@ViewChild('title', { read: ElementRef })
titleElementRef!: ElementRef<HTMLHeadingElement>;
after Signal
titleElementRef2 = viewChild.required('title', { read: ElementRef<HTMLHeadingElement>, });
有 3 个变化:
-
Decorator 没了,改成了函数调用。从 v14 的 inject 函数取代 @Inject Decorator 开始,大家都预料到了,有朝一日 Angular Team 一定会把 Decorator 赶尽杀绝的😱。
-
titleElementRef 类型从 ElementRef<HTMLHeadingElement> 变成了 Signal 对象 -- Signal<ElementRef<HTMLHeadingElement>>。
不过目前 TypeScript 类型推导好像有点问题,titleElementRef2 的类型是 Signal<ElementRef<any>>,它没有办法推导出泛型,所以 read ElementRef 时不够完美。
我们只能自己声明类型来解决
titleElementRef2 = viewChild.required<string, ElementRef<HTMLHeadingElement>>('title', { read: ElementRef, });
泛型第一个参数是 'title' 的类型,第二个是 read 的类型。
-
titleElementRef! 结尾的 ! 惊叹号变成了 viewChild.required。没有惊叹号就不需要 .required。
惊叹号或 required 表示一定能 Query 出 Result,不会出现 undefined。
viewChildren
// before Signal @ViewChildren('title', { read: ElementRef }) titleQueryList!: QueryList<ElementRef<HTMLHeadingElement>>; // after Signal titleArray = viewChildren<string, ElementRef<HTMLHeadingElement>>('title', { read: ElementRef, });
两个知识点:
-
before Signal 返回的类型是 QueryList 对象,after Signal 类型变成了 Signal Array -- Signal<readonly ElementRef<HTMLHeadingElement>[]>。
-
! 惊叹号不需要 viewChildren.required,因为 @ViewChild 和 viewChildren 即便 Query 不出 Result,也会返回 QueryList 对象或 Signal Empty Array。
contentChild 和 contentChildren
content 的写法和 view 是一样的。把 view 改成 content 就可以了。这里就不给例子了。
Replacement for QueryList and Lifecycle Hook
我们先理一下 QueryList 的特性:
-
QueryList 是在 renderView 阶段创建的,理论上来说,组件在 constructor 阶段肯定还拿不到 QueryList,但从 OnInit Lifecycle Hook 开始就应该可以拿到 QueryList 了。
但是
这是因为 Angular 是在 refreshView 阶段才将 QueryList 赋值到组件属性的,所以 OnInit 和 AfterContentInit 时组件属性依然是 undefined。
-
QueryList Result Index 是在 renderView 结束时收集完毕的。理论上来说,只要在这个时候调用 ɵɵqueryRefresh 函数,QueryList 就可以拿到 Result 了。
但是 Angular 一直等到 refreshView 结束后才执行 ɵɵqueryRefresh 函数。
-
综上 2 个原因,我们只能在 AfterViewInit 阶段获取到 QueryList 和 Query Result。
-
Angular 这样设计的主要原因是不希望让我们拿到不完整的 Result,尽管 renderView 结束后已经可以拿到 Result,但是这些 Result 都是还没有经过 refreshView 的,
组件没有经过 refreshView 那显然是不完整的,所以 Angular 将时间推迟到了最后,在 AfterViewInit 阶段所有 Query 到的组件都是已经 refreshView 了的。
-
QueryList.changes 只会在后续的改动中发布,第一次是不发布的。
Replacement for QueryList
Signal-based Query 不再曝露 QueryList 对象了 (这个对象依然还在,只是不在公开而已),取而代之的是 Signal 对象,那我们要怎样监听从前的 QueryList.changes 呢?
QueryList 没了,不要紧,我们多了个 Signal 嘛,Signal 也可以监听丫,要监听 Signal 可以使用 effect 函数。
export class AppComponent { titles = viewChildren<string, ElementRef<HTMLHeadingElement>>('title', { read: ElementRef, }); constructor() { effect(() => { console.log(this.titles()); }); } }
每当内部的 QueryList 发生变化 (包括第一次哦,这点和 QueryList.changes 不同),Signal 就会发布新值,监听 Signal 值的 effect 就会触发。
Replacement for Lifecycle Hook
除了隐藏 QueryList 之外,Signal-based Query 也修改了执行顺序。
export class AppComponent implements OnInit, AfterContentInit, AfterViewInit { titles = viewChildren<string, ElementRef<HTMLHeadingElement>>('title', { read: ElementRef, }); constructor() { console.log(this.titles().length); // 0 effect(() => { console.log(this.titles().length); // 1 }); } ngOnInit(): void { console.log(this.titles().length); // 1 } ngAfterContentInit(): void { console.log(this.titles().length); // 1 } ngAfterViewInit(): void { console.log(this.titles().length); // 1 } }
在 renderView 结束后,Angular 就执行了 ɵɵqueryRefresh,所以从 OnInit 开始就可以获取到 Query Result 了。(注:此时的 Query Result 依然属于不完整状态,组件还没有 refreshView 的)
Angular 修改这个顺序主要是因为它想把职责交还给我们,它提早给,我们可以选择要不要用,它不给,我们连选择的机会都没有。
Signal-based Query 源码逛一逛
App 组件
export class AppComponent { titles = viewChildren<string, ElementRef<HTMLHeadingElement>>('title', { read: ElementRef, }); @ViewChildren('title', { read: ElementRef }) titleQueryList!: ElementRef<HTMLHeadingElement>; }
一个 Signal-based,一个 Decorator-based,我们做对比。
yarn run ngc -p tsconfig.json
app.component.js
2 个区别:
-
Decorator-based 在 refreshView 阶段做了 2 件事,Signal-based 一件也没有。
第一件事是赋值给组件属性,Signal-based 改成了在 constructor 阶段完成。
所以在 constructor 阶段 Decorator-based 的 QueryList 属性是 undefined,而 Signal-based 的 Signal 属性是有 Signal 对象的。
第二件事是刷新 Query Result,Signal-based 改成了去监听 Dyanmic Component 的 append 和 removeChild,当插入和移除时就会刷新 Query Result。
-
在 renderView 阶段,Decorator-based 会创建 QueryList,然后收集 Query Result Index,这些 Signal-based 也都会做,做法也都一模一样。
Signal-based 唯一多做了的事是关联 QueryList 和 Signal。具体流程大致上是这样:
当 Dynamic Component append 和 removeChild 时,它会 set QueryList to Dirty,Signal 会监听 QueryList Dirty,当 QueryList Dirty 后 Signal 会刷新 Query Result。
viewChildren 函数的源码在 queries.ts
createMultiResultQuerySignalFn 函数的源码在 query_reactive.ts
createQuerySignalFn 函数的源码在 query_reactive.ts
createQuerySignalFn 函数有点绕,一行一行很难讲解,我分几个段落讲解吧。
createComputed 函数是我们常用的 Signal computed 函数的 internal 版本
Computed Signal 的特色是它内部会依赖其它 Signal。
Computed Signal 内部
回到 app.component.js,ɵɵviewQuerySignal 函数的源码在 queries_signals.ts
createViewQuery 函数负责创建 TQuery、LQuery、QueryList。
Signal-based 和 Decorator-based 调用的是同一个 createViewQuery 函数,所以 Signal-based 的区别是在 bindQueryToSignal 函数。
bindQueryToSignal 函数的源码在 query_reactive.ts
总结
-
有 2 个主要阶段
第一个是 constructor
第二个是 renderView
-
有 2 个主要对象
第一个是 QueryList
第二是 Computed Signal
-
constructor 阶段创建了 Computed Signal
renderView 阶段创建了 QueryList
-
Computed Signal 负责刷新 Query Result,但刷新 Query Result 需要 QueryList (当然还有其它的,比如 LView 我就不一一写出来的,用 QueryList 做代表)。
所以在 renderView 创建 QueryList 后,Computed Signal 和 QueryList 需要关联起来。
-
_dirtyCounter Signal 是一个小配角,因为 QueryList on Dirty 的时候要刷新 Query Result,
而刷新 Query Result 是 Computed Signal 负责的,要触发一个 Signal 只能通过给它一个依赖的 Signal,所以就有了 _dirtyCounter Signal。
-
最后:QueryList on Dirty 时 -> 通知 _dirtyCounter Signal -> Computed Signal 依赖 _dirtyCounter Signal -> Computed Signal 刷新 Query Result。
-
QueryList on Dirty 是什么时候触发的呢?
LQueries 负责 set QueryList to Dirty
LQueries 的 insertView、detachView 方法是在 Dynamic Component 插入/移除时被调用的。finishViewCreation 会在 LView renderView 后,Child LView renderView 之前被调用。
Might be a bug
export class AppComponent {
constructor() {
const titles = viewChildren<string, ElementRef<HTMLHeadingElement>>('title', {
read: ElementRef,
});
effect(() => {
console.log(titles());
})
}
}
如果我们把 viewChildren 返回的 Signal assign to 一个 variable 而不是一个属性的话,compilation 出来的 App Definition 不会有 viewQuery 方法。
也不只是 assign to variable 才出问题,写法不一样它也 compile 不了。
export class AppComponent {
titles: Signal<readonly ElementRef<HTMLHeadingElement>[]>;
constructor() {
this.titles = viewChildren<string, ElementRef<HTMLHeadingElement>>(
'title', { read: ElementRef, }
);
effect(() => {
console.log(this.titles());
});
}
}
像上面这样分开写也是不可以的。提交了 Github Issue,我猜 Angular Team 会说:是的,必须按照官方的 code style 去写,不然 compiler 解析不到。
这也是 compiler 黑魔法常见的问题,因为语法设计本来就是很复杂的,框架如果要支持各做逻辑会很耗精力。
Signal-based Output (a.k.a Signal Outputs)
Angular v17.3.0 版本 release 了 Signal-based Output。
Signal-based Output 其实和 Signal 没有太多关系,因为它不根本就没有使用到 Signal 对象,它和 Signal-based Input 完全不可相提并论。
它们唯一的共同点是调用的手法非常相识,仅此而已。
Signal-based Output 长这样
export class HelloWorldComponent { newClick = output<string>(); @Output() oldClick = new EventEmitter<string>(); handleClick() { this.newClick.emit('value'); this.oldClick.emit('value'); } }
和 Decorator-based Output 相比,主要是它不使用 Decorator 了,改成使用全局函数 output。
这一点和 inject, input, viewChild, contentChild 概念是一样的,通通都是从 Decorator 改成了全局函数。
监听的方式和以前一样,没有任何改变。
<app-hello-world (newClick)="log($event)" (oldClick)="log($event)" />
OutputEmitterRef vs EventEmitter
output 函数返回的是 OutputEmitterRef 对象。对于我们使用者来说,OutputEmitterRef 和 EventEmitter 没有什么区别。
但是往里面看,它俩的区别可就大了,这甚至会引出 Angular 团队的下一个大方向 -- Optional RxJS🤔。
EventEmitter 继承自 RxJS 的 Subject
而 OutputEmitterRef 不依赖 RxJS
OutputEmitterRef 的源码在 output_emitter_ref.ts
它只有 2 个公开接口 -- subscribe 和 emit。
Signal-based Output 源码逛一逛
如果你没有跟我一起逛过源码,最好是顺着本教程学,因为我讲解过的就不会再重复讲解了。
首先 run compilation
yarn run ngc -p tsconfig.json
app.component.js
监听 output 和监听普通 DOM event 是一样的,都是通过 ɵɵlistener 函数。
hello-world.js
Decorator-based Output 和 Signal-based Output compile 出来的 Definition 是一样的。
关于 output 的信息都记录在 outputs 属性中。
在 Angular bootstrapApplication 源码逛一逛 文章中,我们提到过 initializeDirectives 函数。
它的源码在 shared.ts
在 initializeDirectives 函数的结尾调用了 initializeInputAndOutputAliases 函数
initializeInputAndOutputAliases 函数最终把 output 信息存放到了 TNode 上。
HelloWorld 组件 TNode
上面有提到 app.component.js 会调用 ɵɵlistener 函数监听 output。
相关源码在 listener.ts
总结
<app-hello-world (newClick)="log($event)" (oldClick)="log($event)" />
export class HelloWorldComponent { newClick: OutputEmitterRef<string> = output<string>();
@Output() oldClick = new EventEmitter<string>(); }
上面这些 Template Binding Syntax 最终变成了
helloWorldInstance.newClick.subscribe($event => appInstandce.log($event))
helloWorldInstance.oldClick.subscribe($event => appInstandce.log($event))
Related with Signal
我唯一看到和 Signal 有关的
在 OutputEmitterRef.emit 执行 callback function 之前,它会先把 Signal 的依赖收集器 set 成 null,执行完 callback function 后再还原 Signal 依赖收集器。
我不清楚为什么它这么做,也懒得去研究,以后遇到 bug 再回头看呗。
outputFromObservable 和 outputToObservable
Signal 对象可以 convert to RxJS Observable,OutputEmitterRef 也行。
export class HelloWorldComponent { myClickSubject = new Subject<string>(); myClick = outputFromObservable(this.myClickSubject) satisfies OutputRef<string>; myHover = output<string>(); myHover$ = outputToObservable(this.myHover) satisfies Observable<string>; }
没什么特别的,就只是一个 convert 而已。
值得留意的是,outputFromObservable 返回的是 OutputRef 而不是 OutputEmitterRef,它俩的区别是 OutputRef 只能 subscribe 不能 emit,类似 readonly 的概念。
Signal-based OnInit? (the hacking way...🤪)
Angular 团队说了
Signal-based 会保留 ngOnInit 和 ngOnDestroy。其它 lifecycle 用 effect 和 AfterRenderHooks 替代。
其实 ngOnDestroy 早就被 DestroyRef 取代了,目前无可替代的只剩下 ngOnInit 而已。
这我就纳闷了,留一个另类在那边,这不是摆明来乱的吗?😡
好,今天心血来潮,我们就来试试看有没有一些 hacking way 可以实现 Signal-based 风格的 ngOnInit。
ngOnInit 的原理
首先,我们需要知道 ngOnInit 的原理。
这个好办,以前我们逛过它源码的,如果你忘记了,可以看这篇。
我们先搭个环境
App Template
<app-hello-world [value]="value()" appDir1 />
App Template 里有 HelloWorld 组件,组件上有 Dir1 指令和 @Input value。
HelloWorld 组件
export class HelloWorldComponent implements OnInit { readonly value = input.required<string>(); ngOnInit() { console.log('HelloWorld 组件', this.value()); } }
Dir1 指令
export class Dir1Directive implements OnInit { ngOnInit() { console.log('Dir1 指令') } }
HelloWorld 组件和 Dir1 指令都有 ngOnInit。
我们知道 ngOnInit 会被保存到 LView 的 parent TView 里,也就是说,HelloWorld 组件和 Dir1 指令的 ngOnInit 会被保存到 App TView 的 preOrderHooks array 里。
下图是 App TView
preOrderHooks 的记录是有顺序规则的。
index 0: 25 是 <app-hello-wrold> TNode 在 App TView.data 的 index
index 1: -36 是 HelloWorld 实例在 App LView 的 index
index 2: ngOnInit 是 HelloWorld 组件的 ngOnInit 方法
index 3: -37 是 Dir1 实例在 App LView 的 index
index 4: ngOnInit 是 Dir1 指令的 ngOnInit 方法
一个 TNode 可能会包含多个指令,所以会有多个指令实例和 ngOnInit 方法。
index 5: 下一个 TNode。我们目前的例子没有下一个了,像下面这样就会有
<app-hello-world [value]="value()" appDir1 /> <app-say-hi [value]="value()" appDir1 />
此时 index 5 就是 SayHi TNode 的 index,index 6 就是 SayHi 实例 index,index 7 就是 SayHI ngOnInit 方法,以此类推。
好,搞清楚 ngOnInit 被存放到哪里还不够,我们还要知道它存放的时机。
在 renderView 阶段,App template 方法被调用。
此时会实例化 HelloWorld 组件,并且 register pre order hooks。
但在实例化 HelloWorld 组件之前,有这么一 part
initializeDirectives 函数我们之前逛过,这里就不再赘述了。
我们看它的两个重点:
-
它会从 class HelloWorld 的 prototype 里取出 ngOnInit 方法
HelloWorld.prototype['ngOnInit']
熟悉 JavaScript class prototype 概念的朋友应该能理解
-
如果这个 prototype 中有 ngOnInit,ngDoCheck,ngOnChanges (这三个都是 pre order hooks),
那它会把 TNode index 存入 preOrderHooks array 里,也就是上面的 index 0:25。
提醒:此时 HelloWorld 组件是还没有被实例化的哦,constructor 还没有被执行。
好,接着就是实例化 HelloWorld 组件,然后 register pre order hooks。
相关源码在 di.ts 里的 getNodeInjectable 函数
实例化组件后才会 register pre order hooks。
registerPreOrderHooks 函数源码在 hooks.ts
往 preOrderHooks array 里 push 了 2 个值。也就是上面提到的 index 1: HelloWorld 组件实例的 index,index 2: HelloWorld 组件的 ngOnInit 方法 (方法是从 class HelloWorld prototype 里拿的)
然后就是 refreshView 阶段了,当 @Input 被赋值后,它就会调用 TView.preOrderHooks 中的 ngOnInit 方法。
The hacking way
好,理一理思路:
-
在 HelloWorld 组件实例化之前,class HelloWorld 的 prototype 中最好能有 ngOnInit 方法。
因为这样它才会要把 TNode index push 到 TView.preOrderHooks array 里。
-
在 HelloWorld 组件实例化以后,class HelloWorld 的 prototype 一定要有 ngOnInit 方法。
因为它要 register pre order hooks,把组件实例和 ngOnInit 方法 push 到 TView.preOrderHooks array 里。
我们的目标是不要定义 ngOnInit 方法,取而代之的是像调用 afterNextRender 函数那样在 constructor 里调用 onInit 函数注册 on init callback。
按照我们的目标,上面第一条是无法达成了,所以我们需要手动把 TNode index 添加到 TView.preOrderHooks array 里。
至于第二条,我们可以办到。只要在 constructor 里添加 ngOnInit 方法到 HelloWorld.prototype 就可以了。
好,思路清晰,开干。
首先,我们定义一个全局函数 onInit
type CallBack = () => void; function onInit(componentInstance: Record<PropertyKey, any>, callback: CallBack) { }
参数一是组件实例,我们需要从组件实例中获取它的 prototype,然后添加 ngOnInit 方法进去。
参数二就是 on init callback 函数。
它的使用方式是这样的
export class HelloWorldComponent { readonly value = input.required<string>(); constructor() { onInit(this, () => { console.log('init1', this.value()); console.log(inject(ElementRef)); // callback 最好是 injection context, 可以直接调用 inject 函数会比较方便 }); onInit(this, () => { console.log('init2', this.value()); }); } }
好,我们来具体实现 onInit 函数
function onInit( componentInstance: Record<PropertyKey, any>, callback: CallBack ) { setupTViewPreOrderHooks(); setupPrototype(); saveCallback(); }
有三大步骤:
第一步是把 TNode.index push 到 TView.preOrderHooks 里。
function setupTViewPreOrderHooks() { // 1. 这里是 LView 中的 index 引用 const TVIEW = 1; const PARENT = 3; // 2. 首先,我们要拿到 TNode index // 以这个例子来说的话 // app.component.html // <app-hello-world [value]="value()" appDir1 /> // HelloWorld 组件和 dir1 指令的 TNode 是同一个 <app-hello-world> // TNode index 指的是这个 <app-hello-world> 在 App LView 里的 index // 它是第一个 element,所以 index 是 25 咯。 // 我们透过 ViewContainerRef 拿到当前的 TNode,然后再拿它的 index // 提醒: // 不要使用 ChangeDetectorRef['_lView'][T_Host 5].index 去拿 // 因为指令和组件拿的 ChangeDetectorRef['_lView'] 是不同逻辑,很混乱的。 // 用 ViewContainerRef 就对了 const viewContainerRef = inject(ViewContainerRef) as any; const hostTNode = viewContainerRef['_hostTNode']; const tNodeIndex = hostTNode.index; // 3. 接下来要拿到 TView.preOrderHooks // 同样的,不要试图用 ChangeDetectorRef['_lView'][TView 1] 去拿,不准的 // 用 ViewContainerRef 就对了 const lContainer = viewContainerRef['_lContainer']; const targetLView = lContainer[PARENT]; const targetTView = targetLView[TVIEW]; // 4. 如果 preOrderHooks 是 null 创建一个 array 把 TNode index 传进去给它 if (!targetTView.preOrderHooks) { targetTView.preOrderHooks = [tNodeIndex]; return; } // 5. 如果 preOrderHooks 里还没有这个 TNode index 就 push 进去,有了就 skip if(!targetTView.preOrderHooks.includes(tNodeIndex)) { targetTView.preOrderHooks.push(tNodeIndex); } }
主要是依赖 ViewContainerRef 获取到准确的 TNode index 和 TView,至于它是不是真的拿的那么准,我也不好说,
但基本的组件,指令,@if,@for,ng-template, ngTemplateOutlet 我都测试过,拿的还挺准的。
第二步是添加 ngOnInit 方法到 HelloWorld.prototype
function setupPrototype() { const prototype = Object.getPrototypeOf(componentInstance); if (prototype['ngOnInit'] === undefined) { prototype['ngOnInit'] = StgOnInit; } }
Stg 是我的 library 名字缩写。StgOnInit 是一个通用的 ngOnInit 方法,下面我会展开。
HelloWorld.prototype 有了 ngOnInit 方法,Angular 就会 register pre order hooks 了。
第三步是把 on init callback 保存起来,还有 injector 也保存起来 (调用 callback 时,需要用 injector 来创建 injection context)。
const ON_INIT_CALLBACKS_PROPERTY_NAME = '__stgOnInitCallbacks__'; const INJECTOR_PROPERTY_NAME = '__stgInjector__'; function saveCallback() { const callbacks = componentInstance[ON_INIT_CALLBACKS_PROPERTY_NAME] ?? []; Object.defineProperty(componentInstance, ON_INIT_CALLBACKS_PROPERTY_NAME, { configurable: true, value: [...callbacks, callback], }); if (componentInstance[INJECTOR_PROPERTY_NAME] === undefined) { const injector = inject(Injector); Object.defineProperty(componentInstance, INJECTOR_PROPERTY_NAME, { value: injector, }); } }
把它们保存在组件实例里就可以了。但要记得 enumerable: false 哦。
最后是通用的 ngOnInit 函数
function StgOnInit(this: { [ON_INIT_CALLBACKS_PROPERTY_NAME]: CallBack[]; [INJECTOR_PROPERTY_NAME]: Injector; }) { const callbacks = this[ON_INIT_CALLBACKS_PROPERTY_NAME]; const injector = this[INJECTOR_PROPERTY_NAME]; runInInjectionContext(injector, () => { for (const callback of callbacks) { callback(); } }); }
这个函数被赋值到 HelloWorld.prototype['ngOnInit'],lifecycle 时会被 Angular 调用。
this 指向组件实例。
我们只要从组件实例拿出 callback 和 injector 然后 for loop 执行就可以了。
步骤 1,2的替代方案
步骤 1,2 用到了黑科技,风险比较大,如果我们担心随时翻车,那可以用另一个比较笨拙的方法 -- 手动添加 prototype。
export class Dir1Directive { constructor() { onInit(this, () => console.log('dir1 init')); } } (Dir1Directive.prototype as any).ngOnInit = StgOnInit;
步骤 1,2 主要就是搞 prototype,如果我们改成手动添加,就可以避开黑科技了。当然代价就是代码超级丑。
总结
完整代码
type CallBack = () => void; function StgOnInit(this: { [ON_INIT_CALLBACKS_PROPERTY_NAME]: CallBack[]; [INJECTOR_PROPERTY_NAME]: Injector; }) { const callbacks = this[ON_INIT_CALLBACKS_PROPERTY_NAME]; const injector = this[INJECTOR_PROPERTY_NAME]; runInInjectionContext(injector, () => { for (const callback of callbacks) { callback(); } }); } const ON_INIT_CALLBACKS_PROPERTY_NAME = '__stgOnInitCallbacks__'; const INJECTOR_PROPERTY_NAME = '__stgInjector__'; function onInit( componentInstance: Record<PropertyKey, any>, callback: CallBack ) { setupTViewPreOrderHooks(); setupPrototype(); saveCallback(); function setupTViewPreOrderHooks() { // 1. 这里是 LView 中的 index 引用 const TVIEW = 1; const PARENT = 3; // 2. 首先,我们要拿到 TNode index // 以这个例子来说的话 // app.component.html // <app-hello-world [value]="value()" appDir1 /> // HelloWorld 组件和 dir1 指令的 TNode 是同一个 <app-hello-world> // TNode index 指的是这个 <app-hello-world> 在 App LView 里的 index // 它是第一个 element,所以 index 是 25 咯。 // 我们透过 ViewContainerRef 拿到当前的 TNode,然后再拿它的 index // 提醒: // 不要使用 ChangeDetectorRef['_lView'][T_Host 5].index 去拿 // 因为指令和组件拿的 ChangeDetectorRef['_lView'] 是不同逻辑,很混乱的。 // 用 ViewContainerRef 就对了 const viewContainerRef = inject(ViewContainerRef) as any; const hostTNode = viewContainerRef['_hostTNode']; const tNodeIndex = hostTNode.index; // 3. 接下来要拿到 TView.preOrderHooks // 同样的,不要试图用 ChangeDetectorRef['_lView'][TView 1] 去拿,不准的 // 用 ViewContainerRef 就对了 const lContainer = viewContainerRef['_lContainer']; const targetLView = lContainer[PARENT]; const targetTView = targetLView[TVIEW]; // 4. 如果 preOrderHooks 是 null 创建一个 array 把 TNode index 传进去给它 if (!targetTView.preOrderHooks) { targetTView.preOrderHooks = [tNodeIndex]; return; } // 5. 如果 preOrderHooks 里还没有这个 TNode index 就 push 进去,有了就 skip if(!targetTView.preOrderHooks.includes(tNodeIndex)) { targetTView.preOrderHooks.push(tNodeIndex); } } function setupPrototype() { const prototype = Object.getPrototypeOf(componentInstance); if (prototype['ngOnInit'] === undefined) { prototype['ngOnInit'] = StgOnInit; } } function saveCallback() { const callbacks = componentInstance[ON_INIT_CALLBACKS_PROPERTY_NAME] ?? []; Object.defineProperty(componentInstance, ON_INIT_CALLBACKS_PROPERTY_NAME, { configurable: true, value: [...callbacks, callback], }); if (componentInstance[INJECTOR_PROPERTY_NAME] === undefined) { const injector = inject(Injector); Object.defineProperty(componentInstance, INJECTOR_PROPERTY_NAME, { value: injector, }); } } } @Component({ selector: 'app-hello-world', standalone: true, imports: [], templateUrl: './hello-world.component.html', styleUrl: './hello-world.component.scss', }) export class HelloWorldComponent { readonly value = input.required<string>(); constructor() { onInit(this, () => console.log('HelloWorld 组件', this.value())) } }
以上就是 Signal-based 风格的 ngOnInit。
一时心血来潮写的,没有经过严格测试,我们拿来研究学习就好,不要乱用哦。
最后,还是希望 Angular 团队能提供一个 Signal-based 的 ngOnInit,就目前这个状态,我真的觉得 ngOnInit 和大伙儿 (effect, DetroyedRef, AfterNextRender) 格格不入🙄
Signal, immutable, immer
上面我们有提到,Signal 最好是搭配 immutable 使用。为什么呢?
这里给个例子说明一下。
App Template
<p>outside hi, {{ person().firstName }}</p> <app-hello-world [person]="person()" /> <button (click)="changeFirstName()">change first name</button>
HelloWorld Template
<p>inside hi, {{ person().firstName }}</p>
一个 outside say hi binding,一个 inside say hi binding。
当点击 chang first name button 后,我们看看 outside 和 inside 的 first name binding 是否都会刷新。
App 组件
export class AppComponent { person = signal<Person>({ firstName: 'Derrick', lastName: 'Yam', age: 10 }); changeFirstName() { // 1. 这里没有使用 immutable this.person.update(person => { person.firstName = 'alex'; return person; }); } }
HelloWorld 组件
@Component({ selector: 'app-hello-world', standalone: true, imports: [], templateUrl: './hello-world.component.html', styleUrl: './hello-world.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) export class HelloWorldComponent { readonly person = input.required<Person>(); }
效果
inside binding 没有刷新🤔,为什么呢?
我们在 App 组件里加入一个 effect 监听 person
constructor() { effect(() => console.log(this.person())); }
点击 change first name 以后,effect callback 并没有触发。
因为 Signal 的变更检查方式是 ===,只要 person 对象引用没有换,单单属性值换是不算变更的。
所以 HelloWorld 不会触发 refreshView (因为没有 detect 到 person 变更)。
而 App 组件之所以会触发 refreshView,也不是因为它 detect 到了 person 变更,而是因为它 detect 到了 button click 事件,所以才 refreshView (这是 Change Detection 的另一个浅规则)
immutable
好,那我们把它改成 immutable。
changeFirstName() { // 1. 这里使用 immutable this.person.set({ ...this.person(), firstName: 'alex' }) }
效果
ouside, inside 都会 refreshView 了,因为它们都 detect 到了 person 变更。
immutable 常见写法
immutable 的最大优势是能快速判断引用是否变更,但代价就是改的时候不好写。
改属性值
const person = { name: 'Derrick', age: 11 }; const newPerson = { ...person, age: 12 }; // { name: 'Derrick', age: 12 } // person 的引用换了
remove 属性
const person = { firstName: 'Derrick', age: 11 }; const { firstName, ...newPerson } = person; // 利用解构 console.log(newPerson); // { "age": 11 }
remove 属性 by string
const person = { firstName: 'Derrick', age: 11 }; const keyToRemove = 'firstName'; const { [keyToRemove]: _, ...newPerson } = person; // 利用解构 console.log(newPerson); // { "age": 11 }
push to array
const people = [{ name: 'Derrick', age: 11 }]; const newPeople = [ ...people, { name: 'Alex', age: 13 } ]; // [{ name: 'Derrick', age: 11 }] // people array 和 person 对象的引用都换了
insert to array
const people = [{ name: 'Derrick', age: 11 }]; const newPerson = { name: 'Alex', age: 13 }; const index = 0; const newPeople = [...people.slice(0, index), newPerson, ...people.slice(index)]; console.log(newPeople); // [{ name: 'Alex', age: 13 }, { name: 'Derrick', age: 11 }]
index negative 也支持哦,行为和 splice 一致。
remove from array
const people = [{ name: 'Derrick', age: 11 }]; const newPeople = people.filter(person => person.age === 11); // [] // people array 的引用换了 // 再一个 index 的例子 const values = ['a', 'b', 'c', 'd', 'e']; const index = values.indexOf('c'); const newValues = index === -1 ? values : [...values.slice(0, index), ...values.slice(index + 1)]; // ['a', 'b', 'd', 'e']
上面这几个简单的还能接受,如果遇到嵌套的,那就会变得非常的乱。
remove at index
const people = [{ name: 'Alex', age: 13 }, { name: 'Derrick', age: 11 }, { name: 'David', age: 18 }]; const index = 1; const newPeople = [...people.slice(0, index), ...people.slice(index + 1)]; console.log(newPeople); // [{ name: 'Alex', age: 13 }, { name: 'David', age: 18 }]
上面这段不支持 negative index,如果要支持 negative 像 splice 那样,需要加入一些 formula,我的建议是用 clone array + splice 会更简单。
const people = [{ name: 'Alex', age: 13 }, { name: 'Derrick', age: 11 }, { name: 'David', age: 18 }]; const index = -1; const newPeople = [...people]; // clone newPeople.splice(index, 1); // mutate console.log(newPeople); // [{ name: 'Alex', age: 13 }, { name: 'Derrick', age: 11 }]
immer
为了享受 immutable 的好处,又不付出代价,最好的选择就是引入 immer library。
yarn add immer
它的使用方法非常简单
changeFirstName() { const newPerson = produce(this.person(), daraftPerson => { draftPerson.firstName = 'alex' }); // peson 对象的引用换了 this.person.set(newPerson); }
调用 produce 函数,把 oldPerson 传进去,然后修改 draftPerson,最后它会返回一个 newPerson。
这个 draftPerson 是一个 Proxy 对象,我们修改它不需要使用 immutable 的手法,把它当作 mutable 对象来修改就可以了 (嵌套也没有问题),
immer 会负责监听 Proxy 然后在背地里制作出 newPerson。
另外,immer 修改的时候是很细腻的
const oldPerson = { childA : { age: 11 }, childB: { age : 12 } } const newPerson = produce(oldPerson, draftPerson => { draftPerson.childB.age = 13 }); console.log(newPerson === oldPerson); // false console.log(newPerson.childA === oldPerson.childA); // true console.log(newPerson.childB === oldPerson.childB); // false
上面只改了 childB,所以只有 childB 和 person 对象变更了,而 childA 依然是同一个引用。
还有
draftPerson.childB.age = 12; // assign 回同样的值
虽然有 assign 的动作,但是值没有换,最终也不会有变更
console.log(newPerson === oldPerson); // true console.log(newPerson.childA === oldPerson.childA); // true console.log(newPerson.childB === oldPerson.childB); // true
immer 的局限
像 immer 这种背地里搞东搞西的技术 (类似的还有 Zone.js),通常都会有一些 limitation,这里记入一些我遇到过的。
use immer for class instance
上面的例子都是用 pure object,这里我们试试 class instance
class Person { constructor(firstName: string) { console.log('person constructor'); this.firstName = firstName; } firstName: string; } const oldPerson = new Person('Derrick'); const newPerson = produce(oldPerson, draftPerson => { draftPerson.firstName = 'Alex'; }); console.log('newPerson', newPerson);
效果
报错了,信息上说要加上 [immerable]
class Person { [immerable] = true; }
效果
可以了,但有一点要注意,person constructor 只触发了一次
由 produce 创建出来的 newPerson 是不会执行 constructor 函数的。
lost array properties
const oldValues: string[] & { hiddenValue?: string } = []; oldValues['hiddenValue'] = 'value'; const newValues = produce(oldValues, draftValues => { draftValues.push('value'); }); console.log(newValues['hiddenValue']); // undefined
假如 array 有特别的属性 (虽然很罕见),produce 生成的 newValues 会丢失原本 array 里的属性。
only proxy object and array
const oldPerson = { dateOfBirth : new Date(2021, 0, 1) } const newPerson = produce(oldPerson, draftPerson => { draftPerson.dateOfBirth.setFullYear(2022); }); console.log(newPerson === oldPerson); // true
只有 object 和 array 会被 proxy,像 Date 是不会被 Proxy 的,我们要修改 Date 就必须用 immutable 的手法。
总结
immutable 有它的好处也有它的代价,我个人的使用经验是,但凡 Signal 还是尽量搭配 immutable 会比较好。
Signal 的小烦恼😌
Signal 是很好,但也有它的代价。
无法 JSON.stringify
signal 是 function,在 to json 时会自动被过滤掉。
const person = { firstName: signal('Derrick'), lastName: signal('Yam'), fullName: computed((): string => person.firstName() + ' ' + person.lastName()), child: signal({ age: 11 }), }; console.log(JSON.stringify(person)); // {} emtpty object
如果我们希望它输出正确的值,那就需要提供一个 replacer。
console.log(JSON.stringify(person, (_key, value: unknown) => (isSignal(value) ? value() : value))); // {"firstName":"Derrick","lastName":"Yam","fullName":"Derrick Yam","child":{"age":11}}
参数二是 replacer,判断 value 是否是 Signal,如果是就调用它获取值,这样就可以了。
注:isSignal 是 Angular built-in 函数。
讨人厌的方法调用
Signal 是函数,想获取值就必须调用它
export class AppComponent { readonly disabled = signal(true); constructor() { effect(() => console.log(this.disabled())); // true; } }
像 this.disabled() 这样。
在 Template 也是一样
@if (disabled()) { <h1>show on disabled</h1> }
假如我们不小心忘了写括弧,那就掉坑里了
@if (!disabled) { <h1>show when non disabled</h1> } constructor() { if (!this.disabled) { console.log('call when non disabled'); } }
disable 是函数,!disabled 永远会返回 false,所以上面的 h1 不管 signal disabled value 是 true 还是 false 都不可能出现,console 也绝对不会 call。
假如我们真的很不喜欢方法调用,那可以 wrap 一层 getter setter
export class AppComponent { readonly #_disabled = signal(false); get disabled() { return this.#_disabled(); } set disabled(value) { this.#_disabled.set(value); } constructor() { console.log(this.disabled); // false effect(() => console.log(this.disabled)); // will call on first time and future disabled changes if (!this.disabled) { console.log('non disable'); // called } window.setTimeout(() => { this.disabled = true; // will trigger effect call }, 1000); } }
signal 的功能完全一样,依然可以用 effect 监听,变更时也会 refreshView,通通都没问题。
Should I use Signal in everywhere?
为了 Zoneless Change Detection,所有 ViewModel 都必须使用 Signal 是一定的了。
另外,input, output, model, viewChild, contentChild 也一定是 Signal。
那其它地方有必要统一使用 Signal 吗?
我个人觉得没有必要为了统一而统一,Signal 有 2 个好处是我会考虑用的
-
computed variable
const values = [1, 3, 5, 7]; const getTotalValue = () => values.reduce((r, v) => r + v, 0);
像这种 computed 场景,我觉得换成 Signal 会更好。
const values = signal([1, 3, 5, 7]); const totalValue = computed(() => values().reduce((r, v) => r + v, 0));
-
passing writeable variable
function rename(name: string) { name = 'new value'; } const name = 'Derrick'; rename(name);
name 这个 variable 被传入到函数里就断链接了,在函数内修改 name 并不会修改到外部的 name。
要嘛,我们做一个 return 变纯函数,要嘛我们 wrap 一个对象传进去。
而 Signal 正好可以充当这个 wrapper 对象,而且它仅仅只是 wrap 了这一个 variable。
另外,Signal 的变更监听是依赖 DI Injector 的,当遇到没有 injector 的情况,或许用 RxJS 会更方便一些。
目前我还没用体会到其它适合用 Signal 的地方,以后有我会再朴上。happy coding 💻😊
目录
上一篇 Angular 18+ 高级教程 – Change Detection
下一篇 Angular 18+ 高级教程 – Component 组件 の Dependency Injection & NodeInjector
想查看目录,请移步 Angular 18+ 高级教程 – 目录
喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding 😊💻