啸达同学的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的执行原理,喜欢的请持续关注~~~
OpenTiny 社区招募贡献者啦
OpenTiny Vue 正在招募社区贡献者,欢迎加入我们🎉
你可以通过以下方式参与贡献:
- 在 issue 列表中选择自己喜欢的任务
- 阅读贡献者指南,开始参与贡献
你可以根据自己的喜好认领以下类型的任务:
- 编写单元测试
- 修复组件缺陷
- 为组件添加新特性
- 完善组件的文档
如何贡献单元测试:
- 在
packages/vue
目录下搜索it.todo
关键字,找到待补充的单元测试 - 按照以上指南编写组件单元测试
- 执行单个组件的单元测试:
pnpm test:unit3 button
如果你是一位经验丰富的开发者,想接受一些有挑战的任务,可以考虑以下任务:
- ✨ [Feature]: 希望提供 Skeleton 骨架屏组件
- ✨ [Feature]: 希望提供 Divider 分割线组件
- ✨ [Feature]: tree树形控件能增加虚拟滚动功能
- ✨ [Feature]: 增加视频播放组件
- ✨ [Feature]: 增加思维导图组件
- ✨ [Feature]: 添加类似飞书的多维表格组件
- ✨ [Feature]: 添加到 unplugin-vue-components
- ✨ [Feature]: 兼容formily
参与 OpenTiny 开源社区贡献,你将收获:
直接的价值:
- 通过参与一个实际的跨端、跨框架组件库项目,学习最新的
Vite
+Vue3
+TypeScript
+Vitest
技术 - 学习从 0 到 1 搭建一个自己的组件库的整套流程和方法论,包括组件库工程化、组件的设计和开发等
- 为自己的简历和职业生涯添彩,参与过优秀的开源项目,这本身就是受面试官青睐的亮点
- 结识一群优秀的、热爱学习、热爱开源的小伙伴,大家一起打造一个伟大的产品
长远的价值:
- 打造个人品牌,提升个人影响力
- 培养良好的编码习惯
- 获得华为云 OpenTiny 团队的荣誉和定制小礼物
- 受邀参加各类技术大会
- 成为 PMC 和 Committer 之后还能参与 OpenTiny 整个开源生态的决策和长远规划,培养自己的管理和规划能力
- 未来有更多机会和可能
关于 OpenTiny
OpenTiny 是一套企业级组件库解决方案,适配 PC 端 / 移动端等多端,涵盖 Vue2 / Vue3 / Angular 多技术栈,拥有主题配置系统 / 中后台模板 / CLI 命令行等效率提升工具,可帮助开发者高效开发 Web 应用。
核心亮点:
跨端跨框架
:使用 Renderless 无渲染组件设计架构,实现了一套代码同时支持 Vue2 / Vue3,PC / Mobile 端,并支持函数级别的逻辑定制和全模板替换,灵活性好、二次开发能力强。组件丰富
:PC 端有100+组件,移动端有30+组件,包含高频组件 Table、Tree、Select 等,内置虚拟滚动,保证大数据场景下的流畅体验,除了业界常见组件之外,我们还提供了一些独有的特色组件,如:Split 面板分割器、IpAddress IP地址输入框、Calendar 日历、Crop 图片裁切等配置式组件
:组件支持模板式和配置式两种使用方式,适合低代码平台,目前团队已经将 OpenTiny 集成到内部的低代码平台,针对低码平台做了大量优化周边生态齐全
:提供了基于 Angular + TypeScript 的 TinyNG 组件库,提供包含 10+ 实用功能、20+ 典型页面的 TinyPro 中后台模板,提供覆盖前端开发全流程的 TinyCLI 工程化工具,提供强大的在线主题配置平台 TinyTheme
联系我们:
- 官方公众号:
OpenTiny
- OpenTiny 官网:https://opentiny.design/
- OpenTiny 代码仓库:https://github.com/opentiny/
- Vue 组件库:https://github.com/opentiny/tiny-vue (欢迎 Star)
- Angluar组件库:https://github.com/opentiny/ng (欢迎 Star)
- CLI工具:https://github.com/opentiny/tiny-cli (欢迎 Star)
更多视频内容也可以关注OpenTiny社区,B站/抖音/小红书/视频号。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。