翻阅源码后,我终于理解了Zone.js

翻阅源码后,我终于理解了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)或异步任务相关联的 zonecurrent zone 可以通过 Zone.current 静态方法访问到。

每个 zone 都有一个 name,它被用在工具链或者调试中,在 Zone 中还定义了一些方法来操作 Zones

- z.run(callback, ...),在给定的 zone 中同步的调用一个函数,在执行 callback 函数时,它设置 current zonez。此外,当回调函数执行完毕时,它会重新设置回原来的值。在 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之中。例如,我们可以关联 ac 栈帧到一个相同的zone中。如下图所示:

zone


下面来快速了解下zone是如何做到这样的。

使用 zone.fork 来创建一个子 zone

Zones 中使用最多的特性是使用 fork 方法来创建一个新的 zonefork zone 可以创建一个新的 child zone,并且这个child zoneparent 被设置为当前 zone

const c = z.fork({name: 'c'});
console.log(c.parent === z); // true

fork方法背后的原理是简单地使用Zone Class来创建了一个新的zone(注:可查看 源码 L1052-L1055):

new Zone(targetZone, zoneSpec);

为了完成上文提到的关联ac函数到相同的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

执行环境与 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();

它的执行环境如下:

执行环境与 zone


如果我们只在a函数中使用zoneAB.run,那么bc函数将在ABzone中执行:

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();
执行环境与 zone


你可以看到我们在AB zone中调用b函数,c函数也在这个zone中执行了。

在异步任务中保存zone

javascript 语言的一个显著特征就是它的异步编程,大部分 javascript 开发者都会熟悉 setTimeout 方法,它允许一个函数延后执行。在 Zone 中称作 setTimeout 异步操作为任务(task),更明确一点的叫法为宏任务(macrotask)。另外一种任务的种类为微任务(microtask),例如promise.thenJake 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的引用来实现。随后,这个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 下执行示例

在异步任务中传递上下文

Zone中有几个有趣的功能,开发人员可以利用它们。其中一个是context propagation(上下文传播),它可以简单地理解为:我们可以把数据放入一个zone中,并且在当前zone中运行的函数都可以访问这个数据。

下面将演示我们如何在setTimeout异步任务中访问到我们放置的数据,之前我们了解过zoneSpec对象,这个对象有一个properties属性,可以用来把数据关联到一个zone之中:

const zoneBC = Zone.current.fork({
    name: 'BC',
    properties: {
        data: 'initial'
    }
});

首先创建一个zoneBC zone,设置properties.datadata能够被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 提供了 currentZonetargetZone 参数,他们可以辨别 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 完成后被第二次触发。

如果你想要追踪单个任务,可以使用 onScheduleTaskonInvoke 钩子。

onScheduleTaskonInvokeTask 钩子

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 的更多信息,可看:

A talk by Brian Ford

Zone Primer google doc

Github sources

编辑于 2018-12-05 22:49