乐趣区

我逆向工程zonejs后的发现

原文链接:https://blog.angularindepth.c…

作者:Max Koretskyi aka Wizard

翻者:而井

Zones 是一种可以帮助开发者在多个异步操作之间进行逻辑连接的新机制。通过一个 zone,将相关联的每一个异步操作关联起来是 Zones 运行的方式。开发者可以从中获益:

  • 将数据与 zone 相关联,(使得)在 zone 中的任何异步操作都可以访问到(这些数据),在其他语言中称之为 线程本地存储(thread-local storage)
  • 自动跟踪给定 zone 内的未完成的异步操作,以执行清理或呈现或测试断言步骤
  • 统计 zone 中花费的总时间,用于分析或现场分析
  • 可以在一个 zone 中处理所有没有捕获的异常、没有处理和 promise rejections,而非将其(异常)传导到顶层

网上大部分(关于 zone.js)的文章要么是在讲(zone.js)过时的 API,要么就是用一个非常简单的例子来解释如何使用 Zones。在本文中,我将使用最新的 API 并在尽可能接近实现的情况下详细探索基本 API。我将从 API 开始讲起,然后阐述异步任务关联机制,继而探讨拦截钩子,开发者可以利用这些拦截钩子来执行异步任务。在文末,我将简明扼要地阐述 Zones 底层是如何运作的。

Zones 现在是(属于)EcmaScript标准里的 stage 0 状态的提案,目前被 Node 所阻止。Zones 通常被指向为Zone.js,(Zone.js)是一个 GitHub 仓库和 npm 包的名字。然而在本文中,我将使用 Zone 这个名词(而非 Zone.js),因为规范中依据指定了(Zone)。

相关的 Zone API

让我们先看一下在 Zones 中最常用的方法。这个 Class 的定义如下:

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)。当前区是可以在所有异步操作之间传递的异步上下文。它表示与当前正在执行的堆栈帧 / 异步任务相关联的区。当前区可以通过Zone.current 这个静态 getter 访问到。

每个 zone 都有(属性)name,(这个属性)主要是为了工具链和调试中使用。同时 zone 中也定义了一些用来操作 zones 的方法:

  • z.run(callback, ...)在给定的 zone 中以同步的方式调用一个函数。它在执行回调时将当前区域设置为 z,并在回调完成执行后将其重置为先前的值。在 zone 中执行回调通常被称为“进入”zone。
  • z.runGuarded(callback, ...)runz.run(callback, ...))一样,但是会捕获运行时的异常,并且提供一种拦截的机制。如果存在一个异常没有被父区(parent Zone) 处理,这个异常就会被重新抛出。
  • z.wrap(callback) 会产生一个包含 z 的闭包函数,在执行时表现得 z.runGuarded(callback) 基本一致。即使这个回调函数被传入 other.run(callback)(译者注:回调函数指的是z.wrap(callback) 的返回值),这个回调函数依旧会在 z 区中执行,而非 other 区。这是一种类似于 Javascript 中 Function.prototype.bind 的机制。

在下一章节我们将详细地谈论到 fork 方法。Zone 拥有一系列去运行、调度、取消一个任务的方法:


