深入理解 Zones
Zones 是一个持续异步任务的执行上下文,允许 Zone 的创建者观察并控制其区域内代码的执行,Zones 的职责是对宿主环境的任务调度和处理,例如被用来 Debug 或测试等,对于某些框架如 Angular,Zones 主要被用作通知变化监测。
创建子 Zone
在深入理解 Zones 之前,我们需要了解 Zones 是如何创建的。通过引入 zone.js 库,可以在应用中全局的使用 Zone,该 Zone 我们通常称之为跟 Zone。而创建子 Zone 只需要在父 Zone 中 fork 另一份实例即可,示例代码如下:
let rootZone = Zone.current;
// 从父 Zone 中 fork 一个新的实例 ZoneA
let zoneA = rootZone.fork({name: 'zoneA'});
通常情况下,我们需要将不同的框架或者业务代码在不同 Zone 的执行上下文中运行,Zone.current 表示当前执行环境下的宿主 Zone,唯一改变 Zone.current 的方式是使用 Zone.prototype.run() 方法,可以使用 run() 方法来控制 Zones 的进入或退出。通过下面的例子来加深对这个概念的理解,示例代码如下:
zoneA.run(fnOuter() => {
// 通过 `run()` 方法,`Zone.current` 被更新,当前 Zone 为 zoneA
console.log(Zone.current === zoneA);
// Zones 可以相互嵌套
rootZone.run(fnInner => () {
// 通过 `run()` 方法,`Zone.current` 被更新,当前 Zone 为 rootZone
// 同时让当前执行环境逃离 zoneA
console.log(Zone.current === rootZone);
});
});
跟踪异步操作
通过对上文的理解,我们知道了如何将某框架或者业务逻辑代码的执行环境加入或逃离某个 Zone,这对异步操作的跟踪是有意义的。通过下面的例子来说明,示例代码如下:
let rootZone = Zone.current;
let zoneA = rootZone.fork({name: 'A'});
setTimeout(timeoutCb1() => {
console.log('该 callback 将在 rootZone 中执行', Zone.current === rootZone);
}, 0);
zoneA.run(run1() => {
console.log('该 callback 将在 zoneA 中执行', Zone.current === zoneA);
setTimeout(timeoutCb2() => {
console.log('该 callback 将在 zoneA 中执行', Zone.current === zoneA);
}, 0);
});
一旦异步工作被有序的调度执行的时候,回调函数将在与调用异步 API 时存在的 Zone 中执行,通过包装到 Zone 中的回调,所有的这些操作导致的调度任务将会被指向当前的 Zone 中。跟踪异步操作重要的作用是允许通过在 wrapCallback 之前或之后使用不同的请求方式来拦截它们。在某些情形下,包裹后的 wrapCallback 在有进程调度时,将在当前的 wrapCallback 完成调度,以确保每个异步任务的相互不影响。
上文提到了一个重要概念是回调包装,Zone 一个重要的方面就是支持跨异步操作,为了实现跨异步操作,当有一个任务需要通过异步 API 获取数据时,是需要捕获并恢复当前 Zone。举个例子,在某个 Zone 的上下文中执行一个异步操作如 setTimeout,这个 setTimeout() 方法需要完成的步骤是:
- 通过
Zone.current捕获当前 Zone; - 在代码中包装 callback,一旦被包装的
callback()方法被执行则将会恢复当前 Zone
也就是说,管理当前代码的规则将保留在将要执行的异步任务中,它将区别于其他的 Zone,不同的 Zone 关联着不同的异步任务并且有自己的规则,随着这些异步任务被处理,每一个异步的 wrapCallback 将准确的恢复其当前 Zone,同时保存以备下次异步任务的时候再次被调度。
为了说明这两个步骤的必要性,我们举例说明如下,通过调用 fetch() 方法来返回一个 promise,在 fetch() 方法内部可以使用它当前的 Zone 来做错误处理,而在应用中通过 then() 来返回请求结果,这使得跟踪异步操作井然有序。
Zones 的继承与可组合性
父子 Zone 之间存在着继承关系,同样的也遵循了 JavaScript 的原型继承,Zone 的每个函数在被执行时都会创建自己的执行环境,当代码在某一执行环境中运行时,会创建由变量对象构成的一个作用域链,这确保了执行环境对所有变量或者函数的有序访问。通过下面的例子来理解一下 Zone 的继承,示例代码如下:
let rootZone = Zone.current;
let zoneA = rootZone.fork({name: 'zoneA', properties: {a: 1, b:1}});
let zoneB = zoneA.fork({name: 'zoneA', properties: {a: 2}});
console.log('zoneA 属性 a 的值是:', zoneA.get('a')); // 1
console.log('zoneA 属性 b 的值是:', zoneA.get('b')); // 1
console.log('zoneB 属性 a 的值是:', zoneB.get('a')); // 2
// 在当前 Zone 获取不到某属性是,将向父 Zone 查询改属性
console.log('zoneB 属性 b 的值是:', zoneB.get('a')); // 1
Zones 可以通过 Zone.fork() 来组合在一起,并且所有运行在 Zone 中的业务代码都有一个根 Zone,确保所有的代码都在其中,并且其将有很多的子 Zone。子 Zone 有其独立的运行规则,其功能大致如下:
- 在当前 Zone 处理请求而不委派
- 将拦截委托给父 Zone,并且可选的在 wrapCallback 之前或者之后添加钩子
父子 Zone 之间通过 ZoneDelegate 来实现交互的,一个子 Zone 不能简单的调用父 Zone 中的方法,要实现继承父类方法的功能,需要在子 Zone 创建一个回调函数并且绑定到父 Zone 中。我们要做的就是在有异步操作触发该回调的时候拦截它,以便来确定是否需要通过 ZoneDelegate 再进一步触发父类中的方法。
并不是每个子 Zone 都重写了父 Zone 中的方法,不过 ZoneDelegate 存储了当前 Zone 最近的父 Zone 的一份实例,以便向上调用相关的方法。下面通过表格进一步说明他们之间的关系:
| Zone | ZoneDelegate | 描述 |
|---|---|---|
| run | invoke | 当运行在 run() 方法中的代码块被执行,ZoneDelegate 的钩子 invoke() 被调用,则表示在不改变当前 Zone 的情况下,允许向父级 Zone 派发钩子,即执行父 Zone 对应的钩子。 |
这就是父子组件间的可组合型。可组合性确保了 Zone 之间的职责分明,例如顶层的父 Zone 可以处理一些全局的错误处理,而子 Zone 则可以选择做用户行为跟踪。
理解 Zones
通过上文对 Zones 的介绍,我们对其有了一个大致的认识。Zones 实际上是 Dart 的一种语言特性,是一种用于拦截和跟踪异步工作的机制,可以简单地将其理解为一个异步事件拦截器,也就是说 Zones 能够 hook 到异步任务的执行上下文,并在一些关键节点上重写相应的钩子方法,以此来完成某些操作。Zone 是一个全局对象,其配置了相应的规则用于拦截和跟踪异步回调,主要功能如下:
- 拦截异步任务调度
- 为跨异步操作和错误处理提供当前 Zone 的包裹回调
- 提供一种方式将数据附加到 Zone
- 在提供的上下文中执行错误处理
- 截取阻塞方法
Zone 本身不做任何事情,它仅仅在执行异步任务或事件的时候去执行相应的钩子。zone.js 库重写了(monkey patches)所有的浏览器异步 API 并且在执行的时候将这些异步任务和事件重定向到新的 API 上,即 Zone 能够获取到异步任务或事件的执行上下文,并在一些关键节点上重写相应的钩子方法,以此来完成某些操作。下面以猴子补丁 setTimeout 来说明其被重写的过程,示例代码如下:
// 原生的 setTimeout
let originalSetTimeout = window.setTimeout;
// 重写 setTimeout
window.setTimeout = function(callback, delay) {
return originalSetTimeout(
// 在 Zone 中通过包装回调函数重写 setTimeout
Zone.current.wrap(callback),
delay
);
}
// zone.js 源码缩略
Zone.prototype.wrap = function (callback, source) {
// ...
var _callback = this._zoneDelegate.intercept(this, callback, source);
var zone = this; // 捕获当前 Zone
return function () {
// 在当前的 Zone 中执行最初的 callback
return zone.runGuarded(_callback, this, arguments, source);
};
};
上面的代码示例中,Zone.prototype.wrap() 方法用于重新包装 callback,该 callback 通过 Zone.prototype.runGuarded() 来执行,执行的过程中将会被 ZoneSpec.onInvoke() 拦截并在该函数中处理,Zone.prototype.runGuarded() 方法类似于 Zone.prototype.run(),不同的是,除了将包裹函数执行在当前 Zone 之外,它还处理异步操作的异常捕获,任何的异常都会被捕获,并在 Zone.HandleError() 方法中处理。
通过前面的介绍可以了解到,Zones 以同样的接口、不同的方式实现并重写了一系列与事件相关的标准方法。因此,当开发者使用标准接口时,实际上会先调用 Zones 的重写方法,再由这些方法调用底层的标准方法。这种对上层应用透明的设计,使得在引入 Zones 的时候,原有代码不需要做太大的改动。
reference
- https://github.com/angular/zone.js/blob/master/dist/zone.js.d.ts
- https://docs.google.com/document/d/1F5Ug0jcrm031vhSMJEOgp1l-Is-Vf0UCNDY-LsQtAIY/edit#
- https://www.joshmorony.com/understanding-zones-and-change-detection-in-ionic-2-angular-2/
- http://stackoverflow.com/questions/25915634/difference-between-microtask-and-macrotask-within-an-event-loop-context
- https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
Zones 的继承与可组合性中代码的最后一行
console.log('zoneB 属性 b 的值是:', zoneB.get('a')); // 1
// 这里是get('a')还是get('b')..