这篇文章解释了Angular如何基于zone.js库来实现NgZone服务,以及它为何要这样做。读者通过本文可以了解到在没有zone.js时Angular框架如何运作以及它的自动变化检测何时会失效。
之前我读到的大部分文章都将Zone(zone.js)以及NgZone同Angular中的变化检测紧密联系在一起。虽说它们确实有关联,但是从技术角度来看,它们属于两个不同的部分。毋庸置疑,Zone和NgZone被用来在异步操作之后自动触发框架内的变化检测,但是自动监测机制实际是可以抛开它们独立运行的。因此,在本文的第一章,我将展示在不使用zone.js的情况下如何使用Angular。而第二章将详细介绍Angular如何通过NgZone与zone.js打交道。在本文末尾,我会分析为何有时候在使用像gapi这样的第三方库时,自动变化检测会失效。
我之前写了很多深入分析Angular中变化检测原理的文章,而本文是这一系列的最后一篇。如果你想全面了解变化检测的工作原理,我推荐你从这一篇 These 5 articles will make you an Angular Change Detection expert 开始去读一下这一系列文章。另外,需要说明的是,本文并不会详细介绍Zones(zone.js),而是旨在说明Angular通过构造NgZone去使用Zones的原理以及它们与变化检测机制之间的联系。如果想了解更多关于Zones的内容,可以阅读这篇文章:I reverse-engineered Zones (zone.js) and here is what I’ve found。
抛开Zone(zone.js)使用Angular
最开始的时候,我想伪造一个空的zone对象来方便展示Angular可以在不使用Zone的时候正常工作。不过自动Angular v5发布之后,官方提供了一种更加简单的方式:通过配置noop Zone来停止Zone的工作。
首先,我们需要移除对zone.js的依赖。后面我将会用stackblitz进行演示。因为该网站上是通过Angular-CLI来建立项目的,所以我会先从polyfils.ts(译者注:此处应该是polyfills.ts,估计是作者笔误)中将下面这段代码删掉
* Zone JS is required by Angular itself. */
import 'zone.js/dist/zone'; // Included with Angular CLI.
然后,按照下面的代码配置Angular去使用noop Zone
platformBrowserDynamic()
.bootstrapModule(AppModule, {
ngZone: 'noop'
});
此时如果你运行这个演示应用,可以看到变化检测是全面运行了的,并且组件的name属性会成功地被渲染到DOM中。
接下来,我们通过setTimeout来对这个属性进行更新
export class AppComponent {
name = 'Angular 4';
constructor() {
setTimeout(() => {
this.name = 'updated';
}, 1000);
}
可以看到,属性的变化并没有更新到页面上。这一点是可以理解的,因为NgZone被停掉了,所以变化检测不会自动被触发。不过,我们可以手动去触发检测来让它正常工作。具体的做法是,通过注入ApplicationRef服务并且调用它的tick方法来开启变化检测:
export class AppComponent {
name = 'Angular 4';
constructor(app: ApplicationRef) {
setTimeout(()=>{
this.name = 'updated';
app.tick();
}, 1000);
}
现在这个演示应用中属性变化就会成功地更新到页面中了。
小结一下,上面这个演示是想说明,zone.js还有特别是NgZone,它们并非变化检测实现逻辑的组成部分。只是相比于在特定时刻手动执行app.tick(),通过自动调用的方式可以很方便地实现自动变化检测。下文马上就将解释它们是怎么实现的。
NgZone是怎样使用Zones的
在我的前一篇有关Zone(zone.js)的文章中曾深入解释了Zone所提供的API及其内部运行机制。在该文章中,我解释了关于fork一个zone以及在特定zone中运行任务的核心概念。后面我将会用到这些概念。
另外,在那篇文章中,我还演示了Zone提供的两项功能——上下文传递以及待办异步任务追踪。而Angular所实现的NgZone服务则在很大程度上依赖于这个任务追踪机制。
NgZone本质上就是围绕着fork出的子zone进行的封装:
function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) {
zone._inner = zone._inner.fork({
name: 'angular',
...
这个被fork出来的zone记录在_inner属性中并且通常被叫做Angular Zone。当你使用NgZone.run()方法时,实际也是通过这个zone来运行回调函数的:
run(fn, applyThis, applyArgs) {
return this._inner.run(fn, applyThis, applyArgs);
}
而当前这个运行forking操作的zone则被记录在_outer属性中。当你运行NgZone.runOutsideAngular()时,实际使用的就是它:
runOutsideAngular(fn) {
return this._outer.run(fn);
}
我们通常使用这个方法在Angular Zone外执行一些性能消耗很大的操作,从而避免频繁地触发变化检测。
NgZone通过一个isStable属性来表明当前是否还有待办的微任务与宏任务。此外,NgZone中还定义了下面四种不同的事件:
+------------------+-----------------------------------------------+
| Event | Description |
+------------------+-----------------------------------------------+
| onUnstable | Notifies when code enters Angular Zone. |
| | This gets fired first on VM Turn. |
| | |
| onMicrotaskEmpty | Notifies when there is no more microtasks |
| | enqueued in the current VM Turn. |
| | This is a hint for Angular to do change |
| | detection which may enqueue more microtasks. |
| | For this reason this event can fire multiple |
| | times per VM Turn. |
| | |
| onStable | Notifies when the last `onMicrotaskEmpty` has |
| | run and there are no more microtasks, which |
| | implies we are about to relinquish VM turn. |
| | This event gets called just once. |
| | |
| onError | Notifies that an error has been delivered. |
+------------------+-----------------------------------------------+
另一方面,Angular在ApplicationRef中通过监听onMicrotaskEmpty事件来触发变化检测:
this._zone.onMicrotaskEmpty.subscribe(
{next: () => { this._zone.run(() => { this.tick(); }); }});
还记得我们在前文中正是通过这个tick()方法来实现应用的变化检测的。
NgZone是怎样执行onMicrotaskEmpty事件的
现在我们来看一下NgZone如何执行onMicrotaskEmpty事件。这个事件在checkStable中会被触发:
function checkStable(zone: NgZonePrivate) {
if (zone._nesting == 0 && !zone.hasPendingMicrotasks && !zone.isStable) {
try {
zone._nesting++;
zone.onMicrotaskEmpty.emit(null); <-------------------
而这个checkStable方法又会被三个钩子触发:
在之前那篇介绍Zones的文章中我们曾经分析过,后两个钩子被触发时,说明可能是微任务队列中发生了变化(译者注:这里指的是单个微任务的变化)。因此,Angular需要在这个时候去运行stable状态检测。另外,onHasTask钩子则会用于在追踪到整个任务队列发生变化时(译者注:这里指的是整个任务队列全空或者有新任务进入空队列时,是一种更加宏观的监测)去运行状态检测。
常见的坑
在stackoverflow上有一个关于变化检测最常见的问题是为什么有时在使用第三方库时组件中的数据变化并没有实时展示出来。比如说这里就有一个关于gapi的例子。对这类问题通常的解决办法是,像如下代码一样,将回调函数放在Angular Zone中来运行:
gapi.load('auth2', () => {
zone.run(() => {
...
不过,这个问题的有趣之处在于,为何Angular Zone并没有将这个请求登记在册,即为什么gapi的请求操作没有触发上述任何一个钩子?正是因为没有收到消息,所以NgZone才没能自动触发变化检测。
为了分析这个问题,我深入研究了一下gapi压缩后的源码,然后发现它是使用JSONP来进行网络请求的。这种方式并没有使用常规的AJAX接口,像XMLHttpRequest或者Fetch API等,而Zones对后面这些API是打了补丁并且进行追踪了的。相反地,这种方式定义了一个script的标签并为其指定源路径,然后定义了一个全局的回调函数。当被请求的script带着数据从服务端返回时会触发这个回调函数。Zones没法给这种方式打补丁或者进行检测,因此在Angular框架中只能对使用这种技术进行的网络请求不闻不问了。
下面这段就是gapi压缩版本中相关的代码,感兴趣的可以了解一下:
Ja = function(a) {
var b = L.createElement(Z);
b.setAttribute(“src”, a);
a = Ia();
null !== a && b.setAttribute(“nonce”, a);
b.async = “true”;
(a = L.getElementsByTagName(Z)[0]) ?
a.parentNode.insertBefore(b, a) :
(L.head || L.body || L.documentElement).appendChild(b)
}
这里的变量Z其实就是"script",而变量a则记录了请求的地址:
https://apis.google.com/_.../cb=gapi.loaded_0
该地址中的最后一段gapi.loaded_0就是定义的全局回调函数了:
typeof gapi.loaded_0
“function”
文章内容到此结束。感谢您的耐心阅读!