1. 程式人生 > >為什麼JavaScript 程式碼有時會被覆蓋

為什麼JavaScript 程式碼有時會被覆蓋

程式碼覆蓋提供有關是否以及可選地應用程式的某些部分被執行的頻率的資訊。它通常用於判定一個測試套件執行特定程式碼庫的全面程度。

它為什麼是有用的? 

作為一名JavaScript開發者,你可能經常發現自己處於程式碼覆蓋可能有用的情景。例如:

  • 對測試套件的質量感興趣? 重構一個大型的遺留專案? 程式碼覆蓋可以準確顯示程式碼庫中已覆蓋了哪些部分。

  • 想快速瞭解是否覆蓋了程式碼庫的特定部分? 程式碼覆蓋可以顯示有關應用程式的哪些部分已被執行的實時資訊,而不是使用console.log進行printf-風格的除錯或手動執行程式碼。

  • 或者你可能正在優化速度,並想知道要關注哪些點? 執行次數可以指出關鍵函式和迴圈。

JavaScript在V8中的程式碼覆蓋

今年早些時候,我們在V8上添加了對JavaScript程式碼覆蓋的原生支援。5.9版本中的初始釋出提供了函式粒度(顯示已執行的函式)的覆蓋範圍,後來擴充套件為支援在v6.2中的塊粒度覆蓋(同樣的,僅對於單獨表示式有效)。

函式粒度(左側)和塊粒度(右側)

對JavaScript開發者

目前訪問覆蓋資訊有兩種主要的方式。對於JavaScript開發者,Chrome DevTools的Coverage tab給出了JS (和CSS)覆蓋率並在原始碼面板中指出了無用程式碼。

塊覆蓋coverage 在DevTools Coverage 面板中的塊覆蓋。覆蓋的行使用綠色標註,未覆蓋的行則使用紅色。

給嵌入式

嵌入式及框架作者可以通過直接hook到Inspector API上獲得更大的靈活性。V8提供兩種不同的覆蓋模式:

  1. 盡力覆蓋模式下收集覆蓋資訊,確保在執行時對效能的影響最小,但可能會丟失已被垃圾回收(GC)函式的資料。

  2. 精確覆蓋確保不會因為GC而丟失任何資料,使用者可以選擇接收執行計數而不是二進位制覆蓋資訊;但效能可能會受此額外開銷的影響(有關詳細資訊,請參閱下一節)。精準覆蓋可以按函式或塊粒度收集資訊。

精準覆蓋的Inspector API如下:

  • Profiler.startPreciseCoverage(callCount, detailed) 使能覆蓋資訊收集,可選呼叫次數(vs.二進位制覆蓋)以及塊粒度(vs. 函式粒度);
  • Profiler.takePreciseCoverage() 返回已收集的覆蓋資訊,其中包含原始碼範圍列表以及相關的執行次數;
  • Profiler.stopPreciseCoverage() 禁用收集並釋放相關資料結構。

Inspector協議間的通訊可能如下所示:

// The embedder directs V8 to begin collecting precise coverage.
{ "id": 26, "method": "Profiler.startPreciseCoverage",
            "params": { "callCount": false, "detailed": true }}
