啸达同学的zone.js系列分享第四篇,新鲜出炉,终于等到了Angular对zone.js的使用
zone.js系列往期文章
- zone.js由入门到放弃之一——通过一场游戏认识zone.js
- zone.js由入门到放弃之二——zone.js API大练兵
- zone.js由入门到放弃之三——zone.js 源码分析【setTimeout篇】
NgZone
我在《zone.js由入门到放弃之一》中简述过zone.js和NgZone的关系,我说ngZone生于zone.js;长于Angular。在这里我再解释一下这句话的意思:首先zone.js维护了一个执行上下文栈,可以帮助开发者追踪异步任务、并通过生命周期勾子注入业务。NgZone实际上就是一个从root zone中fork出来的子zone。只不过这个子zone是专门为Angular量身定做的,并被当作一个可注入的服务被集成到Angular开发工具中。我见过有些文章中说Angular封装了zone.js从而构建出NgZone。其实这种说法是不准确的,个人理解,Angular其实并没有对zone.js的框架或是核心做任何改动,只是利用zone.js的执行上下文来监听异步事件,从而指导Angular在合适的时机进行变更检测。
NgZone的前半生
本文的开始,我们先看下NgZone是在何时何处构造出来的:
首先,一个Angular的工程的入口文件是main.ts
。在main.ts
中,大家大多都会见到这么一句platformBrowserDynamic().bootstrapModule(AppModule).catch(err => console.error(err));
,我这里简单叙述一下这里到底执行了些啥。Angular是支持跨平台运行的,那么Angular在执行前需要确定当前工程到底是运行在哪一个平台下的:浏览器?服务端(SSR)?WebWorker?或是移动端?
platformBrowserDynamic()
方法返回的实际就是一个平台对象PlatformRef
,PlatformRef
中定义了如何引导启动一个Angular应用。在这里我多扯几句Angular实现化平台运行的原理。Angular工程在初始化的时候会注入很多基础服务,比如Renderer2、Compiler等等很多。这些服务其实都是一些抽象类,对外提供了统一的API,对内会屏蔽了不同平台之间的差异。当我们的Angular应用运行在不同平台时,Angular都会有一套相对应的实现逻辑;就像设计模式中的适配器一样,不同平台有不同平台的adapter。这也就是为什么,我们在浏览器时使用BrowserModule
启动应用;而在SSR中使用AppServerModule
启动应用。
那么在浏览器模式下,platformBrowserDynamic()
返回的平台信息在application_ref.ts
这个文件下。这个服务对外暴漏了一个bootstrapModuleFactory
方法,当我们通过bootstrapModule
启动Angular应用的时候,bootstrapModule
最终会调用到bootstrapModuleFactory
。而从这个bootstrapModuleFactory
开始,我们将第一次在Angular中看到NgZone的身影。
@Injectable({providedIn: 'platform'})
export class PlatformRef {
...
constructor(private _injector: Injector) {}
bootstrapModuleFactory<M>(moduleFactory: NgModuleFactory<M>, options?: BootstrapOptions):
Promise<NgModuleRef<M>> { ... }
...
}
bootstrapModuleFactory何许人也
bootstrapModuleFactory
中通过getZone方法构建了ngZone服务。getZone方法也比较简单,它会实例化一个NgZone服务。
function getNgZone(ngZoneToUse: NgZone|'zone.js'|'noop'|undefined, options: NgZoneOptions): NgZone {
let ngZone: NgZone;
// 留着后面讲
if (ngZoneToUse === 'noop') {
ngZone = new NoopNgZone();
} else {
ngZone = (ngZoneToUse === 'zone.js' ? undefined : ngZoneToUse) || new NgZone(options); 👈
}
return ngZone;
}
而下面就是我简化过后NgZone的构建逻辑,是不是一下子看到很多熟悉的勾子函数。正向前文说的,NgZone就是一个特殊的Zone,而帮助Angular进行变更检测的所有逻辑都集中在ZoneSpec中定义的这几个勾子中,了解了这些内容会对掌握Angular变更检测原理提供很大帮助。不过本期不会对这几个勾子进行详细讲解,下一篇文章,我会step by step地演示这其中的逻辑,对这块感兴趣的可以关注一下。
export class NgZone {
constructor() {
self._outer = self._inner = Zone.current;
zone._inner = zone._inner.fork({
name: 'angular',
properties: <any>{ isAngularZone: true },
onInvokeTask: (...): any => {
...
},
onInvoke: (...): any => {
...
},
onHasTask: (...): any => {
...
},
onHandleError: (...): any => {
...
},
});
}
}
所以本期讲解到此结束...
哈哈,我当然不会这么敷衍的,其实到这里这次的NgZone分享才刚刚开始。NgZone有个非常有意思的属性叫做_outer
,因为大家在使用Angular的时候很少会直接跟zone.js接触,而这个_outer,它也是一个zone的实例,它在Angular中的存在感要远比zone.js多的多。后文中,我们统一把_outer
称之为OuterZone,而把_inner
称之为InnerZone。
export class NgZone {
constructor() {
self._outer = self._inner = Zone.current; 👈
zone._inner = zone._inner.fork({...});
}
}
OuterZone
曾经有一个作者这么评价zone.js对Angular的贡献:
作为 Angular 开发者,我们每个人都欠 Zone.js 一顿饭:多亏了有 Zone 的协助,我们能够以魔术般的方式使用 Angular;事实上,大部分时候我们只是修改了一个属性,Angular 就会自动渲染组件,确保视图总是及时更新。非常酷!
话虽如此,但是如果我说OuterZone的出现就是为了让Angular可以摆脱zone.js的控制而运行,这会不会显得很打脸。Angular团队解释这么做是为了性能。因为zone.js会在初始化时将很多异步方法Patch了,从而可以监控到这些异步任务,并通知Angular在适当的时机进行变更检测。但是有的时候,我们有些业务并不需要触发变更检测,毕竟每进行一次变更检测在时间和空间上都是有消耗的。尤其是像拖拽、鼠标移动、滚动条这种事件,他们会在短时间被触发多次。如果每次事件触发都需要进行变更检测,那就太浪费了。所以,Angular团队以及zone.js的作者都开始想办法,让开发者的一些动作可以不受zone.js的“监管”。这里我总结了几种办法:
- 使用noop代替zone.js,让Angular完全与zone.js脱离关系
- 使用OnPush策略
- 让zone.js停止对某些异步方法进行跟踪
- 使用OuterZone
让Angular完全与zone.js脱离关系
这一点其实Angular团队已经写到官方指导中了,Angular团队同时也给出了代码案例说明了脱离zone.js后应该如何进行变更检测。这里我就不过多介绍这部分内容了,毕竟修改起来也就2行代码的事。
OnPush策略
由于上面脱离方式过于暴烈,Angular同时又提供了OnPush策略用来进行组件级的性能优化。其实按道理讲,OnPush策略其实跟zone.js并没有什么关系,放在这里只不过是想说明一下,这也是一种让代码“脱离”变更检测的方式(OnPush策略并不是完全脱离)。同时,Angular也建议在使用OnPush策略的时候,配合ChangeDetection一起使用,这样能让你在需要变更检测的时候也能恢复变更检测。对于OnPush策略的文献也很多,我这里也不做展开了,感兴趣的可以自己搜一下。
让zone.js停止对某些异步方法进行跟踪
我在《zone.js由入门到放弃之二》中介绍过如何让zone.js放弃对setTimeout进行Patch,当我设置了global.__Zone_disable_timers = true;
后,setTimeout就不会被Patch了。诸如这样的配置有很多,需要的可以点击这里。👈
使用OuterZone
首先,我们明确一下ngZone的构造过程中生成了两个Zone,InnerZone是负责跟Angular配合进行变更检测的;而OuterZone实际就是Zone.current,它并不会参与Angular的变更检测。NgZone中定义了一个runOutsideAngular的方法,这个方法会调用OuterZone.run方法,让参数中的fn
可以执行在OuterZone中。
export class NgZone {
...
runOutsideAngular<T>(fn: (...args: any[]) => T): T {
return (this as any as NgZonePrivate)._outer.run(fn);
}
}
举个例子,假设你有一个setTimeout方法。当这个方法在Angular中执行时,由于zone.js对setTimeout进行过打包,所以zone.js会追踪setTimeout的各个执行阶段并触发对应的钩子函数。又由于InnerZone是rootZone的一个子Zone,同时InnerZone中设置了大量了的勾子函数,所以InnerZone也可以感知到setTimeout的执行过程,并在特定的情况下触发便变检测。在Angular中,大多的异步过程都是这么执行的。
当我们有一天不希望某个setTimeout方法再触发变更时,我们可以让这个setTimeout执行在runOutsideAngular
中。此时,因为OuterZone没有设置任何勾子函数,也不会通知Angular应用进行变更检测。所以,runOutsideAngular
实际上相当于给你提供一块世外桃源,让你可以“安静”地运行一些异步任务。
Show me your code
上面讲了这么多概念,下面我想用一个简单的性能优化案例来串一下今天所有的知识点。在本期示例中,我们要做一个自动登出的界面。界面每过5s会检查一次页面上是否有鼠标操作。如果有,则页面保持登录状态;如果没有,界面自动登出。在这里,我们仅对界面的登录、登出状态做简单处理——通过isLogined
控制登录状态。
// app.component.html
<div class="hotspot">
<h1>{{isLogined ? '欢迎来到自动登出系统!' : '期待您下次光临!'}}!</h1>
</div>
需求澄清
- Origin:界面每5s进行一次检测
- Origin:5s内有鼠标移动、鼠标滚轮事件触发,则界面保持登录态;反之,界面自动登出
V1版本
V1版本中,app组件在构造时启动定时器,定时器每过5s检查页面状态isDirty
。isDirty === trye
,则页面有鼠标事件触发;isDirty === false
,则页面自动登出。鼠标事件通过@HostListener
监听;同时通过ngDoCheck
勾子,观测页面进行变更检测的频次。
// app.component.v1.ts
export class AppComponent {
isLogined = true;
timer: any;
isDirty = false;
constructor() {
this.startTiming();
}
// 变更检测时会被触发
ngDoCheck() {
console.log('rendering...');
}
startTiming() {
console.log('startTiming!');
this.timer = setInterval(() => {
// 当检测没到有鼠标事件触发过,则停止检测
if (!this.isDirty) {
this.stopTiming();
}
this.isDirty = false;
}, 5000);
}
stopTiming() {
clearInterval(this.timer);
this.isLogined = false;
}
// 鼠标移动监听
@HostListener('mousemove')
mouseLisener() {
this.isDirty = true;
console.log('mousemoved');
}
// 鼠标滚轮监听
@HostListener('window:scroll')
onScrollEvent() {
this.isDirty = true;
console.log('scrollmoved');
}
ngOnDestroy(): void {
this.timer ?? clearInterval(this.timer);
}
}
通过Angular提供的DevTools可以方便地监控到Angular应用执行过程中的性能情况,当页面加载后,只要稍微动一动鼠标,Angular的变更检测就会疯狂执行(每一个小柱子代表一次CD)。
下图是界面中控制台的疯狂输出,这里每当界面上有鼠标滚轮或是鼠标移动事件发生后,都会引起Angular进行一次变更检测。
V2版本
从V1版本的日志图中我们可以发现,每次scrollmoved
执行过后都会紧跟一个rendering...
打印。所以在V2版本中,我们先对需求做一些调整,我们删除对滚轮事件的监听。为了能尽量全地演示这些性能提升手段,V2版本中,我们通过zone.js中的屏蔽手段屏蔽对鼠标滚轮事件的检测。
- Origin:界面每5s进行一次检测
- Changed:5s内有鼠标移动、
鼠标滚轮事件触发,则界面保持登录态;反之,界面自动登出
我在《zone.js由入门到放弃之二》中讲过对setTimeout方法的屏蔽方法,这里我也把zone.js提供的所有屏蔽API分享出来,大家可以按需使用。
👇👇👇
在Angular中屏蔽很简单,但是有坑。
STEP1
增加一个zone-flag.v2.ts
文件(文件名随便取),内容就一行如下:
(window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll'];
STEP2
在polyfills.ts
文件中写入:
import './zone-flag.v2';
import 'zone.js/dist/zone';
切记,这里必须这么写,直接把
(window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll'];
写到pollyfills是不行,想问为啥,问就是变量提升的坑。👈
此时,重新运行Angular应用,这时你会发现,scrollmoved
日志后面没有再紧跟rendering...
日志了,这意味着鼠标的scroll事件已经不会触发变更检测了。
既然鼠标滚轮事件已经移出我们的监听范围,则我们也可以修改一下app中的代码,将对scroll的监听移除。
// app.component.v2.ts
export class AppComponent {
// 鼠标滚轮监听
// @HostListener('window:scroll')
// onScrollEvent() {
// this.isDirty = true;
// console.log('scrollmoved');
// }
}
V3版本
V2中,我们已经通过zone.js让鼠标的scroll事件脱离了变更检测。接下来,我们使用ngZone的runOutsideAngular
方法,让mousemove也脱离Angular的变更检测。V3的代码,分别注入了Renderer2
,ElementRef
,NgZone
服务。通过runOutsideAngular
注册监听可以让事件不触发变更检测。同时,我们通过mouseMoveUnsub
保存事件的注销方法,在界面登出后注销事件监听。
// app.component.v3.ts
export class AppComponent {
isLogined = true;
timer: any;
isDirty = false;
mouseMoveUnsub: any;
constructor(private render2: Renderer2, private elementRef: ElementRef, private zone: NgZone) {
this.startTiming();
// 在OutZone中注册监听事件,事件触发时不会引起变更检测
this.zone.runOutsideAngular(() => {
this.mouseMoveUnsub =
this.render2.listen(this.elementRef.nativeElement, 'mousemove', this.mouseLisener.bind(this));
});
}
// 变更检测时会被触发
ngDoCheck() {
console.log('rendering...');
}
startTiming() {
console.log('startTiming!');
this.timer = setInterval(() => {
// 当检测没到有鼠标事件触发过,则停止检测
if (!this.isDirty) {
this.stopTiming();
}
this.isDirty = false;
}, 5000);
}
stopTiming() {
clearInterval(this.timer);
this.isLogined = false;
// 解除事件监听
this.mouseMoveUnsub();
}
// 鼠标移动监听
// @HostListener('mousemove')
mouseLisener() {
this.isDirty = true;
console.log('mousemoved');
}
ngOnDestroy(): void {
this.timer ?? clearInterval(this.timer);
}
}
这是优化后的控制台,在保证功能不变的情况下,Console控制台也清净了不少。除了setInterval
会间歇性地触发变更检测,其它的鼠标事件已经都不会触发变更检测了。
接下来,我们把V3的性能profile拿出来对比看一下。在V3版本下,不管你在界面上如何操作鼠标,都不会触发变更检测了。从图上我们也能看出,Angular变更检测的周期基本上每隔5s才会触发一次,与setInterval的执行周期一致,这也是符合预期的。将V3的火焰图跟V1的对比一下你就会发现,此时变更检测的次数远小于之前。我大致看了一下,每次变更检测的耗时大概在0.1ms~0.6ms之间。这么看,同样的功能,性能之间的差异有着天壤之别!
V4
最后一版我们再把最后一点小问题优化掉,从V3图上我们还能零星看到几次rendering...
日志,之前说了,这是由于setInterval
导致的。V4版本就是要把setInterval
产生的变更检测也优化掉。
STEP1
在经过前面的优化学习之后,这里的处理对大家说应该十分好理解了。这里,我们同样通过runOutsideAngular
方法处理setInterval
的执行。
// app.component.v4.ts
export class AppComponent {
// ...
constructor(private render2: Renderer2, private elementRef: ElementRef, private zone: NgZone, private cd: ChangeDetectorRef) {
// 在OutZone中注册监听事件,事件触发时不会引起变更检测
this.zone.runOutsideAngular(() => {
// 进一步消除setInterval的变更检测
this.startTiming();
this.mouseMoveUnsub =
this.render2.listen(this.elementRef.nativeElement, 'mousemove', this.mouseLisener.bind(this));
});
}
// ...
}
但是,当我们把this.startTiming();
放到runOutsideAngular
后,我们发现如果5s没有对界面操作,界面也不会变成期待您下次光临!!
。这里希望大家能先自己想想这是为什么,然后再往下看。
STEP2
NgZone的本质是用来配合执行变更检测的,当我们使用runOutsideAngular
后,回调函数的执行将会脱离变更检测。又由于在zone.js中,Zone的执行上下文是会传递的;当setInterval中的回调执行是,它依旧会在OutZone中执行。试想一下,当this.stopTiming();
执行在OutZone中的时候,this.isLogined = false;
根本不会引起变更检测,则UI也不会进行渲染。此时,你会发现,当你脱离变更检测的时候,双向绑定的魔力也会消失。
此时,我们就需要手动唤醒变更检测。这里唤醒变更的方式有多钟:
- 通过NgZone.run方法可以让被执行方法回到InnerZone中执行,从而触发变更检测
- 通过ChangeDetectorRef.detectChanges手动进行变更检测
// app.component.v4.ts
export class AppComponent {
isLogined = true;
timer: any;
isDirty = false;
mouseMoveUnsub: any;
constructor(private render2: Renderer2, private elementRef: ElementRef, private zone: NgZone, private cd: ChangeDetectorRef) {
// 在OutZone中注册监听事件,事件触发时不会引起变更检测
this.zone.runOutsideAngular(() => {
// 进一步消除setInterval的变更检测
this.startTiming();
this.mouseMoveUnsub =
this.render2.listen(this.elementRef.nativeElement, 'mousemove', this.mouseLisener.bind(this));
});
}
// 变更检测时会被触发
ngDoCheck() {
console.log('rendering...');
}
startTiming() {
console.log('startTiming!');
this.timer = setInterval(() => {
// 当检测没到有鼠标事件触发过,则停止检测
if (!this.isDirty) {
this.stopTiming();
}
this.isDirty = false;
this.cd.detectChanges();
}, 5000);
}
stopTiming() {
clearInterval(this.timer);
// 方法一:通过this.zone.run恢复变更检测
// this.zone.run(() => {
// this.isLogined = false;
// });
// 方法二:通过this.zone.run恢复变更检测
this.isLogined = false;
this.cd.detectChanges();
// 解除事件监听
this.mouseMoveUnsub();
}
// 鼠标移动监听
// @HostListener('mousemove')
mouseLisener() {
this.isDirty = true;
console.log('mousemoved');
}
ngOnDestroy(): void {
this.timer ?? clearInterval(this.timer);
}
}
总结
本期内容先概念,后示例,通过一个性能优化案例把本期所学的知识实践了一下。从案例中可以看到,当场景适合时,“摆脱”变更检测带来的性能提升是巨大的。同时,你还会发现,我们其实可以不经过zone.js就触发变更检测,而且性能还不错,这是不是说明我们可以抛弃zone.js了呢?
这其实是一个很有意思的话题,尤其在lvy和OnPush策略推出后。在此,我有些个人观点想分享一下。我个人认为虽然我们有很多途径可以摆脱zone.js和变更检测,但是这些“摆脱”都很临时。尤其当我们清楚了zone.js在背后作出的努力后,我们就知道完全让用户自己去控制变更检测是多么恐怖,就好像一夜回到了ajax + jQuery的时代,每一次的UI渲染都需要用户手动执行。所以说,这些“摆脱”方法其实是在前端业务复杂化到一定程度后,同时人们对极致性能的追求到一定程度后所催生的一种产物;是Angular团队为了迎合更广泛的需求上的一种调整。zone.js毕竟给大家带来了太多便利,想要完全放弃会有不少困难。
我之前看到过一个Angular的大佬在讲lvy,视频最后的问答阶段,有位观众问Angular会不会抛弃zone.js?这位大佬大概的回答是:他不认为zone.js会消失。他们只是建议大家在“傻瓜”节点中通过OnPush策略减少对zone.js的使用,但是针对大多数应用,zone.js不会消失。
油管链接奉上,我就不一句一句给大家翻译了。
最后的最后,下一期我会详细讲一下InnerZone的执行原理,喜欢的请持续关注~~~