我逆向工程zone.js后的发现

而井
原文链接:https://blog.angularindepth.c...

作者:Max Koretskyi aka Wizard

翻者:而井

logo

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调用栈

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

用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:

用zone.run来切换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();

结果就是如上所述,用图表表示就是如图:
不切换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();

zoneAB.run onece in a function

如你所见,我们可以预期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和调用任务。所以这里有一个更准确的表示:

the root zone that uses the information associated with a task to restore correct zone and then invoke the task

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

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的更多信息,这里是一些很好的资源:

阅读 5.1k

而井前端修仙传
而井前端修仙传
829 声望
1.8k 粉丝
0 条评论
829 声望
1.8k 粉丝
宣传栏