class Zone {runTask(...);
  scheduleTask(...);
  scheduleMicroTask(...);
  scheduleMacroTask(...);
  scheduleEventTask(...);
  cancelTask(...);

这里有一些开发者比较少用到的底层方法,所以我并不打算在本文中详细地讨论它们。调度一个任务是 Zone 中的内部操作,对于开发者而言,其意义大致等同于调用一些异步操作,例如:setTimeout

在调用堆栈中保留 Zone

JavaScript 虚拟机会在每个函数它们自己的栈帧中执行函数。所以如果你有如下代码:

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

在 MDN 网站上,有我在 c 函数中捕获执行栈的方法的描述。

调用栈如下图所示:

可以看出,除了 3 个栈帧是我们调用函数时产生的,另外还有一个栈是全局上下文的。

在常规 JavaScript 环境中,c函数的栈帧是无法与 a 函数的栈帧相关联的。但是通过一个特定的 zone,Zone 允许我们做到这一点(将 c 函数的栈帧是与 a 函数的栈帧相关联)。例如,我们可以将堆栈帧 a 和 c 与相同的 zone 相关联,将它们有效地链接在一起。然后我们可以得到以下调用栈:

稍后我们将看到如何实现这一效果。

用 zone.fork 创建一个子 zone

Zones 中一个最常用的功能就是通过 fork 方法来创建一个新的 zone。Forking 一个 zone 会创建一个新的子 zone,并且设置其父 zone 为调用 fork 方法的 zone:

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

fork方法内部其实只是简单的通过一个类创建了一个新的 zone:

new Zone(targetZone, zoneSpec);

为了完成将 ac函数置于同一个 zone 中相关联的目的,我们首先需要创建那个 zone。为了创建那个 zone,我们需要使用我上文所展示的 fork 方法:

const zoneAC = Zone.current.fork({name: 'AC'});

我们传入 fork 方法中的对象被称为区域规范(ZoneSpec),其拥有以下属性:

interface ZoneSpec {
    name: string;
    properties?: {[key: string]: any };
    onFork?: (...);
    onIntercept?: (...);
    onInvoke?: (...);
    onHandleError?: (...);
    onScheduleTask?: (...);
    onInvokeTask?: (...);
    onCancelTask?: (...);
    onHasTask?: (...);

name定义了一个 zone 的名称,properties则是在这个 zone 中相关联的数据。其余的属性是拦截钩子,这些钩子允许父 zone 拦截其子 zone 的某些操作。重要的是理解 forking 创建 zone 层次结构,以及在父 zone 中使用 Zone 类上的所有方法来拦截操作。稍后我们将在文章中看看如何在异步操作之间使用 properties 来分享数据,以及如何利用钩子来实现任务跟踪。

让我们再创建一个子 zone:

const zoneB = Zone.current.fork({name: 'B'});

现在我们拥有了两个 zone,我们可以在特定的 zone 中使用它们来执行一些函数。为了达到这个目的,我们需要使用 zone.run() 方法。

用 zone.run 来切换 zone

为了在一个 zone 中创建一个特定的相关联的栈帧,我们需要使用 run 方法。正如你所知,它以同步的方式在指定的 zone 中运行一个回调函数,完成之后将会恢复到之前的 zone。

让我们运用这些的知识点,简单地修改以下我们的例子:

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 中执行。让我们瞧一瞧这个结果:

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,那么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();

如你所见,我们可以预期 b 函数是在 ABzone 中调用的,但是(出乎意料的是),c 函数也是在 (AB) 这个 zone 中执行的。

在异步任务之间维持 zone

JavaScript 开发有一个鲜明的特征,那就是异步编程。可能大多数 JS 新手都可以熟练使用 setTimeout 方法来做异步编程,该方法允许推迟执行函数。Zone 调用 setTimeout 异步操作任务。具体来说,(setTimeout 产生的)是一个宏任务。另一类任务则是微任务,例如,promise.then。这些术语在浏览器内部所使用,Jake Archibald 对任务、微任务、队列、调度做过深度的介绍说明。

让我们看看 Zone 中是如何处理像 setTimeout 这类的异步任务的。为此,我们将使用上面使用的代码,但不是立即调用函数 c,而是将它作为回调传递给 setTimeout 函数。所以这个回调函数将在未来的某个时间(大约 2 秒内),在 单独的调用堆栈 中执行:

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 之后会在 root zone 的处理程序中用于调用任务。

这意味着每一个异步任务的调用栈基本上都开始于 root zone,root zone 将使用与任务相关的信息来恢复正确的 zone 和调用任务。所以这里有一个更准确的表示:

在异步任务之间传递上下文

Zone 有一系列开发者可以受益的有趣功能。其中之一就是上下文传递。这意味着我们可以在 zone 中访问到数据,并且 zone 中运行的任何任务也可以访问到这些数据。

让我们使用前一个例子,来演示我们是如何在 setTimeout 异步任务中传递数据的。你已经了解到了,当 forking 一个新 zone 时,我们可以传入一个 zone 规范对象。这个对象有一个可选属性properties。我们可以使用这个属性来将数据与 zone 做关联,如下:

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

之后,(数据)可以通过 zone.get 方法来访问得到:

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 是一个浅不变对象,这意味着你不可以对其(数据对象的 properties 属性对象)属性新增属性、删除属性的操作。这也是 Zone 不提供方法去做上述操作的最大原因。所以在上面的例子中,我们不能对 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 方法创建的子 zone,会从父 zone 继承属性:

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 将所有未完成的任务保留在一个队列之中。要想在此队列状态更改时收到通知,我们可以使用区规范(zone spec)的 onHasTask 钩子。这是它的类型定义:

onHasTask(delegate, currentZone, targetZone, hasTaskState);

由于父 zone 可以拦截子 zone 事件,因此 Zone 提供 currentZone 和 targetZone 两个参数,用以区分任务队列中发生更改的 zone 和拦截事件的 zone。举个例子,如果你需要确保只想拦截当前 zone 的事件,只需要比较一下 zone(是否相同):

// We are only interested in event which originate from our zone
if (currentZone === targetZone) {...}

传入钩子函数的最后一个参数是hasTaskState, 它描述了任务队列的状态。这里使它的类型定义:

type HasTaskState = {
    microTask: boolean; 
    macroTask: boolean; 
    eventTask: boolean; 
    change: 'microTask'|'macroTask'|'eventTask';
};

所以如果你在一个 zone 中调用 setTimeout,那么你将获得的hasTaskState 对象如下:

{
    microTask: false; 
    macroTask: true; 
    eventTask: false; 
    change: 'macroTask';
}

表明队列中存在未完成的 macrotask,队列中的更改来自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);

那么,我们会得到如下输出:

macroTask
true
{
    "microTask": false,
    "macroTask": true,
    "eventTask": false,
    "change": "macroTask"
}

每当 setTimeout 完成时,onHasTask 都会被再次触发:

需要注意的是,我们只能使用 onHasTask 来跟踪 整个任务队列 空 / 非空 状态。你不可以利用它 (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);

你会得到以下输出:

1
macroTask
true

4006
macroTask
false

你可以看得出,当 2setTimeout任务完成时,并没有触发任何事件。onHasTask钩子会在第一个 setTimeout 被调度时(译者注:调度不意味着 setTimeout 中的回调函数被执行完成了,只是 setTimeout 函数被调用了 )触发,然后任务队列的状态会从 非空 改变到 ,当最后一个 setTimeout 的回调函数完成时,onHasTask钩子将被触发第二次。

如果你想要跟踪特定的任务,你需要使用 onSheduleTaskonInvoke钩子。

onSheduleTask 和 onInvokeTask

Zone 规范中定义了两个可以跟踪特定任务的钩子:

  • onScheduleTask
    检查到类似 setTimeout 之类的异步操作时,(onScheduleTask)会被执行
  • onInvokeTask
    传入异步操作、如 setTimeout 之中的回调函数被执行时,(onInvokeTask)会被执行

以下就是如何使用这些钩子来跟踪各个任务(的例子):

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);

预期输出:

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 的进入

可以通过调用 z.run() 显式地进入(切换)zone,也可以通过调用任务来隐式进入(切换)zone。在上一节中,我解释了 onInvokeTask 挂子,当 Zone 内部执行与异步任务相关联的回调时,该钩子可用于拦截 zone 的进入。还有另一个钩子 onInvoke,您可以通过运行z.run() 在进入 zone 时收到通知。

以下是如何使用它的示例:

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);

将输出:

entering zone‘z’

`Zone.current` 底层是如何运行的

当前 zone 被这里的闭包中使用 _currentZoneFrame 变量所跟踪着,它(_currentZoneFrame)被 Zone.current 这个 getter 所返回。所以为了切换 zone,需要简单地更新以下 _currentZoneFrame 的值。现在,你可以通过 z.run() 或调用任务来切换 zone。

这里是 run 方法更新变量的地方:

class Zone {
   ...
   run(callback, applyThis, applyArgs,source) {
      ...
      _currentZoneFrame = {parent: _currentZoneFrame, zone: this};

runTask方法更新变量的地方在这里

class Zone {
   ...
   runTask(task, applyThis, applyArgs) {
      ...
      _currentZoneFrame = {parent: _currentZoneFrame, zone: this};

在每个任务中 invokeTask 方法会调用 runTask 方法

class ZoneTask {invokeTask() {
         _numberOfNestedTaskFrames++;
      try {
          self.runCount++;
          return self.zone.runTask(self, this, arguments);

创建的每个任务时都会在 zone 属性中保存其 zone。这正是用于在 invokeTask 中运行任务的 zone(self指的是此处的任务实例):

self.zone.runTask(self, this, arguments);

其他资源

如果您想获得有关 Zone 的更多信息,这里是一些很好的资源:

  • A talk by Brian Ford
  • Zone Primer google doc
  • Github sources (for the brave ones)
退出移动版