我逆向工程zone.js後的發現

而井發表於2019-09-02
原文連結: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的更多資訊,這裡是一些很好的資源:

相關文章