Angular 18+ 高级教程 – Component 组件 の 生命周期钩子 (Lifecycle Hooks)
前言
之前在 Component 组件 の Angular Component vs Custom Elements 文章中,我们有学习过几个基础的 Lifecycle Hooks。
比如 OnChanges、OnInit、AfterViewInit、OnDestroy,但那篇只是微微带过而已。
这篇让我们来深入理解 Angular 的 Lifecycle Hooks。
介绍
在 Component 组件 の Dependency Injection & NodeInjector 文章中,我们看见了组件从无到有的创建与渲染过程。
整个过程可以被分解成多个阶段,每一个阶段的组件都处于不同的形态。
比如
A 阶段,组件只是个 Definition。
B 阶段,组件已经被实例化,但是 Template 中的子组件尚未被实例化。
C 阶段,ViewModel 已经更新到 DOM 上。
等等
组件生命周期钩子的目的就是让我们有机会去拦截上面这些阶段,然后对组件做一些事情。
Angular 一共提供了 8 个钩子,它们是(排名不分先后):
- OnInit
- OnChanges
- DoCheck
- AfterContentInit
- AfterContentChecked
- AfterViewInit
- AfterViewChecked
- OnDestroy
如果加上组件 constructor 那就是 9 个钩子。
Constructor 阶段
想要深入理解组件生命周期钩子,最简单的方法自然是逛源码咯。
经历过了 Change Detection 和 NodeInjector 两篇文章。我相信大家对 bootstrapApplication 函数已经不陌生了,那我们直接进入重点吧。
组件实例化发生在什么时候?
bootstrapApplication 有两大环节,一个是 create,一个是 update。
所有的组件都会在 create 阶段被实例化。
ApplicationRef.bootstrap 方法中有这么一段
这里的重点是,所有组件(App 和其后裔)实例化都是在 create 阶段完成的。在进入 update 阶段之前,所有组件都是已经实例化好的了。
我们进入 ComponentFactory.create 方法看看 create 阶段实例化组件的细节。
renderView 函数的源码在 render.ts
所谓的 render 就是执行这个组件的 template 方法,这个方法会实例化所有的子组件。
这里的重点是,先实例化完所有子组件,然后才继续逐个 render 子组件。
renderChildComponents 函数
renderComponent 函数
组件实例化的顺序
依据上面的源码,它有 3 个重要的步骤
-
实例化组件
-
render 组件 (也就是执行组件 template 方法,这方法会实例化所有子组件)。
-
所有子组件实例化好了后,再逐个 render 子组件,这里就开始递归了,一直到所有后裔组件实例化完成。
我们来看个复杂点的例子
相同颜色表示它们在同一个组件 Template 里,号码表示组件被实例化的顺序。
我们需要搞清楚什么时候在同一层,什么时候去下一层。
1. 相同颜色的,一定是连贯的顺序
因为实例化子组件时,是不会进入下一层孙组件的,只有等到所有子组件实例化好后,才会往下一层孙组件走。
2. 路线图
constructor 阶段可以做什么?
constructor 的特别之处在于它是 injection context,所以它可以使用 inject 函数。
其它 Lifecycle Hooks 都不行哦。
我们通常会在 constructor 阶段注入组件需要的 Service,把它们存起来,供之后使用。
constructor 阶段不可以做什么?
constructor 阶段,@Input 属性值还没有被填写,它是不可用的。
8 大组件生命周期钩子 Overview
我们先 overview 这 8 个生命周期钩子:
-
8 个 Lifecycle Hooks 全部发生在 refreshView 函数中,除了 DestroyHooks。(大名鼎鼎的 refreshView 函数在 Change Detection 文章中就介绍过了,不熟悉的请回去温习)
-
refreshView 会遍历所有组件,只要中途没有中断,所有组件的 Lifecycle Hooks 都有机会触发,当然如果中途断了,那剩余的组件就不会触发任何 Lifecycle Hooks 了。
-
OnInit、AfterContentInit、AfterViewInit 只会在第一次 refreshView 时触发,往后就不再触发了。
-
OnChanges 在每一次 refreshView 时,只要有 @Input 并且值发生了变化就会触发 (第一次赋值也算值变化),如果没有变化就不会触发。
-
DoCheck、ngAfterContentChecked、ngAfterViewChecked 在每一次 refreshView 都会触发。
-
OnDestroy 只会在组件被销毁时触发,也只会触发一次。(我们还没有学到如何销毁组件,这个之后在 Dynamic Component 章节才会教)
PreOrderHooks (OnChanges, OnInit, DoCheck)
8 个 Lifecycle Hooks 全部发生在 refreshView 函数中,处了 DestroyHooks,那我们就从 refreshView 函数源码开始吧。
下面会有 4 中情况,我会一起讲,虽然可能有点乱,但没办法,分开讲篇幅会很长,很啰嗦。
-
refreshView(rootLView) 从 Root LView 开始
-
refreshView(appLView) 从 App LView 开始
-
第一次 refreshView
-
第 n 次 refreshView
refreshView 函数 の template 方法
refreshView 源码在这里 change_detection.ts
App 组件长这样
app.component.js
refreshView 第一件事是去执行 LView 的 template 方法,这个方法指的是组件 Definition 中的 template 方法 (上图)。
这个 template 方法,我们已经研究过许多次了。
create mode 主要负责实例化子组件,创建它们的 TNode、TView、LView、NodeInjector 等等。
update mode 主要负责把 ViewModel 和 binding syntax 结合,也就是 "更新 DOM" 啦。
此外 update mode 里还多了 2 个以前没有学过的新代码 ɵɵproperty 和 ɵɵadvance,我们来看看它们是做什么的。
ɵɵproperty 函数
ɵɵproperty 的作用是把 ViewModel binding 到子组件的 @Input 属性上。
ctx 就是组件实例,也就是所谓的 ViewModel 啦。
ɵɵadvance 函数
ɵɵadvance 有 2 个功能,一个是移位。一个是执行 PreOrderHooks。
移位
移位的概念是这样的:
假设 Template 长这样
compile 以后
有箭头的地方是有 binding syntax 的。坐标是 index 1、8、10、17。
它的过程是:
移位到 index 1 > 更新 DOM >
移位到 index 8 > 填 @Input 给 C1 组件 >
移位到 index 10 > 更新 DOM >
移位到 index 17 > 填 @Input 给 C2 组件。
PreOrderHooks
ɵɵadvance 函数源码在这里 advance.ts
在移位之前,它会先执行 PreOrderHooks。
如果是第 1 次 refreshView,那会执行 tView.preOrderHooks。
如果是第 n 次 refreshView,那会执行 tView.preOrderCheckHooks。
我们来看看 TView 里的 preOrderHooks 和 preOrderCheckHooks 是指哪一些 Hooks。
getNodeInjectable 主要任务是实例化组件,然后它会把组件的 PreOrderHooks register to TView。
这里的重点是 TView 指的是谁?
假设,当前实例化的组件是 C1 组件,那 C1 的 PreOrderHooks 会被 regsiter 到 C1 的 parent TView,也就是 App TView。
同理,如果当前实例化的组件是 App 组件,那 App 的 PreOrderHooks 会被 register 到 App 的 parent TView,也就是 Root TView。
这个概念和组件 providers 是一样的,组件 providers 也是保存到 parent TView 中。
举例
下图是 C1 和 C2 组件,它们都有 OnInit Hook。
下图是 App Template
C1 和 C2 组件的 OnInit Hook 会被 register 到它们的 parent TView(也就是 App TView)。
下图是 App TView
里面有 2 个 OnInit Hook。其它位置的号码,我们不需要理会。
小总结
refreshView 会执行组件 template 方法 (update mode)
template 方法内有 ɵɵproperty 和 ɵɵadvance 函数。
ɵɵproperty 负责填写子组件的 @Input
ɵɵadvance 负责执行子组件的 PreOrderHooks 和移位。
回到 App template 方法
上面有 2 个重点
-
当子组件执行 PreOrderHooks 时,父组件的 template 方法是还没有执行完的。
比如上面,C1 OnInit 时,App 还没有更新 value3 到 DOM,也还没有填写 value4 到 C2 的 @Input。
-
template 方法的结尾不是 ɵɵadvance。
所以当 template 方法执行完,C1 PreOrderHooks 是执行了的,但是 C2 PreOrderHooks 却还没有执行。
C2 好像是被落下了。
refreshView 函数 の PreOrderHooks
上面提到,C2 PreOrderHooks 被落下了。我们继续看 refreshView 函数。
在执行完组件 template 方法后,接着就执行 PreOrderHooks 了。这个环节和 ɵɵadvance 里的几乎是一样的,只是它没有指定 index。
至此,所有子组件的 PreOrderHooks 都执行完了。
PreOrderHooks 可以 / 不可以做什么?
PreOrderHooks 阶段,组件(e.g. C1)所有的 @Input 都已经赋值。
此时我们可以修改组件的属性,因为组件的 template 还没有执行。
但是,最好不要修改父组件(App)的属性了,因为这个时候,父组件的 template 已经开始执行了,
某些属性或许已经被用于 DOM 更新了。
总之,best practice 是不要在 PreOrderHooks 里修改祖先组件的属性。
OnChanges 阶段可以做什么?
OnChanges 类似于 Custom Elements 的 attributeChangedCallback。
在每一次 refreshView 时,只要有 @Input 并且值发生了变化就会触发 (第一次赋值也算值变化),如果没有变化就不会触发。
export class C1Component implements OnChanges { @Input() value!: string; ngOnChanges(changes: SimpleChanges): void { const valueChange = changes['value']; if (valueChange.firstChange) { console.log('before after', [ valueChange.previousValue, // undefined valueChange.currentValue, // value 2 ]); } } }
OnInit 阶段可以做什么?
我们大部分逻辑代码都会写在这里。
DoCheck 阶段可以做什么?
之前在 Change Detection 文章中讲解过了,这里就不复述了。
小例子
最后补上一个小例子
App Template
<app-c1 /> <app-c2 />
在 App refreshView 时:
- C1 OnInit (此时 App template 方法还没有执行完)
- C2 OnInit
ContentHooks (AfterContentInit, AfterContentChecked)
Content 指的是 Content Projection (a.k.a slot / transclude / ng-content)。
refreshView 函数 の ContentHooks
同样是 refreshView 函数,在执行组件 template 方法 > PreOrderHooks 之后,就到了 ContentHooks。
detectChangesInEmbeddedViews 是 Dynamic Component 章节的内容,之后会教。
refreshContentQueries 是 Query Elements 章节的内容,之后会教。
和 PreOrderHooks 一样,ContentHooks 也都放在 TView 里。
我们看看 tView.contentHooks 是什么时候被赋值的。
下图是 App Template
compile 以后
子组件的 ContentHooks 是在 ɵɵelementEnd 函数中被 register 到 TView 的。
注:ɵɵelement 函数结尾也是有调用 ɵɵelementEnd 哦
ɵɵelementEnd 函数
ɵɵelementEnd 函数源码在 element.ts
registerPostOrderHooks 函数源码在 hooks.ts
PostOrderHooks Registration 小细节
1. PostOrderHooks 包含 ContentHooks,ViewHooks 还有 DestroyHook,它们都是在这个环节 register 的。
2. Register PreOrderHooks 是发生在单个组件或指令实例化后,所以每一次只 register 一个组件或指令。
Register PostOrderHooks 是发生在 1 个 TNode 内的组件和指令全部实例化之后,所以每一次是把 TNode 内的组件和指令全部都 register。
3. Register PreOrderHooks 的顺序
顺序是 C2 -> C1 -> C3
和 PreOrderHooks 的区别
PostOrderHooks 和 PreOrderHooks 非常相似,都是在组件 template 方法 create mode 阶段把 Hooks register 到 TView。
都是在 template 方法 update mode 时执行。
比较大的区别是 register 的顺序
PreOrderHooks 很简单,上到下,组件或指令实例化之后就 register。
PostOrderHooks 则多了一个里到外的概念,因为它是依据 ɵɵelementEnd 的位置。
比如上图,从上往下看,第一个 element end 是 </app-c2>,然后是 </app-c1> 最后才是 </app-c3>,所以顺序是 C2 > C1 > C3。
另外一点是,子组件(e.g. C1)PreOrderHooks 执行时,组件(App)template 方法是还没有执行完的,它有一半一半的概念,
PostOrderHooks 就没有这个概念,在子组件(C1)PostOrderHooks 执行时,(App)template 方法是已经执行完的了。
PostOrderHooks 可以 / 不可以做什么?
绝对不可以修改祖先组件的属性,因为祖先组件的 template 方法都已经执行完了,DOM 都更新完了。
ContentHooks 阶段可以做什么?
提醒:ngAfterContentInit 只会触发 1 次,AfterContentChecked 每一次 refreshView 都会触发。
ngAfterContentInit 阶段我们可以 Query Content,比如 C1 组件 query C2 组件。(注:Query Elements 下一篇才会教)
然后可以修改 C2 的属性,因为这时 C2 还没有执行 template 方法,DOM 还没有更新,所以还能改。
关于 Query Content 的细节,下一篇会教,这里我们只要知道这个 Hooks 可以用于处理 Content Projection 里的内容就好了。
小例子
最后补上一个小例子
App Template
<app-c1> <app-c2 /> </app-c1> <app-c3 />
在 App refreshView 时:
- C1 OnInit
- C2 OnInit (此时 App template 方法还没有执行完)
- C3 OnInit
- C2 AfterContentInit (此时 App template 方法已经执行完毕)
- C1 AfterContentInit
- C3 AfterContentInit
ViewHooks (AfterViewInit, AfterViewChecked)
View 指的是 LView。
refreshView 函数 の ViewHooks
同样是 refreshView 函数,在执行组件 template 方法 > PreOrderHooks > ContentHooks 之后就到了 ViewHooks。
executeViewQueryFn 是 Query Elements 章节的内容,之后会教。
ViewHooks register to TView 这个过程,上面讲解 ContentHooks 时已经讲过了,它们都属于 PostOrderHooks,都是在 ɵɵelementEnd 时一起 register 的。
和 ContentHooks 的区别
最重要的一点是,在执行子组件 ViewHooks 前,会先递归 refreshView(子组件)
例子说明
下图是 App Template
PreOrderHooks 的顺序是:C1 > C2 > C3
这时 App template 方法已经执行完了,App DOM 已经更新。
ContentHooks 的顺序是:C2 > C1 > C3
这时 C1、C2、C3 的 template 方法都还没有执行,DOM 还没有更新。
在执行 ViewHooks 之前,先递归 refreshView(C1)、refreshView(C2)、refreshView(C3)
假设 C1、C2、C3 Template 里都没有子组件,那它们 TView 自然也没有 Hooks,那执行完 template 方法后就返回到了 refreshView(App)。
接着就到执行 ViewHooks 了,ViewHooks 的顺序和 ContentHooks 一样 (因为它们是一起 register 的):C2 > C1 > C3
结束
ViewHooks 阶段可以做什么?
当一个组件的 ViewHooks 触发时,它的祖先,它自己,它的后裔,所有组件的 template 方法都已经执行完了,DOM 也更新完了。
通常这个时候只会做一些 Query Element + DOM manipulation。
小例子
最后补上一个小例子
App Template
<app-c1> <app-c2 /> </app-c1> <app-c3 />
在 App refreshView 时:
- C1 OnInit
- C2 OnInit (此时 App template 方法还没有执行完)
- C3 OnInit
- C2 AfterContentInit (此时 App template 方法已经执行完毕)
- C1 AfterContentInit
- C3 AfterContentInit
- C1 refreshView
- C2 refreshView
- C3 refreshView
- C2 AfterViewInit
- C1 AfterViewInit
- C3 AfterViewInit
DestroyHooks (OnDestroy)
Dynamic Component 章节会教。
Example
以前写的一些例子 Github – angular-blog-component-lifecycle-hooks。
结构
<!-- app.html --> <app-child> <app-transclude-to-child></app-transclude-to-child> </app-child> <!-- child.html --> <app-inside-child></app-inside-child> <ng-content></ng-content> <!-- inside-child.html --> <p>inside-child works!</p> <!-- transclude-to-child.html --> <app-inside-transclude-to-child></app-inside-transclude-to-child> <!-- inside-transclude-to-child.html --> <p>inside-transclude-to-child works!</p>
结果
AfterRenderHooks (afterNextRender, afterRender)
Angular 在 v16.2 推出了新的 AfterRenderHooks,目前 (v17 版本) 这 2 个 Hooks 任处于 Developer Preview。
不过,依据目前的线索,这或许是未来 Angular 的 Lifecycle Hooks 方向哦。大家还是趁早多了解一下。
afterRender 函数
export class AppComponent { constructor() { afterRender(() => { console.log('Hello World'); }); } }
AfterRenderHooks 的使用方式和其它 Lifecycle Hooks 截然不同。
它是通过 afterRender 函数来 register Hooks 的。
这个 afterRender 函数依赖 Injector,所以必须在 injection context (e.g. constructor) 内才可以使用。
afterRender 源码逛一逛
我们直接看源码了解细节呗。
三大重点:
-
afterRender 依赖 Injector
-
afterRender 只在 browser 执行,Server-side Render (SSR) 是不执行的。
-
afterRender 可以被取消
以上三个概念,其它 Lifecycle Hooks 都没有。
afterRender 会把 callback 存放到一个全局变量里,等 Lifecycle 到指定时刻,所有 callback 会被拿出来调用。简单说就是一个典型的观察者模式。
afterNextRender
afterNextRender 和 afterRender 的区别类似于 AfterViewInit 和 AfterViewChecked
afterNextRender 只会执行一次。
AfterRenderHook 什么时候执行?
我们先来温习一下 Change Detection 机制。
ApplicationRef.tick > ViewRef.detectChange > refreshView > 递归 refreshView。
PreOrderHooks、PostOrderHooks 都发生在 refreshView 内。
而 AfterRenderHooks 则是在 detectChange 的结尾,也就是所有 refreshView 之后。
这是 ApplicationRef.tick 源码,在 application_ref.ts
里头有一些细节,我们一个一个来看
首先,它是一个 while,也就是说可能会执行超过一次
MAXIMUM_REFRESH_RERUNS 是 10 次,white 最多执行 10 次。
white 一开始是执行 refreshView,refreshView 里面又会递归。
过了这个 for loop,所有的 LView 就都 refresh 好了,所有的 Lifecycle Hooks 也都执行了。
在 refreshView 的结尾 (after ViewHooks),LView 会被 mark for clean。
也就是说,假如我们在 AfterViewInit 里执行 markForCheck 是没有作用的,因为它马上又会被 mark for clean。
refresh 完所有 LView 后,会 run++ 记入 white 执行的次数 (white 不可执行超过 10 次),然后开始执行 after render callback,它会先跑 Angular 内部注册的 after render callback,稍后才执行我们注册的。
执行完 internal after render callback 以后,它会检查 LView 是否肮脏。如果肮脏就 continue。
white + continue 就是重跑一轮,也就是再 refreshView 一轮,再执行 internal after render callback 一轮。
white 最多 10 次,所有这里是不可能无限循环的。
执行完 internal after render callback 以后,就到我们注册的 after render callback。
同样的,执行 callback 以后,也会检查 LView 是否肮脏,如果没有肮脏就 break,有肮脏它就会进入下一轮的 white looping,又会执行多一轮 refreshView 和 after render callback。
注意,从这里我们就看出 AfterRenderHooks 和 ViewHooks 的大不同了。
ViewHooks 里面 markForCheck 是没有作用的,因为马上就被 mark for clean 了。
AfterRenderHooks 里面 markForCheck 是有作用的,它会导致再执行一轮 refreshView。
结论:
ViewHooks 里面 markForCheck 是无效的 (除非你 delay 执行,比如 wrap 一层 setTimeout),因为 ViewHooks 之后,LView 会被 mark for clean。
AfterRenderHooks 里面可以 markForCheck (不需要 delay 执行),因为执行完 after render callback 如果有 LView 肮脏它会重新执行 refreshView。
AfterRenderHook 的执行顺序
First in first out
Hooks 的执行顺序是 first in first out,先 register 的就先执行。
register 发生在 construtor,所以 App AfterRenderHooks 会先于 Child AfterRenderHooks。
这一点和 ViewHooks 是不同的哦,ViewHooks 是从底层到上层,AfterRenderHooks 是上层到底层。
AfterRenderPhase
除了 first in first out 概念,AfterRenderHooks 还分 phrase (阶段)。
有 4 个 phrase。
phrase 执行的顺序是 EarlyRead > Write > MixedReadWrite > Read。
综合 first in first out 和 phrase 概念,最终的执行顺序是这样。
-
先把 phrase: EarlyRead 所有 Hooks 按 register first in first out 执行一遍
-
再把 phrase: Write 所有 Hooks 按 register first in first out 执行一遍
-
再把 phrase: MixedReadWrite 所有 Hooks 按 register first in first out 执行一遍
-
最后是把 phrase: Read 所有 Hooks 按 register first in first out 执行一遍
为什么要分那么多阶段?
这里先不管,我们清楚它的顺序就好了,下一 part 我会给具体例子说明分段的用法与好处。
Nested Hooks
下面这个叫 Nested Hooks
最好不要搞这么绕...
我们这样来理解啊,
假设一开始有 100 个 AfterRenderHooks,在 refreshView 执行完之后,开始 foreach 100 个 hooks,
依据 phrase 和 first in first out 顺序。执行到一半,比如第 50 个出现了 nested hook,此时会把这个 nested hook 先 hold 着,一直到 100 个 hooks 执行完。
假设,一共 hold 了 10 个 nested hooks,那这个时候才 register 这 10 个 hooks,总数变成 110 个 hooks。
接着就等下一轮 detectChange 了。(重点:这一轮只执行 100 hooks,10 nested hooks 只是 register 而已没有执行,等下一轮才执行 110 hooks)
AfterRenderCallbackHandlerImpl 源码
关于 First in first out,Phrase,Nested Hooks 的相关源码在 after_render_hooks.ts
class AfterRenderCallbackHandlerImpl
Nested Hooks 自动执行?
上面源码显示,Nested Hooks 会在下一轮 detectChange 才被执行,但如果我们做测试的话,会发现它视乎是在同一轮 detectChange 执行的。
其实这是我们的错觉,Angular 调用 tick 比我们想象中的频密。
所以它确实是下一轮 detectChange 才执行的。
is nested / executing?
如果想判断当前是否是 nested 或者说是不是正在执行着 after render callback,我们可以透过 AfterRenderManager。
export class AppComponent { constructor() { const afterRenderManager = inject(ɵAfterRenderManager); console.log('outside', afterRenderManager.impl?.executing ?? false); // false afterNextRender(() => { console.log('inside', afterRenderManager.impl?.executing ?? false); // true }); } }
AfterRenderHooks vs ViewHooks
写法上的区别
PreOrderHooks 和 PostOrderHooks 的写法偏向面向对象,一个 interface 配一个对象方法。
AfterRenderHooks 则有点函数式的味道,没有 interface,不需要对象方法,直接调用一个全局函数,这个和 inject 函数就很像。Angular 目前的方向是减少面向对象,增加函数式,弃 decorator。
未来 Signal-based Component 可能会统一使用 AfterRenderHooks 的写法。相关参考:Github – [Complete] Sub-RFC 3: Signal-based Components
功能上的区别
AfterRenderHooks 依赖 Injector。
AfterRenderHooks 只在 browser 会执行,Server-side Render (SSR) 不执行。
ViewHooks 的执行顺序是依据组件,从内到外或者从底层到上层,一遍完。
AfterRenderHooks 的执行顺序分 4 个 phrase,所以会跑 4 轮,每一轮依据 register 顺序 first in first out 来执行。
所以 AfterRenderHooks 其实比 ViewHooks 复杂和灵活。
ViewHooks 里面 markForCheck 是无效的 (除非你 delay 执行,比如 wrap 一层 setTimeout),因为 ViewHooks 之后,LView 会被 mark for clean。
AfterRenderHooks 里面可以 markForCheck (不需要 delay 执行),因为执行完 after render callback 如果有 LView 肮脏它会重新执行 refreshView。
用哪个?
旧代码可以不必急改,但新代码推荐使用新的方式。我目前是没有看到有什么是 ViewHooks 能做但 AfterRenderHooks 做不了的,所以敢敢用呗。
Angular 与 Browser render (repaint / reflow) の 我们该何时读写 DOM?
不熟悉 Browser render 的建议先看这两篇
JavaScript – 单线程 与 执行机制 (event loop)
DOM – Browser Reflow & Repaint
我们先复习一下 Angular 监听到事件后,它的渲染过程。
当一个事件 (e.g. document click) 触发后,会进入我们写好的 handle 方法 (可能会有多个不同组件监听同一个事件哦),接着我们会修改 ViewModel。
然后 Angular 会触发 setTimeout + tick (change detection 机制),接着是 refreshView (update DOM),最后是游览器渲染。
两个知识点:
-
update ViewModel 不会立刻 update DOM,而是 setTimeout 后 (押后到所有 ViewModel update 好后) 才 update DOM。
-
update DOM 不会立刻导致游览器渲染,而是等所有 update DOM 完成后游览器才会渲染 (这个是游览器渲染机制)
为什么 ViewModel 修改后不要立刻 update DOM?
因为要考虑到 read DOM information 导致的 reflow 问题。
事件触发后,handle 方法 (可能有多个) 里我们有可能会 read DOM information,假如我们 update ViewModel 后,它直接去 update DOM,那在读 DOM information 时就可能会导致游览器提前 reflow。
为了避免不必要的 reflow,Angular 就用 setTimeout 押后了 udpate DOM。
那假如我们自己做 DOM Manipulation (不使用 ViewModel),我们要如何像 Angular 那样押后 update DOM 呢?
答案是使用上一 part 教的 AfterRenderHooks。
例子说明:
App Template
<button appDir1 appDir2>click me</button>
button 上有 2 个指令 -- Dir1 和 Dir2
Dir1 指令
@Directive({ selector: '[appDir1]', standalone: true, host: { '(click)' : 'handleClick()' } }) export class Dir1Directive { private readonly hostElement: HTMLElement = inject(ElementRef).nativeElement; handleClick() { const width = this.hostElement.offsetWidth; this.hostElement.style.width = `${width + 30}px`; } }
handleClick 的时候,先读再写。假设我的需求是读取最初的 width。
Dir2 指令也是同样的代码。
当 click 触发后,它们的执行过程是 (读 > 写 > reflow > 读 > 写 > reflow)。因为写了又读,所以中间多了一个 reflow。
同时第二次读和第一次读的值是不同的,因为 width 换了也 reflow 了,这不满足我上面提的特别需求。
我们把它改成这样
export class Dir1Directive { private readonly hostElement: HTMLElement = inject(ElementRef).nativeElement; private readonly injector = inject(Injector); handleClick() { const offsetWidth = this.hostElement.offsetWidth; // 押后 update DOM afterNextRender({ write: () =>this.hostElement.style.width = `${offsetWidth + 30}px` }, { injector: this.injector }); } }
事件触发后直接读,afterNextRender 了再写。
这样它们的执行顺序就变成了 (读 > 读 > 写 > 写 > reflow),两次读都获得最初的 width,而且只 reflow 一次,满足了指定的需求。
总结:做 DOM manipulation 时,要多留意写了之后读而导致的 reflow 问题。
我的习惯是,总是像 Angular 那样押后写的操作,这样会比较整齐好打理。
提醒:为什么不使用 EarlyRead?像这样
afterNextRender({ earlyRead: () => this.hostElement.offsetWidth, write: (offsetWidth) => { this.hostElement.style.width = `${offsetWidth + 30}px`; } }, { injector: this.injector });
afterNextRender 是发生在 refreshView 之后,也就是说 ViewModel 已经 update DOM 了,所以 EarlyRead 读 DOM information 是有可能导致游览器提前 reflow 的。
总之要读 DOM information 就要在 sync (同步) 或者至少 async (异步) Microtask 的阶段,因为 Angular 会在 setTimeout (async Macrotask) 后 update DOM,这时再想读 DOM information 就可能会导致提前 reflow 了。
export class AppComponent { private readonly injector = inject(Injector); handleClick() { // sync read DOM information -- OK, won't reflow console.log(document.body.offsetWidth); // async Microtask read DOM information -- still OK, won't reflow queueMicrotask(() => console.log(document.body.offsetWidth)); // afterNextRender 发生在 setTimeout/requestAnimationFrame -> tick -> refreshView (update DOM) 之后 -- 所以 not OK,maybe reflow afterNextRender( { earlyRead: () => console.log(document.body.offsetWidth), }, { injector: this.injector }, ); // 事件监听 callback 函数比 afterNextRender 还晚触发,所以也是 not OK, maybe reflow window.setTimeout(() => console.log(document.body.offsetWidth)); requestAnimationFrame(() => console.log(document.body.offsetWidth)); } }
APP_INITIALIZER 和 APP_BOOTSTRAP_LISTENER
我们在 Change Detection 和 NodeInjector 文章中逛过 Angular bootstrapApplication 源码,相信大家还记忆犹新。那这里我们直接进入主题吧。
App Initializer
在创建 Root Injector 之后,bootstrap App 组件之前 (也就是 AppComponent constructor 都还没有执行之前),有一个阶段叫 App Initialize。
我们可以通过 DI 提供一些 callback 让 Angular 在这个阶段执行。
app.config.ts
export const appConfig: ApplicationConfig = { providers: [ { provide: APP_INITIALIZER, multi: true, useValue: () => { // do something before bootstrap App 组件 return Promise.resolve(); }, }, ], };
APP_INITIALIZER 是一个 DI token,我们提供 callback 函数给它就可以了。
注:必须 multi: true 哦,因为 Angular 只有这么一个接口,其它 module (e.g. Router, Material, any thrid party library) 都有可能会 provide 这个 token。
接着在那个阶段 Angular 就会执行所有 callback 函数。
callback 函数不一定要有返回值,但如果要返回 Promise 或 RxJS Observable 也可以,Angular 会同步遍历执行所有 callback 然后异步等待所有 callback 函数 Promise resolve。
相关源码在 application_init.ts
App Bootstrap Listener
App Initializer 是在 bootstrap App 组件之前,而 App Bootstrap Listener 则是在 bootstrap App 组件之后 (也就是 AppComponent afterNextRender 之后)
我们可以通过 DI 提供一些 callback 让 Angular 在这个阶段执行。
app.config.ts
export const appConfig: ApplicationConfig = { providers: [ { provide: APP_BOOTSTRAP_LISTENER, multi: true, useValue: (compRef: ComponentRef<AppComponent>) => { console.log('done bootstrap', compRef); }, }, ], };
APP_BOOTSTRAP_LISTENER 是一个 DI token,我们提供 callback 函数给它就可以了。
注:必须 multi: true 哦,因为 Angular 只有这么一个接口,其它 module (e.g. Router, Material, any thrid party library) 都有可能会 provide 这个 token。
接着在那个阶段 Angular 就会执行所有 callback 函数。
和 APP_INITIALIZER 不同,APP_BOOTSTRAP_LISTENER 返回值是 void,不能是 Promise。
这也合理啦,毕竟这已经是渲染最后一个阶段了,接下来也没用东西做了,哪还需要等什么。
Best Practice
如果我们想在 APP_BOOTSTRAP_LISTENER 阶段操作 DOM 的话,最好加一些环境判断和 requestAnimationFrame。
{ provide: APP_BOOTSTRAP_LISTENER, multi: true, useFactory: () => { const isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); if (!isBrowser) return; return () => requestAnimationFrame(() => console.log('操纵 DOM')); }, },
afterNextRender 在 server-side rendering 时是不会执行的,但 APP_BOOTSTRAP_LISTENER 会,所以假如我们要操作 DOM 就要确保是游览器环境。
那为什么要加 requestAnimationFrame 呢?
虽然这个阶段已经是 App refresh LView 之后了,但不代表 Angular 已经执行完了,像 <router-outlet /> 也是通过 APP_BOOTSTRAP_LISTENER 阶段来执行的,它就有可能还没有执行。
而且 Angular 执行过程中经常会加 queueMicrotask 控(打)制(乱)节奏,所以最安全的做法是加一个 requestAnimationFrame,这样就可以确保 JS event loop 已经干净,游览器要渲染前,我们来做最后的 DOM manipulation。
目录
上一篇 Angular 18+ 高级教程 – Component 组件 の Dependency Injection & NodeInjector
下一篇 Angular 18+ 高级教程 – Component 组件 の Query Elements
想查看目录,请移步 Angular 18+ 高级教程 – 目录
喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding 😊💻