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

原文連接:blog.angularindepth.com/i-reverse-e…javascript

做者:Max Koretskyi aka Wizardjava

翻者:而井node

Zones是一種能夠幫助開發者在多個異步操做之間進行邏輯鏈接的新機制。經過一個zone,將相關聯的每個異步操做關聯起來是Zones運行的方式。開發者能夠從中獲益:git

  • 將數據與zone相關聯,(使得)在zone中的任何異步操做均可以訪問到(這些數據),在其餘語言中稱之爲線程本地存儲(thread-local storage)github

  • 自動跟蹤給定zone內的未完成的異步操做,以執行清理或呈現或測試斷言步驟npm

  • 統計zone中花費的總時間,用於分析或現場分析編程

  • 能夠在一個zone中處理全部沒有捕獲的異常、沒有處理和promise rejections,而非將其(異常)傳導到頂層segmentfault

網上大部分(關於zone.js)的文章要麼是在講(zone.js)過期的API,要麼就是用一個很是簡單的例子來解釋如何使用Zones。在本文中,我將使用最新的API並在儘量接近實現的狀況下詳細探索基本API。我將從API開始講起,而後闡述異步任務關聯機制,繼而探討攔截鉤子,開發者能夠利用這些攔截鉤子來執行異步任務。在文末,我將簡明扼要地闡述Zones底層是如何運做的。promise

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,那麼b和c函數都在將在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的更多信息,這裏是一些很好的資源:

相關文章
相關標籤/搜索