// Embedder requests coverage data (delta since last request).
{ "id": 32, "method":"Profiler.takePreciseCoverage" }
// The reply contains collection of nested source ranges.
{ "id": 32, "result": { "result": [{
  "functions": [
    {
      "functionName": "fib",
      "isBlockCoverage": true,    // Block granularity.
      "ranges": [ // An array of nested ranges.
        {
          "startOffset": 50,  // Byte offset, inclusive.
          "endOffset": 224,   // Byte offset, exclusive.
          "count": 1
        }, {
          "startOffset": 97,
          "endOffset": 107,
          "count": 0
        }, {
          "startOffset": 134,
          "endOffset": 144,
          "count": 0
        }, {
          "startOffset": 192,
          "endOffset": 223,
          "count": 0
        },
      ]},
      "scriptId": "199",
      "url": "file:///coverage-fib.html"
    }
  ]
}}

// Finally, the embedder directs V8 to end collection and
// free related data structures.
{"id":37,"method":"Profiler.stopPreciseCoverage"}

幕後細節

如上一節所述,V8支援兩種主要的程式碼覆蓋模式:盡力和精確覆蓋。欲瞭解他們實現概述,請繼續閱讀。

盡力覆蓋 

盡力和精確覆蓋模式都大量重用其它的V8機制,其中首數被稱為呼叫計數器的機制。每次通過V8的Ignition直譯器呼叫函式時,我們都會在函式的反饋向量上增加其呼叫計數器。隨著函式後來變得愈加頻繁並通過優化編譯器做了提升,這個計數器用於幫助輔助關於行內函數的內聯決策;現在,我們也依靠它報告程式碼覆蓋情況。

第二種重用機制確立了函式的原始碼範圍。報告程式碼覆蓋時,呼叫計數需要與原始檔中的相關範圍作關聯。例如,在下面的示例中,我們不僅需要報告函式f已經執行了一次,還包含f的原始碼範圍從第1行開始到第3行結束。

function f() {
  console.log('Hello World');
}

f();

又一次我們是幸運的,我們能夠重用 V8 中的現有資訊。由於 Function.prototype.toString 需要知道函式在原檔案中的位置以提取適當的子字串,函式已經知道它們在原始碼中的起始位置和結束位置。
在收集到最優的覆蓋範圍時,這兩種機制簡單地結合在一起:首先,我們通過遍歷整個堆來找到所有存活的函式。對於每個可見的函式,我們報告呼叫次數(儲存在反饋向量中,我們可以從函式中訪問)和源範圍(方便儲存在函式本身)。

請注意,由於無論是否啟用 coverage,都會維護呼叫計數,因此盡力服務的覆蓋不會引入任何執行時開銷。它也不使用專用的資料結構,因此既不需要顯式啟用也無需顯式禁用。

那麼為什麼這種模式稱為盡力服務(best-effort)呢,它的侷限性是什麼? 超出範圍的函式可能會被垃圾回收器釋放掉。這意味著相關的呼叫計數將會丟失,事實上我們完全忘記了這些函式曾經存在過。 因此“盡力服務”:即使我們盡力了,所收集的覆蓋資訊也可能不完整。

精準覆蓋 (函式粒度) 

與盡力服務模式相比,精確覆蓋可確保所提供的覆蓋資訊是完整的。為實現這一目標,我們會在啟用精準覆蓋後將所有反饋向量新增到V8的根參考集中,從而阻止GC對其進行回收。雖然這確保了資訊無丟失,但它通過人為地保持物件存活增加記憶體開銷。

精準覆蓋模式還可以提供執行計數。這為精準覆蓋實施增加了另一個竅門。回想一下,每次通過V的直譯器呼叫函式時,呼叫計數器都會遞增,並且一旦函式訪問頻率過高,這些函式就可以升級並進行優化。 但優化的函式不再增加其呼叫計數器,因此必須禁用優化編譯器,以使其報告的執行次數保持準確。

精準覆蓋(塊粒度)

塊粒度覆蓋必須報告準確到獨立表示式層級的覆蓋範圍。例如,在下面的一段程式碼中,塊覆蓋可以檢測到條件表示式的else分支: c從不執行,而函式粒度覆蓋只會知道函式 f(作為一個整體)被覆蓋了。

function f(a) {
  return a ? b : c;
}

f(true);

你可能從前面的部分想起我們已經在 V8 中提供了函式呼叫次數和原始碼範圍。不幸的是,這不適合塊覆蓋的場景,我們必須實現新的機制來收集執行次數和它們相應的原始碼範圍。

第一個方面是原始碼範圍:假設我們擁有一個特定塊的執行計數,我們如何將它們對映到原始碼的一部分呢? 為此,我們需要在解析原始檔時收集相關位置資訊。在塊覆蓋之前,V8已經在某種程度上做到了這一點。一個示例是由如上所述的Function.prototype.toString而觸發的函式範圍的收集。另一個例子是用於構造Error物件的回溯的原始碼位置。但這些都不足以支援塊覆蓋; 前者僅適用於函式,而後者僅儲存位置資訊(例如if-else語句的if標記的位置),而不是原始碼範圍。

因此,我們必須擴充套件解析器以收集原始碼範圍。為了演示,假設我們正在使用if-else語句:

if (cond) {
  /* Then branch. */
} else {
  /* Else branch. */
}

當啟用塊覆蓋時,我們收集 then 和 else 分支的原始碼範圍,並將它們與已解析的 IfStatement AST 節點相關聯。其他相關語言結構也是如此處理。

在解析過程中收集完原始碼範圍集之後,第二個方面是在執行時跟蹤執行計數。 這是通過在生成的位元組碼陣列的關鍵位置插入新的專用 IncBlockCounter 位元組碼來完成的。在執行時,IncBlockCounter 位元組碼處理程式只是增加對應的計數器介面(可通過函式物件訪問)。

在 if-else 語句的上述示例中,這樣的位元組碼將被插入在三個位置:緊接在 then 分支的主體之前,在 else 分支的主體之前,緊接在 if-else 語句之後(由於分支內可能存在非本地控制,因此需要連續的計數器)。

最後,報告塊粒度覆蓋與函式粒度報告類似。但除了呼叫計數(來自反饋向量)之外,我們現在還報告了感興趣的源範圍的集合以及它們的塊計數(儲存在掛起該函式的輔助資料結構中)。