翻阅源码后,我终于理解了Zone.js
[翻译] 翻阅源码后,我终于理解了Zone.js
原文链接:I reverse-engineered Zones (zone.js) and here is what I’ve found
作者:Max Koretskyi
译者:秋天;校对者:Sunny Liu
注意本文主题不是 NgZone,而是与 NgZone 底层依赖的 zone.js 相关,如果熟读本文的内容,你可以创建自己的 NgZone,或者至少知道 NgZone 内部机制,想要更多了解 NgZone,可查看 Do you still think that NgZone (zone.js) is required for change detection in Angular?
Zones 是一种可以帮助开发者处理多个关联异步操作的新机制,Zones 通过 zone
来连接异步操作,开发者可以用它来做以下事情:
- 把一些数据关联到 zone
中,类似于某些语言中的本地线程存储(thread-local storage
),这样在 zone
中的任意异步操作都可以访问这些数据。
- 自动追踪指定 zone
还未执行完的异步任务,以便执行类似清理、渲染或者测试断言等。 - 分析发生在当前 zone
中异步执行的总时间,用于分析工作。
- 处理 zone
中所有未捕获的异常或者未处理的 promise reject
,阻断他们往上层冒泡。
大部分网上讲 Zone 的文章中,提及的 API 都已过时,而且都讲的比较浅显,因此本文作者使用最新的 API 来讲解 Zone,并且更加贴近 Zone 的实现。本文首先将描述 API 的使用,随后展示其异步任务的处理机制和拦截钩子。文章最后解释 Zone 内部工作原理。
Zones 当前处在 EcmaScript 标准的 stage 0 提案阶段,不过此时在 Node.js 的实现被搁置。不过目前提到的 Zone,通常都是指 zone.js
,这也是 github 仓库 和 npm 包 中的名字,然而本文仍使用 EcmaScript 标准中的名称 Zone
。
Zone API
首先看下使用 Zones 时最常用的方法,该类包含如下接口(注:可查看 源码 L136-L281):
class Zone {
constructor(parent: Zone, zoneSpec: ZoneSpec);
static get current();
get name();
get parent();
fork(zoneSpec: ZoneSpec);
run(callback, applyThis, applyArgs, source);
runGuarded(callback, applyThis, applyArgs, source);
wrap(callback, source);
}
Zones 中有一个关键的概念:current zone
,它是所有异步操作传播的上下文环境。它代表与正在执行的栈帧(stack frame
)或异步任务相关联的 zone
。current zone
可以通过 Zone.current
静态方法访问到。
每个 zone
都有一个 name
,它被用在工具链或者调试中,在 Zone
中还定义了一些方法来操作 Zones
:
- z.run(callback, ...)
,在给定的 zone
中同步的调用一个函数,在执行 callback
函数时,它设置 current zone
为z
。此外,当回调函数执行完毕时,它会重新设置回原来的值。在 zone 中执行一个回调函数,类似于进入(enter)到一个zone
中。
- z.runGuarded(callback, ...)
,与 run
方法类似,但是它能够捕捉运行时的错误,并且提供了一个拦截错误的机制。如果错误没有在父Zone
中被处理,那么会被重新抛出。
- z.wrap(callback)
生成一个新函数,保存 z
在闭包中,在执行 wrap
时实际执行的是 z.runGuarded
函数。如果回调函数后续传递给 other.run(callback)
,它仍然在z zone
中执行,而非other
。这个机制类似于 js 中Function.prototype.bind
的用法。
fork
方法将在下一节的末尾讲,Zone
除了上述方法之外还有一系列方法来控制运行、任务编排(scheduling)和取消任务:
class Zone {
runTask(...);
scheduleTask(...);
scheduleMicroTask(...);
scheduleMacroTask(...);
scheduleEventTask(...);
cancelTask(...);
这些方法都是低级别的方法,它们很少被开发者直接使用,本文将不作过多介绍。编排一个任务是 Zone
的一个内部操作,不过对开发者来说,它通常意味着一些异步的操作,例如 setTimeout
。
在调用堆栈中保存 zone
javascript VM 在它 自身的栈帧 中来执行函数,如果你有下面一段代码(注:可查看 StackBlitz CallStack Demo):
function c() {
// capturing stack trace
try {
new Function('throw new Error()')();
} catch (e) {
console.log(e.stack);
}
}
function b() { c() }
function a() { b() }
a();
在c
函数中,它的调用栈如下:
at c (index.js:3)
at b (index.js:10)
at a (index.js:14)
at index.js:17
c
函数中捕捉堆栈的方法已在 MDN 网站 中有所讲解。
调用栈使用图例表示如下:
在函数调用过程中,我们有3个栈帧,还有一个 global 上下文。
在常规的 JavaScript 环境中,c
函数栈帧不会与 a
函数栈帧有任何关联。而 Zone
允许我们把每个栈帧关联到一个特殊的zone
之中。例如,我们可以关联 a
和 c
栈帧到一个相同的zone
中。如下图所示:
下面来快速了解下zone
是如何做到这样的。
使用 zone.fork
来创建一个子 zone
Zones
中使用最多的特性是使用 fork
方法来创建一个新的 zone
,fork zone
可以创建一个新的 child zone
,并且这个child zone
的 parent
被设置为当前 zone
:
const c = z.fork({name: 'c'});
console.log(c.parent === z); // true
fork
方法背后的原理是简单地使用Zone Class
来创建了一个新的zone
(注:可查看 源码 L1052-L1055):
new Zone(targetZone, zoneSpec);
为了完成上文提到的关联a
与c
函数到相同的zone
中,我们首先需要创建这个zone
,即使用fork
方法:
const zoneAC = Zone.current.fork({name: 'AC'});
传递给 fork
方法的对象,我们称之为 zoneSpec
(Zone
规范),它具有下述属性(注:可查看 源码 L346-L453):
interface ZoneSpec {
name: string;
properties?: { [key: string]: any };
onFork?: ( ... );
onIntercept?: ( ... );
onInvoke?: ( ... );
onHandleError?: ( ... );
onScheduleTask?: ( ... );
onInvokeTask?: ( ... );
onCancelTask?: ( ... );
onHasTask?: ( ... );
name
定义了 zone
的名称,properties
用于把数据关联到 zone
中,其他属性都是拦截钩子,允许 parent zone
拦截 child zone
中的指定操作。理解 forking
创建的 zones
层次关系非常重要,所有 Zone
类中操作 zone
的方法都能够被 parent zone
使用钩子拦截。文章的下文将会举例使用 properties
来共享异步任务和钩子之间数据,从而实现任务追踪。
下面我们再创建一个 child zone
:
const zoneB = Zone.current.fork({name: 'B'});
现在我们拥有两个 zone
,我们可以使用 zone.run()
方法,让它们在一个指定的 zone
中执行函数。
使用 zone.run()
实现切换 zones
为了使特定的栈帧与 zone
关联,我们需要在 zone
中使用 run
方法运行这个函数。就像上文介绍的那样,它在指定的zone
中运行回调函数,并在回调完成后,恢复它。
让我们应用这些知识,并且稍微改变一下我们的样例(注:可查看 StackBlitz Zone Run Demo):
function c() {
console.log(Zone.current.name); // AC
}
function b() {
console.log(Zone.current.name); // B
zoneAC.run(c);
}
function a() {
console.log(Zone.current.name); // AC
zoneB.run(b);
}
zoneAC.run(a);
现在每个调用栈都关联了一个zone
:
上面代码中,我们使用run
方法执行了每个函数,并且直接为函数指定了zone
。你可能会思考,如果我们不使用run
方法,而仅仅是在zone
中执行函数,会怎么样?
请注意理解这个点:所有在函数中执行的函数调用和异步任务,其所在的zone
与当前这个函数相同。
我们知道在zones
环境中,通常拥有一个root Zone
,如果我们没有使用zone.run
来切换zone
,那么所有函数都将在root zone
中执行。例如(注:可查看 StackBlitz Demo):
function c() {
console.log(Zone.current.name); // <root>
}
function b() {
console.log(Zone.current.name); // <root>
c();
}
function a() {
console.log(Zone.current.name); // <root>
b();
}
a();
它的执行环境如下:
如果我们只在a
函数中使用zoneAB.run
,那么b
和c
函数将在AB
的zone
中执行:
const zoneAB = Zone.current.fork({name: 'AB'});
function c() {
console.log(Zone.current.name); // AB
}
function b() {
console.log(Zone.current.name); // AB
c();
}
function a() {
console.log(Zone.current.name); // <root>
zoneAB.run(b);
}
a();
你可以看到我们在AB zone
中调用b
函数,c
函数也在这个zone
中执行了。
在异步任务中保存zone
javascript 语言的一个显著特征就是它的异步编程,大部分 javascript 开发者都会熟悉 setTimeout
方法,它允许一个函数延后执行。在 Zone
中称作 setTimeout
异步操作为任务(task
),更明确一点的叫法为宏任务(macrotask
)。另外一种任务的种类为微任务(microtask
),例如promise.then
。Jake Archibald 在他的文章 Tasks, microtasks, queues and schedules 深入解释了这些浏览器内部的术语。
下面将展示一下 Zone
中是如何处理 setTimeout
这样的异步任务,为了演示,我们把上面的示例代码稍做修改,在函数调用中加入setTimeout
函数,模拟出异步调用(注:StackBlitz setTimeout Demo):
const zoneBC = Zone.current.fork({name: 'BC'});
function c() {
console.log(Zone.current.name); // BC
}
function b() {
console.log(Zone.current.name); // BC
setTimeout(c, 2000);
}
function a() {
console.log(Zone.current.name); // <root>
zoneBC.run(b);
}
a();
我们已经知道,如果我们在 zone
中调用一个函数,那么被调用的函数也会在这个 zone
中执行。这个行为对包含有异步操作的函数具有同样的效果。如果我们定义一个异步任务,并且指定一个回调函数,那么这个函数也将会在相同的 zone
下执行。
函数调用过程可以表示为:
这个图很清晰,但是这个图隐藏了一个重要的实现细节:Zone
需要为每个要执行的任务指定正确的zone
。因此,Zone
需要记住当前要执行的任务将会在哪个zone
中执行,它通过维护与任务关联zone
的引用来实现。随后,这个zone
被用来从root zone
处理机(root zone handler
)中调用一个任务。
也就是说,每个异步任务的调用栈总是从root zone
开始,并且恢复(restore
)任务所需要的信息到与任务关联的zone
中,随后执行任务。所以更正确的表示应该是这样:
(译者注:此处描述不容易理解,尤其是恢复的含义,对照图来说就是:b函数执行后,b函数里有一个异步任务c,其实这个c对应的zone
及相关信息是由zone task handler
准备的,而这个zone task handler
就是属于root zone的,此处恢复的意思就是,提供给c函数的执行上下文信息从root zone
恢复为zoneAB
)
在异步任务中传递上下文
Zone
中有几个有趣的功能,开发人员可以利用它们。其中一个是context propagation(上下文传播)
,它可以简单地理解为:我们可以把数据放入一个zone
中,并且在当前zone
中运行的函数都可以访问这个数据。
下面将演示我们如何在setTimeout
异步任务中访问到我们放置的数据,之前我们了解过zoneSpec
对象,这个对象有一个properties
属性,可以用来把数据关联到一个zone
之中:
const zoneBC = Zone.current.fork({
name: 'BC',
properties: {
data: 'initial'
}
});
首先创建一个zoneBC zone
,设置properties.data
,data
能够被zone.get
方法访问到(注:StackBlitz propagate context Demo):
function a() {
console.log(Zone.current.get('data')); // 'initial'
}
function b() {
console.log(Zone.current.get('data')); // 'initial'
setTimeout(a, 2000);
}
zoneBC.run(b);
对象中的properties
属性为浅不可变性shallow-immutable
,即不可以添加或删除对象中的属性。这很大可能是因为Zone
没有提供这样的方法。所以对于上面的代码样例,我们是无法重新赋值properties.data
的。
不过我们可以传递一个对象到properties.data
来代替原始的对象,通过这种方式来变更properties.data
数据:
const zoneBC = Zone.current.fork({
name: 'BC',
properties: {
data: {
value: 'initial'
}
}
});
function a() {
console.log(Zone.current.get('data').value); // 'updated'
}
function b() {
console.log(Zone.current.get('data').value); // 'initial'
Zone.current.get('data').value = 'updated';
setTimeout(a, 2000);
}
zoneBC.run(b);
另外一个有趣的地方是:使用fork
方法创建的child zone
,其属性可以从parent zones
中继承:
const parent = Zone.current.fork({
name: 'parent',
properties: { data: 'data from parent' }
});
const child = parent.fork({name: 'child'});
child.run(() => {
console.log(Zone.current.name); // 'child'
console.log(Zone.current.get('data')); // 'data from parent'
});
跟踪未完成的任务
zone
的一个有趣的能力经常被用来追踪未完成的异步宏任务和微任务,zone
把所有未完成的任务放到队列中,使用 ZoneSpec
接口中 onHasTask
钩子方法来观察队列中任务的状态是否改变。下面是对应的语法:
onHasTask(delegate, currentZone, targetZone, hasTaskState);
因为 parent zones
能够拦截 child zones
的事件,所以 Zone
提供了 currentZone
和 targetZone
参数,他们可以辨别 zone
状态改变是发生在任务队列中还是被拦截了。 例如,如果你需要确认你正在拦截 current zone
中的事件,需要对比 zones
:
// We are only interested in event which originate from our zone
if (currentZone === targetZone) { ... }
onHasTask
钩子中最后一个参数是 hasTaskState
,它描述了任务队列的状态,它的定义如下:
type HasTaskState = {
microTask: boolean;
macroTask: boolean;
eventTask: boolean;
change: 'microTask'|'macroTask'|'eventTask';
};
如果你在一个 zone
中调用了 setTimeout
函数,那么对应的 hasTaskState
对象的值如下:
{
microTask: false;
macroTask: true;
eventTask: false;
change: 'macroTask';
}
这个状态值,表明任务队列中存在一个等待执行的宏任务,并且状态的改变来自一个宏任务。
在代码中,可以描述为:
const z = Zone.current.fork({
name: 'z',
onHasTask(delegate, current, target, hasTaskState) {
console.log(hasTaskState.change); // "macroTask"
console.log(hasTaskState.macroTask); // true
console.log(JSON.stringify(hasTaskState));
}
});
function a() {}
function b() {
// synchronously triggers `onHasTask` event with
// change === "macroTask" since `setTimeout` is a macrotask
setTimeout(a, 2000);
}
z.run(b);
这段代码的输出如下(注:StackBlitz onHasTask Demo):
macroTask
true
{
"microTask": false,
"macroTask": true,
"eventTask": false,
"change": "macroTask"
}
在两秒后,setTimeout
中的a函数执行,输出变为:
macroTask
false
{
"microTask": false,
"macroTask": false,
"eventTask": false,
"change": "macroTask"
}
这里有一个小小的注意点,只可以使用 onHasTask
钩子来追踪整个任务队列的空/非空状态,你不可以用它来追踪独立的任务,假设你运行如下代码:
let timer;
const z = Zone.current.fork({
name: 'z',
onHasTask(delegate, current, target, hasTaskState) {
console.log(Date.now() - timer);
console.log(hasTaskState.change);
console.log(hasTaskState.macroTask);
}
});
function a1() {}
function a2() {}
function b() {
timer = Date.now();
setTimeout(a1, 2000);
setTimeout(a2, 4000);
}
z.run(b);
对应的输出为(注:StackBlitz onHasTask Demo):
1
macroTask
true
4006
macroTask
false
通过这个结果可以看出,在2秒钟的时候,setTimeout
对应的事件没有打印出来。
那是因为第一个 setTimeout
被放置入执行任务队列后,onHasTask
钩子被触发了一次,hasTaskState.change
状态从 non-empty
转为 empty
,随后在第4秒时 setTimeout
完成后被第二次触发。
如果你想要追踪单个任务,可以使用 onScheduleTask
和 onInvoke
钩子。
onScheduleTask
与 onInvokeTask
钩子
Zone Spec
定义了两种可以追踪单个任务的钩子: - onScheduleTask,当异步操作,如 setTimeout
被检测到的时候触发 - onInvokeTask,当异步操作中的回调执行时触发,例如 setTimeout(callback)
中 callback
执行时
下面是使用钩子来追踪单个任务的示例:
let timer;
const z = Zone.current.fork({
name: 'z',
onScheduleTask(delegate, currentZone, targetZone, task) {
const result = delegate.scheduleTask(targetZone, task);
const name = task.callback.name;
console.log(
Date.now() - timer,
`task with callback '${name}' is added to the task queue`
);
return result;
},
onInvokeTask(delegate, currentZone, targetZone, task, ...args) {
const result = delegate.invokeTask(targetZone, task, ...args);
const name = task.callback.name;
console.log(
Date.now() - timer,
`task with callback '${name}' is removed from the task queue`
);
return result;
}
});
function a1() {}
function a2() {}
function b() {
timer = Date.now();
setTimeout(a1, 2000);
setTimeout(a2, 4000);
}
z.run(b);
该段代码的执行结果如下(注:StackBlitz onScheduleTask onInvokeTask Demo):
1 “task with callback ‘a1’ is added to the task queue”
2 “task with callback ‘a2’ is added to the task queue”
2001 “task with callback ‘a1’ is removed from the task queue”
4003 “task with callback ‘a2’ is removed from the task queue”
使用 onInvoke
来拦截进入 zone
的行为
一个 zone
能够被进入(entered or switched
),通常是通过显示地调用 z.run()
方法或者通过显示地调用任务。 在上一节中,解释了 onInvokeTask
钩子能够被用来拦截回调方法被执行时的状态。
onInvoke
方法则是用于在执行 z.run()
方法时,无论任何时候发生 zone enter
行为,都可以获知。
例如:
const z = Zone.current.fork({
name: 'z',
onInvoke(delegate, current, target, callback, ...args) {
console.log(`entering zone '${target.name}'`);
return delegate.invoke(target, callback, ...args);
}
});
function b() {}
z.run(b);
其输出为(注:StackBlitz onInvoke Demo):
entering zone ‘z’
Zone.current
在钩子下面的运行机制
current zone
使用 __currentZoneFrame
变量来跟踪,并被捕获到闭包之中,它由Zone.current
的 getter 方法返回。
所以想要切换zone
,只需要修改 __currentZoneFrame
变量就可以实现。现在可以通过运行 z.run()
或者调用任务来切换 zone
。
下面是run
方法更新 __currentZoneFrame
变量的代码:
class Zone {
...
run(callback, applyThis, applyArgs, source) {
...
_currentZoneFrame = {parent: _currentZoneFrame, zone: this};
runTask
更新变量的代码如下:
class Zone {
...
runTask(task, applyThis, applyArgs) {
...
_currentZoneFrame = { parent: _currentZoneFrame, zone: this };
runTask
方法被 invokeTask
方法调用,每个任务都具备:
class ZoneTask {
invokeTask() {
_numberOfNestedTaskFrames++;
try {
self.runCount++;
return self.zone.runTask(self, this, arguments);
每个任务创建的时候,都会在zone
属性保存当前的zone
信息,这正是用于在invokeTask
下运行runTask
方法。 此处的self
引用指向任务实例。
self.zone.runTask(self, this, arguments);
资源
想要了解有关 Zones 的更多信息,可看: