zone.js 由入门到放弃之五——NgZone & ApplicationRef 源码分析
啸达同学刚写 zone.js 系列就说过,NgZone 影响着 Angular 中的变更检测,历时一个多月的笔耕不辍,终于到了他初次下笔时的目的地~
zone.js 系列
zone.js 由入门到放弃之一——通过一场游戏认识zone.js
zone.js 由入门到放弃之二——zone.js API大练兵
zone.js 由入门到放弃之三——zone.js 源码分析【setTimeout 篇】
zone.js 由入门到放弃之四——Angular对zone.js的应用
初见 NgZone
其实在上一篇文章中,大家已经初步窥探过 NgZone 的芳容了。而且我们也知道了,在 NgZone 中维护了 OuterZone 和 InnerZone 两个 Zone。今天的这篇文章,我们主要分析一下 InnerZone,并看一下 InnerZone 是如何跟 Angular 的变更检测联系到一起的。
InnerZone 四方法
NgZone 中 InnerZone 的创建是通过forkInnerZoneWithAngularBehavior
完成的,创建过程的简化版如下,其中又能看到很多熟悉的勾子函数。这里简单复习一下这几个勾子的意义:
onInvokeTask
:zone.js 会在初始化的时候将异步方法都 Pathc 成 ZoneTask,从而跟踪异步任务的执行情况的。onInvokeTask
就是其中的一个勾子函数,它会在异步任务执行回调的时候触发。onInvoke
:onInvoke
会在我们手动执行 zone.run()的时候执行。onHasTask
:是针对整个任务队列状态改变的监听,当检测任务队列中有任务进入、或是有任务执行完出队列的时候会被执行。onHandleError
:当有异常抛出时被执行
InnerZone 对异步任务的控制精华基本上就全部浓缩在这几个勾子函数中了,与此同时,为了更好地配合对异步任务的跟踪,NgZone 中还定义了很多状态监控字段。只有理清这些字段的含义才能继续往下深入代码。
不熟悉 zone.js 原理的可以回看一下 zone.js 由入门到放弃之一和 zone.js 由入门到放弃之二(链接见文首)
InnerZone 五状态
接下来这几个状态属性会贯穿在后面的源码分析的全部过程中,我们也会通过对这几个状态的跟踪了解一下 InnerZone 事件跟踪的原理。
hasPendingMacrotasks: boolean 队列中是否有待执行的宏任务
hasPendingMicrotasks: boolean 队列中是否有待执行的微任务
_nesting: number 队列中待执行任务的个数
isStable: boolean 当任务队列中既没有待执行的宏任务,也没有待执行的微任务时,isStable 为 ture,表示当前是个稳定的状态。反之则代表非稳定状态。
lastRequestAnimationFrameId: number 这个状态有些特别,它是一个延时器,后面会展开解释。
代码走读
前面在介绍 zone.js 的时候我们说过,zone.js 把异步任务分为 MacroTask、MicroTask 和 Event 三种。今天我们就分别把这三种任务都按流程分析一遍。从难易程度上看,MacroTask 最简单,Event 相对最复杂。接下来,我们就按照这个顺序讲解。
MacroTask
之前在 zone.js 由入门到放弃之三中,详细介绍过 zone.js 对 setTimeout 的 Patch 过程,如果不了解具体过程的强烈建议先浏览一下那篇文章。
这一次,我们还是通过个 setTimeout 事件来跟踪 NgZone 的处理过程,测试代码很简单,如下所示。
因为 zone.js 可以感知到任务队列的变化情况,所以当setTimeout
执行时,它可以知道当前有一个宏任务来了,同时会触发 onHasTask 勾子。
onHasTask
当onHasTask
"检测"到有宏任务到来时,会把hasPendingMacrotasks
设置为 true。
此时,NgZone 中的几个状态值大概是这个样子的,hasPendingMacrotasks 变为 true,表示当前有一个待执行的 MacroTask。
接下来,zone.js 会通过调用scheduleFn
,并把封装后的回调函数放在 Timer 队列中等待时钟到达。
onInvokeTask
当时钟到达以后,事件循环会把封装后的回调函数放在任务队列中等待执行。当执行到回调时,回调会触发task.invoke
函数,接下来就会唤醒 onInvokeTask 勾子函数。
delegate.invokeTask(target, task, applyThis, applyArgs);
是用来调用真正的回调函数的。除了这行,我们可以看到在回调之前先后分别还各有一个方法:onEnter
和onLeave
。
onEnter
onEnter
执行过程中,_nesting
会自增,表示了当前新增一个待执行任务。当有任务要执行时,之前的稳定状态会被打破,同时触发一个onUnstable
事件。这个onUnstable
事件被 ApplicationRef 订阅,ApplicationRef 会根据这个事件同步修改它自身的稳定状态(ApplicationRef 的代码后面讲解)。
onLeave
onLeave
函数执行的时候,说明 MarcoTask 的回调已经执行完毕,_nesting
会执行一次自减操作。接下来又执行了checkStable
函数。
checkStable
函数非常关键!每当执行到 checkStable 的时候,都是变更检测执行的关键。以至于这个函数的每一行都值得拿出来讲一下,我在代码中标记了序号,这样方便后面走读代码。
checkStable
既然是判断是否进行变更检测关键,那么 1 标识的 if 子句就是判断的关键。代码的意思大概就是,只有确保当前没有任何待执行的任务,同时当前状态为不稳定状态的时候才需要触发变更检测。代码 2 标识了一个成对的
_nesting
自增、自减操作。这里这么做的原因是代码 3 这里抛出了事件,对该事件的订阅实际上也是一个异步任务。所以这里通过_nesting
的自增、自减操作说明这里是有一个异步任务的。代码 3 就是变更检测的关键了,AppliactionRef 会订阅
onMicrotaskEmpty
事件,每当onMicrotaskEmpty
触发后,AppliactionRef 就会执行一次变更检测。代码 4 这里大家可能会有疑问,为什么在这里还要对
hasPendingMicrotasks
进行一次判断?这是因为在代码 3 这里,对onMicrotaskEmpty
的订阅者有可能会在订阅回调中再执行一些异步任务,就像下面这样。此时,并不能保证在checkStable
的过程中,不会有新的任务进入到待执行队列。所以这里,又对hasPendingMicrotasks
的状态做了一次判断。确保在状态变为稳定之前,任务队列中不存在任务微任务。
代码 5 是对外触发一个状态稳定的事件,这个事件跟
OnEnter
函数中那个onUnstable
相对。但是你可能会好奇,这里为什么要在runOutsideAngular
中执行。我这里解释下,仅代表个人见解。onStable
和onMicrotaskEmpty
存在一样的问题,因为都是可观察对象,所以存在订阅者在回调继续执行异步任务的问题。如果在onStable
的订阅中执行异步任务,那 NgZone 的状态马上有会变成非稳定的,这将会陷入一个无限的死循环中,NgZone 会在稳定和不稳定状态之间来回切换,永不停止。所以这里使用runOutsideAngular
,让 zone.js 放弃对这里的代码进行跟踪。这样,根据上一讲我们学过的内容,runOutsideAngular
中执行异步不会触发变更检测,当然也不会触发 NgZone 的状态变化。改变 zone 的状态为稳定。
这里我多补充一点知识,我之前看到这里的代码的时候也觉得有点绕,所以我在这里做了大量的测试。结果发现,如果在 onStable 的订阅回调中再使用 zone.run 执行异步任务的时候就会造成一个无限的死循环。这里是我的最小实现仓,够胆的可以试试,你的浏览器会在瞬间崩溃。当然,我也给官方提了issue,原作者也证实了这的确是个问题,感兴趣的可以跟踪一下,持续关注。
截止到这里,我们再看一下 NgZone 的几个状态指标。此时队列中不存在待执行的任务,NgZone 会把自身状态修改为稳定态。
onHasTask
整个 setTimeout 跟踪的最后一步还是这个勾子,这次,勾子函数中会把 hasPendingMacrotasks 置为 false。此时,几个状态已经恢复为最初的问题状态,Angular 也在这个过程中执行了一次变更监测。
MicroTask
MicroTask 和 MacroTask 的行为大致上一致,只不过由于 zone.js 在处理 MicroTask 和 MacroTask 时有一丢丢的区别,导致这里处理也会有一点不同,这个我会在下文做专门解释。当然,如果你还想关注 zone.js 在处理 MicroTask 和 MacroTask 时到底有什么不一样的,可以关注一下我的下一篇文章(如果有的话),里面会像本系列的第三期一样,详细解释 zone.js 处理 Promise 的技术细节。
onHasTask
onHasTask 跟之前没什么区别,执行过后,状态如下。与 MacroTask 不同,这次是 hasPendingMicrotasks 变为 true,表示队列中有一个待执行的微任务。
onInvokeTask
MicroTask 和 MacroTask 在这个勾子中的处理过程基本上是相同的。但是 MicroTask 在回调执行的时候和 MacroTask 还是有一点差异的。前面部分,我们讲 MacroTask 的时候,delegate.invokeTask(target, task, applyThis, applyArgs);
这句会直接触发 setTimeout 的回调函数执行。但是在 MicroTask 中,微任务的回调外部还会包装一层zone.run
,导致 MicroTask 的回调会通过onInvoke
勾子执行。
onInvoke
可以看到onInvoke
和onInvokeTask
函数的内容是差不多的。onEnter
和onLeave
的调用也基本一致,所以这里就不专门分析了。
当这个函数执行结束后,几个状态值变化如下。
onHasTask
最后一个执行的还是onHasTask
函数,这个函数执行完毕后,几个状态又回到初始状态。
Event
Event 的执行方式跟 MacroTask 和 MicroTask 都不太一样。还记得之前我们在讲 NgZone 的 5 大状态的时候,有一个lastRequestAnimationFrameId
一直没有用到。那么,在 Event 的处理过程中,我们会看到它的作用。
onInvokeTask
Event 的处理入口是onInvokeTask
而不是onHasTask
,onEnter
和delegate.invokeTask
与之前都差不多,但是在 finally 子句中,你会发现 Event 的处理中多了一个delayChangeDetectionForEventsDelegate
函数。其实从函数的函数名大概能猜个七七八八,这个是一个事件延时处理的函数。
delayChangeDetectionForEventsDelegate
其实我们在上一期讲解中已经介绍了一些通过 NgZone 进行性能调优的手段,那么这个函数的产生实际上也是用于性能上的优化。我们知道,浏览器很多事件诸如 mousemove、scroll 这些都会在短时间内产生大量的事件。如果每个这样的事件都会触发一次 Angular 的变更检测的话,那么对性能上的要求是很大的。所以,NgZone 也需要在内部对于这些浏览器的事件做一些特殊处理,让大量的事件积攒一段时间后再统一做一次变更检测。
那么delayChangeDetectionForEventsDelegate
中实际调用的方法是delayChangeDetectionForEvents
,所以我们重点关注一下delayChangeDetectionForEvents
函数的源码。
代码 1 这里,我们第一次见到对 lastRequestAnimationFrameId 的判断,当第一个 Event 到来时,这里的 lastRequestAnimationFrameId 还是初始值-1
zone.nativeRequestAnimationFrame
的调用实际上调用的是Window.requestAnimationFrame
。这里,NgZone 实际上是希望通过requestAnimationFrame
收集这一帧内的所有事件,在这一帧结束后,再统一执行一次变更检测。requestAnimationFrame
执行的返回值会赋值给lastRequestAnimationFrameId
,这样,在接下来代码每次进入到代码 1 处的时候,函数会直接返回。updateMicroTaskStatus
被用来更新微任务状态的。那么这里执行之后,状态值中的 hasPendingMicrotasks 会变为 true。这里这么做是为了收集 Event 的时候可以阻塞微任务触发变更检测,这么做的原因是为了确保 Event 事件的执行顺序不会被微任务打乱。这里要详细介绍又会有很大篇幅,感兴趣的可以自己看下这个issue;不想关注的可以先跳过这里。
当当前帧执行完毕、下一帧要执行的时候会调用一次
checkStable
函数。这个函数在前面讲过,它是触发 Angular 变更检测的关键。通过执行该方法,Angular 会通过 ApplicationRef 执行变更检测动作。
再见 ApplicationRef
上一节中,我们讲过一点 ApplicationRef 相关的知识,这一次,我们重点看下 ApplicationRef 跟变更检测相关的代码。
变更检测
前面说到,NgZone 在checkStable
中,如果发现当前已经没有待执行的任务的时候,会触发一个onMicrotaskEmpty
事件。在这里,这个事件会被 ApplicationRef 所捕获。捕获后,会执行ApplicationRef.tick
,而这个 tick 就是变更检测的入口。
tick
在tick
方法中,我们可以看到 ApplicationRef 通过调用视图的detectChanges
方法,让组件完成自上而下的变更检测。上一篇文章中,我们介绍过一些手动执行变更检测的方法,其中有提到过ChangeDetectorRef.detectChanges()
这个方法。这个方法可以对当前组件以及当前组件的子组件进行进行变更检测。那么这里看到的view.detectChanges()
跟ChangeDetectorRef.detectChanges()
又有什么关系?
其实从_views
类型的继承链可以发现,_views
的类型InternalViewRef
继承自ViewRef
,ViewRef
又继承自ChangeDetectorRef
。所以调用view.detectChanges()
就相当于调用了ChangeDetectorRef.detectChanges()
,从而完成一次自上而下的变更检测。
以上就是 NgZone 和 ApplicationRef 之间的配合关系。我们整体再回顾一下整个系列课程的内容。zone.js 通过 Monkey Patch 对所有异步方法进行打包;打包后的异步方法被植入了很多勾子函数,而这些勾子函数可以被 zone.js 的上下文检测到,从而完成对异步任务的监控。
NgZone 是对 zone.js 的一个使用案例,NgZone 通过维护 InnerZone 和 OuterZone 两个 Zone 实现了对 Angular 应用中的异步任务的监控和去监控。NgZone 同时在内部也维护了几个对异步任务监控的状态信息,通过这些信息实现了和 ApplicationRef 之间的“通信”,最终由 ApplicationRef 完成对 Angular 应用的监控。
本文小结
到这里,今天的内容就介绍的差不多了。最后,这里还需要像读者说明一点,在 NgZone 中跟踪 Task 的运行是一件比较难的事情,本文所有这些 Task 的举例其实都是理想化的。比如说,在举例 setTimeout 的时候,你会发现当你想在 Angular 应用中对异步 Task 跟踪的时候,会有很多其它 Task 同时在执行着,这些 Task 经常会在你调试跟踪的时候对你形成“干扰”。所以,本文的这些举例只是希望让大家看过后,能大致对每种不同任务在 NgZone 中流程有个认识,而真实的过程会远比我今天讲的内容复杂的多。这同时也从侧面反映出,zone.js 默默对 Angular 作出多大的贡献。
大完结
本系列分享历时将近 1 个多月,加上前期的一些分析和总结,我个人大概持续关注 zone.js 有两个多月了。最后的最后,我也分享几点个人感受:
有人说 zone.js 是暴力美学,我个人感觉可能美的地方更多一些吧。作为 Angular 变更检测的核心,Angular 的变更检测在三大框架中是独一份的存在。我觉得比起其它两个通过数据劫持和虚拟 Dom 的方式进行数据绑定的方式,zone.js 显得还是要温柔一些的。毕竟数据劫持是直接“污染”了数据的,而 zone.js“改造”的是工具。我没法说谁更好,只是个人更偏向于后者。
截止到现在,我个人也没有完全看完 zone.js 的源码,但是我希望我会在后续的工作中持续关注这个产品。同时我也看到 JiaLi(zone.js 作者)为了他的作品不断地对 zone.js 进行改进。所以,请他加油,我希望 zone.js 可以越来越好!不过话说回来,JiaLi 想在 Angular 社区完成一个 PR 是不是太难了点啊。我看了他好多的修改,经常要等好久才能审核通过,有点心疼他。🤣
其实最开始的时候,我只是想自己学学 zone.js 的,并没有规划这个系列分享。但是,我在学习源码的时候,苦于能找到的资料太旧又太少,所以就准备自己写一个有史以来最通俗、最全面、也最适合中国人学的 zone.js 材料。当然,前两个“最”我可能还不配;但是第三个最,我觉得还是可以搏一搏的✌。
OpenTiny Vue 招募贡献者啦!
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 是一套企业级组件库解决方案,适配 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-official 一起参与交流前端技术~
OpenTiny 代码仓库:https://github.com/opentiny/
Vue组件库:opentiny.design/tiny-vue
Angular组件库:opentiny.design/tiny-ng
欢迎进入代码仓库 Star🌟TinyVue、TinyNG、TinyCLI~
如果你也想要共建,可以进入代码仓库,找到 good first issue
标签,一起参与开源贡献~
往期文章推荐
评论