- A+
前言
Angular 从 v14 开始大改特改,改最多的就是编码风格。
以前是 class first,Decorator first,mutable first。
现在变成了 function first,immutable first。
本篇主要是探讨 v14 后,尤其是 Signal-based 后的 Angular 编码风格,看看怎么写会比较合理舒服?。
注:只是探讨而已,不定义好与坏。
readonly property?
export class AppComponent { private readonly renderer = inject(Renderer2); readonly value = signal(0); }
上面的 inject 和 signal 你觉得有必要加上 readonly 吗?
readonly 的意思是,这个属性只能在 constructor 阶段被赋值,过了这个阶段就不可以赋值了。
比如说
export class AppComponent implements OnInit { private readonly renderer = inject(Renderer2); readonly value = signal(0); constructor() { this.value = signal(100); // 这个是 ok 的 } ngOnInit() { this.value = signal(100); // 这是不 ok 的,会报错 this.value.set(100); // 这是 ok 的 } }
理由
inject property 确实不太可能再赋值了,说它是 readonly 不过分,合理。
signal property 也同样不太可能被赋值,因为 signal 赋值是 value.set 而不是 value =,所以用 readonly 也合理。
市场
虽然写 readonly 合理,但这也不代表我们就需要或应该这样写。
我们还得看看市场有没有人这样写。
翻一翻 Angular 源码
哎哟,确实是有写 readonly 哦。
但是!
也是有没写 readonly 的?。没写 readonly 并不是说它之后会再赋值,它就只是没有写 readonly 而已。
在看 Angular Material 源码也差不多一个样,有些有写,有些没写。
那 signal 呢?
不好意思啊,Angular 和 Angular Material 源码中都还没有使用到这么新潮流的 signal...WHAT??!
但是 Angular Material 在使用 RxJS Subject 时,确实有蛮多是添加 readonly 的
正方观点 -- 写 readonly
质问:既然有理有据,为什么不写?
我写,你绝对不可能说我错,挺多说没有必要。
有写就多一层保护,你难保哪天有个新手动你的代码时,一不小心就给 readonly property 赋值了。
反方观点 -- 不写 readonly
反问:有什么 property 是不需要写 readonly 的?
一个 property 如果它不可变,那需要写 readonly。
一个 property 如果可变,那我们用 signal,signal 也需要写 readonly。
所以不管可变,还是不可变,最终都需要写 readonly。
既然一定要写 readonly 那又何必写 readonly 呢?哲理?
readonly 和 let const 不同,let cost 是换一个 keyword,readonly 是增加多一个 keyword,越多代码看上去就越乱,所以没有必要就不应该增加代码量。
结论
最终还是回归到个人代码管理方式和喜好上。
如果你想特别严谨,那就 readonly first,就像 const first 一样,只要写 property 那就先加上 readonly,直到你赋值它 error 的时候,你再思考是否合理。
如果你不喜欢一堆的 readonly,那你要习惯不给 property 赋值,习惯 signal first,这样就不容易出 bug 了。
private property?
上一 part 探讨 readonly 的同时,我们也看见了 private property。
几乎所有的 inject property 都设置了 private。
在 Angular MVVM,组件实例代表 view model,它的属性和方法通常是用作于 template binding。
而 inject property 通常存放的是 Service,而这些 Service 通常也不会直接用作 template binding,所以设置成 private 还算是挺合理的。
那问题来了:
export class AppComponent { private readonly value = input.required<string>(); private readonly templateRef = contentChild.required(TemplateRef); private readonly viewContainerRef = viewChild.required('container', { read: ViewContainerRef }); }
input, contentChild, viewChild 如果没有被直接用于 template binding,也需要加上 private 吗?
首先,input 绝对不可以设置 private
会直接报错。因为 input 会直接用作 template binding,所以当然不应该是 private。
至于 contentChild 和 viewChild 则都可以设置 private。
结论:但凡没有被 template binding 的属性,都可以设置成 private。
市场
可以不代表有必要。Angular Material 目前没有使用 signal-based query,所以只能参考它 old school 写法。
@ViewChild 属性不是 private。
private property 利与弊
class private property 最大的好处是当我们在使用 class 实例时,private property 不可见。
有两个场景我们会使用到组件实例:
-
template binding
这也是绝大部分的场景
-
query 组件实例,调用属性或方法
这个场景也会发生,只是比较少
如果我们不希望在以上两个场景中,调用组件实例时看见某些属性和方法,那我们就需要设置 private。
比如
没有 private 的情况下有 4 个选择
加上 private 后只剩下 2 个选择,这样使用起来就更干净
private 的代价和 readonly 一样,就是多了代码量。
结论
和 readonly 一样,最终还是回归到个人代码管理方式和喜好上。
如果你想特别严谨,那就 private first,需要用到属性时才解除 private。
如果你不喜欢一堆的 private,那就在使用组件实例时小心一点,不要选错属性就可以了。
inject,Injector.get,runInInjectionContext?
inject,Injector.get,runInInjectionContext 用哪一个比较好?
inject 的劣势
inject 函数有一个 injection context 的局限。它非常的烦人。
export class AppComponent { constructor() { inject(Renderer2); // it work } doSomething() { inject(Renderer2); // Error! } }
constructor 内可以使用 inject,doSomething 方法内不可以使用 inject。
另外,如果 constructor 里 wrap 了一层
constructor() { afterNextRender(() => { inject(Renderer2); // Error! }); }
那它就变成不能使用 inject 了。
Injector.get 的优势
只要有 injector 对象在手,到哪里都一定可以注入。
export class AppComponent { private injector = inject(Injector); constructor() { this.injector.get(Renderer2); // it work queueMicrotask(() => { this.injector.get(Renderer2); // it work }); } doSomething() { this.injector.get(Renderer2); // it work } }
Injector.get 劣势 vs inject 优势
如果有一个在组件外的函数,它需要注入 Renderer2。
使用 Injector.get 的话,我们必须把 Injector pass 进去函数里。
假如使用 inject 函数就不需要 passing。
early inject vs lazy inject
假如我们需要在 afterNextRender 里注入使用 Renderer2。
constructor() { afterNextRender(() => { const renderer2 = inject(Renderer2); // Error! console.log(renderer2); // use renderer2 }); }
after render callback 里是不能使用 inject 函数的。
我们可以提早 inject,像这样
constructor() { const renderer2 = inject(Renderer2); // inject early afterNextRender(() => { console.log(renderer2); // use renderer2 }); }
虽然是破解了,但是这样就变成 early inject,不是 lazy inject 了。
试想想,本来在 SSR (Server-Side Rendering) 的环节,after render callback 是不执行的,所以也不会 inject Renderer2。
但由于我们改成了 early inject,变成即使是 SSR (Server-Side Rendering) 环节,它也一定会去 inject Renderer2。
inject 不到的地方用 injector.get 做替代
为了避免 early inject,我们唯一的方法就是用 injector.get 来替代 inject。
constructor() { const injector = inject(Injector); // 这里可以用 inject 函数 afterNextRender(() => { const renderer2 = injector.get(Renderer2); // 这里用不到 inject 函数,就改用 injector.get console.log(renderer2); // use renderer2 }); }
runInInjectionContext 的优势与劣势
虽然 injector.get 可以替代 inject,但是 injector.get 也有它自己的劣势。
但遇上这种情况时,我们可以 wrap 一层 runInInjectionContext,让它去替代 Injector.get。
runInInjectionContext 的劣势是嵌套很难看。
export class AppComponent { private injector = inject(Injector); doSomething() { runInInjectionContext(this.injector, () => { inject(Renderer2); // it work queueMicrotask(() => { inject(Renderer2); // Error again! }); }); } }
而且,假如它里面又 wrap 一层,inject 又不能用了,你还得再 wrap 一层 runInInjectionContext...
结论
可以看到各个都有优势和劣势,我感觉有两条路可以走:
-
everything inject first,inject 不到的地方用 injector.get 来代替,injector.get 不灵时就 wrap runInInjectionContext。
-
everything injector.get,统一,真的没办法 (比如调用组件外的函数,它里面需要注入,但又不想 passing injector) 才 wrap 一层 runInInjectionContext 使用 inject。
constructor, OnInit, ContentHooks, ViewHooks, AfterRenderHooks, effect, ngOnDestroy, DestroyRef
听说 Signal-based 以后,AfterContentInit,AfterViewInit,ngOnChanges 都会被废弃,还有 ngOnDestroy 也岌岌可危。
取而代之的是新潮流 effect,AfterNextRender 和 DestroyRef。
AfterRenderHooks vs ViewHooks
以前讲过了,这里不再复述。
ngOnDestroy vs DestroyRef
以前讲过了,这里不再复述。
组件 class 新结构
old school 的写法
export class AppComponent implements AfterViewInit, OnDestroy { ngAfterViewInit() { console.log(); } ngOnDestroy() { console.log(); } }
Lifecycle hooks 是通过 class methods 来表述。
新潮流的写法
export class AppComponent { constructor() { afterNextRender(() => console.log()); inject(DestroyRef).onDestroy(() => console.log()); } }
最大的区别是,代码都跑到 constructor 里去了。
在 Signal-based 后,组件 class 结构有了一些新 pattern。
export class AppComponent { constructor() { // lifecycle hooks afterNextRender(() => console.log()); inject(DestroyRef).onDestroy(() => console.log()); } value = signal(0); // use for template binding doSomething() {} // use for template binding }
组件 class 的属性和方法专注服务于 template binding,而 lifecycle hooks 则包裹在 constructor 里。
虽然这样没有 follow 原生的 Custom Element,但我个人感觉,这样是比较合理的。
因为除了框架,没有人会去调用 AppComponent.ngAfterViewInit() 或 ngOnDestroy()。
强迫使用 class implements + public method 去定义只有框架才会调用的 lifecycle hooks,这对其它使用组件的人就是一种干扰。
effect 取代 ngOnChanges, ContentHooks, ViewHooks
Signal based 以后,@Input,@ContentChild, @ViewChild 都变成了 Signal。
export class AppComponent { value = input.required<string>(); contentTemplate = contentChild.required(TemplateRef); viewContainer = viewChild.required('container', { read: ViewContainerRef }); }
要监听 Signal 变更就得使用 effect,要使用 effect 就要在 injection context 里。
组件 class constructor 是天然的 injection context
constructor() { effect(() => { console.log(this.value()); console.log(this.contentTemplate()); console.log(this.viewContainer()); }); }
于是,代码又都跑到 constructor 里去了。这样也挺好,大家都集合在 constructor 里,让 class 属性和方法专注服务于 template binding 就好。
无可替代的 OnInit?
我给一个简单的例子
export class AppComponent implements OnInit { loading = signal(false); datas = signal<string[]>([]); private injector = inject(Injector); async ngOnInit() { this.loading.set(true); const httpClient = this.injector.get(HttpClient); this.datas.set(await firstValueFrom(httpClient.get<string[]>(''))); this.loading.set(false); } }
三个动作,show loading,ajax datas, hide loading and show datas。
我们尝试不使用 ngOnInit,用其它方式代替 OnInit,看看会长啥样。
用 constructor 代替 OnInit
constructor() { this.loading.set(true); const httpClient = inject(HttpClient); httpClient.get<string[]>('').subscribe(datas => { this.datas.set(datas); this.loading.set(false); }); }
由于 constructor 不支持 async await 语法,所以只能用 subscribe 替代 await。
或者 wrap 一层 IIFE (Immediately invoked function expression)
constructor() { (async () => { this.loading.set(true); const httpClient = inject(HttpClient); this.datas.set(await firstValueFrom(httpClient.get<string[]>(''))); this.loading.set(false); })(); }
没有 async await 是硬伤,两个 workaround 都不理想。
另外,constructor 阶段执行的太早,此时 @Input 的 value 都还没有被赋值,如果 ajax 依赖 @Input value 那更不可能用 constructor 了。
AfterRenderHooks 代替 OnInit
SSR (Server-Side Rendering) 环境下不执行 AfterRenderHooks,这和 OnInit 逻辑上就不一样了,完全不用考虑这个方案。
effect 代替 OnInit
effect 的触发时机非常的晚,比 AfterRenderHooks 还晚,也就是说所有 LView 都已经执行完 renderView 和 refreshView 了,这时你说要来 show loading?
而且 effect 内也不鼓励再修改 signal (虽然它可以,但不鼓励),所以 effect 视乎也不合适。
Oninit is the best choice
constructor 太早,又不支持 async await,不好。
effect 太晚,又不鼓励修改 Signal,虽然勉强可以,但还是不好。
Oninit 时机刚好,它在 @Input 赋值后就立刻执行,也支持 async await,也可以改 Signal。
唯一的缺陷是,如果依赖 @Input 并且需要监听后续的变更,那最终我们还是得用上 effect。
All in constructor 后遗症
forgot out of injection context
当你写多了 constructor 内的代码,你的潜意识会认为总是可以直接使用 inject 和 effect,因为 constructor 是 injection context。
但是,一旦你脱离 constructor,而你的潜意识没有脱离的话,就会报错了?
讨厌 this
当你写多了 constructor 内的代码,你会开始讨厌 this,你会觉得它很多余。
从前
export class AppComponent implements AfterContentInit, AfterViewInit { private sharedMethod() { console.log(); } ngAfterContentInit() { this.sharedMethod(); } ngAfterViewInit() { this.sharedMethod(); } }
如今
constructor() { const sharedMethod = () => console.log(); afterNextRender(() => sharedMethod()); effect(() => sharedMethod()); }
因为 all in constructor,所以 no need implements, no need private, no need this。
如果你不喜欢 variable + arrow function,也可以改成下面这样
export class AppComponent { constructor() { const that = this; afterNextRender(() => sharedMethod()); effect(() => sharedMethod()); function sharedMethod() { console.log(that); // can use 'this' here } } }
不过,如果 sharedMethod 需要用到 this,那就要偷龙转风成 'that' 了。(注:ESLint 要 by pass 哦)
讨厌 this 2.0
除了 all in constructor 不需要 this 以外,Signal 也让我们可以减少 this 的使用。
从前
export class AppComponent { firstName = 'Derrick'; lastName = 'Yam'; age = 11; doSomething() { console.log(this.firstName); console.log(this.lastName); console.log(this.age); this.firstName = ''; this.lastName = ''; this.age = 0; } }
一堆 this dot,看得都烦。
我们可以用 Object Destructuring Assignment 减少 this
但遇到值类型属性要赋值,这招就不灵了。
换成 Signal 以后,哎哟,它就可以了。
export class AppComponent { firstName = signal('Derrick'); lastName = signal('Yam'); age = signal(11); doSomething() { const { firstName, lastName, age } = this; console.log(firstName()); console.log(lastName()); console.log(age()); firstName.set(''); lastName.set(''); age.set(0); } }
因为 Signal 是对象嘛,不是值类型。
虽然 Signal 的 getter setter 也很丑,但是它把很丑的 this 换掉了,也算将功补过吧。
MVVM vs DOM Manipulation
MVVM 指的是利用 Template Binding Syntax 去 add event listener 和修改 DOM property。
DOM Manipulation 指的是用原生 DOM API add event listener 和修改 DOM property。
Angular 肯定是鼓励我们多使用 MVVM 远离 DOM Manipulation 的。
但有时候使用 DOM Manipulation 真的会比 MVVM 来的简单直观,尤其是在开发 UI 组件库的时候。
MVVM 的优势
<button (click)="doSomething()">click me</button>
这种 template binding 是最简单直观的。一个点击事件,一个方法调用。
方面名字就说明了点击后会做些什么。
MVVM 的局限
如果每一个都是这么简单,那用 MVVM 就够了。但可惜,现实就不是这样。
再一个搜索的例子
<input #input (input)="search(input.value)">
input 之后要做 searching,上面这样写没有问题。
但如果要求先 debounce time 500ms 才做 searching 呢?
<input #input (input)="debounceTime(500); search(input.value)">
上面这样写是不成立的。
要像下面这样才可以
<input #input (input)="debounceSearch(input.value)">
export class AppComponent { private debounceTime = 500; private timeout: number | null = null;
debounceSearch(value: string) { if (this.timeout) window.clearTimeout(this.timeout); this.timeout = window.setTimeout(() => { console.log('search', value); this.timeout = null; }, this.debounceTime); } }
虽然实现了,但是没有利用上 RxJS,代码量有点多。
我们可以换成这样
<input #input (input)="inputSubject.next(input.value)">
export class AppComponent { readonly inputSubject = new Subject<string>(); private search(searchText: string) { console.log(searchText); } constructor() { this.inputSubject.pipe(debounceTime(500)).subscribe(searchText => this.search(searchText)); } }
为了使用 RxJS debounceTime,我们需要把 MVVM 事件监听 convert to RxJS Observable。
Subject 上面扮演的角色是一个 Proxy,模板 (input) 监听事件后通过 Subject.next 做转发。
组件内通过 Subject.subscribe 做监听。整个代码看上去就不太高雅。而且换成这个写法后,模板上就不再表述 input 事件后的处理方式了。
DOM Manipulation replace MVVM
既然 MVVM 和 RxJS 八字不合,那倒不如直接用 DOM Manipulation。
export class AppComponent { private readonly input = viewChild.required<string, ElementRef<HTMLInputElement>>('input', { read: ElementRef }); constructor() { const injector = inject(Injector); afterNextRender(() => { const search = (searchText: string) => console.log('search', searchText); const destroyRef = injector.get(DestroyRef); const input = this.input().nativeElement; const input$ = fromEvent(input, 'input').pipe(debounceTime(500)); input$.pipe(takeUntilDestroyed(destroyRef)).subscribe(() => search(input.value)); }); } }
query element + add event listener,非常传统的方式。
用 DOM Manipulation 方式需要注意几个点:
-
viewChild 的 input 最少要在 OnInit 之后才能使用
-
add event listener 最好是放在 after next render callback 里
-
当组件销毁时记得 remove event listener
-
如果想使用 Angular 扩展的 add event listener 语法,比如 keydown.enter,那我们需要配上 Renderer2.listen。
-
如果 input 是在 ng-template 里,那我们还需要监听 input signal
市场
参考 Angular Material 源码,真的是五花八门。
MVVM binding 的方法有些有声明事件后的处理方式,有些没有。
直接使用 RxJS fromEvent 对 element 做事件监听。
用 Subject 作为 Proxy 转发事件。
总结
个人感受,做业务项目,尽可能使用 MVVM,省事。
做 UI 组件库,优先使用 MVVM,但如果感觉不适,那果断换成 DOM Manipulation。
提醒:用 DOM Manipulation 要注意上面提到的几个事项哦。
Partial Options
Partial Options 不仅仅在 Angular,任何地方都很常见。
A simple example
有一个 function
function doSomething () {}
这个 function 需要一些 options
function doSomething ( options: { disabled: boolean, prefix: string; useComponent: boolean } ) {}
假如这些 options 都是 optional 的,那我们会这么写
function doSomething ( options?: { disabled?: boolean, prefix?: string; useComponent?: boolean } // all optional ) { const { disabled = false, prefix = 'stg', useComponent = false } = options ?? {}; // set default options console.log([disabled, prefix, useComponent]); // use options }
如果某一些是 required,那我们会这么写
function doSomething ( options: { disabled?: boolean, prefix: string; useComponent?: boolean } // prefix is required ) { const { disabled = false, prefix, useComponent = false } = options; // set default options except prefix console.log([disabled, prefix, useComponent]); // use options } doSomething({ prefix: 'stg' });
如果 options 不多,而且不需要 pass 来 pass 去,上面这样写就已经很 ok 了。
但如果 options 很多,或者要 pass 来 pass 去,上面这样写就不太灵活。
Angular Material partial options?
我们参考 Angular Material 怎么写。
下图是 Angular Material Dialog open 方法,源码在 dialog.ts
注:Config 和 Options 这里我们不区分词汇意义,我们把它们当同义词看待就好了。
由于 Config 内有太多的属性,所以 Angular Material 用了一个 DialogConfig class 来管理。
虽然它是 class,但在官网的例子中,调用 Dialog.open 时所传入的 config 并不是 DialogConfig 实例,而只是一个满足鸭子类型的对象。
另外,在 Dialog.open 方法里,最终使用的 config 对象,也不是 DialogConfig 实例
既然都不是用实例,那为什么要用 class 呢?用 interface 不是更合理吗?
是的,不合理,Angular Material 之所以使用 class 是因为它想利用 class constructor 来提供属性的 default value。
class 和 interface 傻傻分不清楚是它的第一个问题。
第二个问题是它没有很好的区别哪些属性是 optional。
既然都给了 default value 'dialog',role 属性为什么会是 optional?
role 根本不可能是 undefined,除非我们去 delete role 属性
delete dialogConfig.role;
但这显然不可能发生。
Angular Material 之所以让 role 是 optional 是因为它拿 DialogConfig 作为 Dialog.open 的参数。
我们要知道,DialogConfig 或者任何 options 都有 2 个角度。
第一个角度是提供者,比如,我提供一个 options 给 doSomething function 使用。
提供者只需要提供最低要求,也就是说只有 required 的需要提供,其它 optional 的可以不提供。
第二个角度是消费者,比如在 doSomething function 里使用 options。
消费 options 的时候,我们会希望 options 是完整的,那些 optional 的都应该要补上 default value,不应该再是 undefined 了。(除非它在业务设计上是真正意义上的 optional,那就没有 default value)
结论,一个 DialogConfig 不可能同时满足两个角度,这是 Angular Material 的第二个问题。
Partial options best practice
首先,做一个消费者用的 Options interface
interface Options { disabled: boolean; prefix: string; useComponent: boolean; }
接着定义哪些 keys 在提供者角度是 optional 的
type OptionalKeys = 'disabled' | 'useComponent';
接着定义 partial options 和 default options
type PartialOptions = Partial<Pick<Options, OptionalKeys>> & Omit<Options, OptionalKeys> type DefaultOptions = Pick<Options, OptionalKeys>; // 上面两个等价于下面两个 // interface PartialOptions { // disabled?: boolean; // prefix: string; // useComponent?: boolean; // } // interface DefaultOptions { // disabled: boolean; // useComponent: boolean; // }
接着做一个 init function,它负责 assign default value
export function initOptions(partialOptions: PartialOptions): Options { const defaultOptions: DefaultOptions = { disabled : false, useComponent: false }; return { ...defaultOptions, ...partialOptions } }
最后在 doSomething 函数里面和外面使用它们
function doSomething (partialOptions: PartialOptions) { const options = initOptions(partialOptions); } doSomething({ prefix: 'stg' });
上面使用 interface + initOptions 函数替代了 class。其实要换成 class 也 ok,只要不要像 Angular Material 那样乱就行了。
提醒:
object assign 时要小心 undefined 值。
return { ...defaultOptions, ...partialOptions }
最好开启 tsconfig 的 exactOptionalPropertyTypes。
开启后,如果遇到 undefined 值可以使用 conditional add property 来添加属性。
总结
以后有新纠结,我会再补上,分享给大家。
目录
上一篇 Angular 18+ 高级教程 – 盘点 Angular v14 到 v18 的重大改变
想查看目录,请移步 Angular 18+ 高级教程 – 目录
喜欢请点推荐?,若发现教程内容以新版脱节请评论通知我。happy coding ??