[譯] Google 工程師提升網頁效能的新策略:空閒執行,緊急優先

清秋發表於2018-11-04

幾周前,我開始檢視我網站的一些效能指標。具體來說,我想看看我的網站在最新的效能指標 —— 首次輸入延遲 (FID)上的表現如何。 我的網站只是一個部落格(並沒有執行很多的 JavaScript),所以我原本預期會得到相當不錯的結果。

使用者一般對於小於 100 毫秒的輸入延遲沒有感知,因此我們推薦的效能目標(以及我希望在我的分析中看到的數字)是對於 99% 的頁面載入,FID 小於 100 毫秒。

令我驚訝的是,我網站 99% 的頁面的 FID 在 254 毫秒以內。我是個完美主義者,儘管結果不算很糟糕,但我卻無法對這個結果置之不理。我一定得把它搞定!

簡而言之,在不刪除網站的任何功能的情況下,我把 99% 頁面的 FID 降到了 100 毫秒以內。但我相信讀者朋友們更感興趣的是:

  • 我是如何診斷問題的。
  • 我採用了什麼具體的策略和技術。

說到上文中的第二點,當時我試圖解決我的問題時,偶然發現了一個非常有趣的效能策略,特別想分享給大家(這也是我寫這篇文章的主要原因)。

我把這個策略稱作:空閒執行,緊急優先

我的效能問題

首次輸入延遲(FID)是一個網站效能指標,指使用者與網站首次互動(像我這樣的部落格,最有可能的首次互動是點選連結)和瀏覽器響應此互動(請求載入下一頁面)之間的時間。

存在延遲是由於瀏覽器的主執行緒正在忙於做其他事情(通常是在執行 JavaScript 程式碼)。因此,要診斷這個高於預期的 FID,我們首先需要在網站載入時啟動效能跟蹤(啟用 CPU 降頻和網路限速),然後在主執行緒上找到耗時長的任務。一旦確定了這些耗時長的任務,我們就可以嘗試將它們拆解為更小的任務。

以下是我在對網站啟用效能跟蹤後的發現:

我的網站載入時的 JavaScript 效能跟蹤圖(啟用網路限速/ CPU 降頻)

我的網站載入時的 JavaScript 效能跟蹤圖(啟用網路限速和 CPU 降頻)。

可以注意到,主要指令碼包在瀏覽器中單獨執行時,它需要耗時 233 毫秒才能完成。

執行我網站的主要指令碼包耗時 233 毫秒

執行我網站的主要指令碼包耗時 233 毫秒。

在這些程式碼中,一部分來自 webpack 樣板檔案和 babel polyfill,但大多數程式碼來自我指令碼的 main() 入口函式,它本身需要 183 毫秒才能完成:

執行我網站的 `main()` 入口函式耗時 183 毫秒。

執行我網站的 main() 入口函式耗時 183 毫秒。

這並不像是我在 main() 函式中做了什麼荒謬的事情。在 main() 函式中,我先初始化了我的 UI 元件,然後執行了我的 analytics 方法:

const main = () => {
  drawer.init();
  contentLoader.init();
  breakpoints.init();
  alerts.init();

  analytics.init();
};

main();
複製程式碼

那麼是什麼花了如此長時間執行?

我們繼續來看一下這個火焰圖的尾部,可以看到沒有一個函式佔據了大部分時間。絕大多數函式耗時不到 1 毫秒,但是當你將它們全部加起來時,在單個同步呼叫堆疊中,執行它們卻需要超過 100 毫秒。

JavaScript 就像被“千刀萬剮”了一樣。

由於這些功能全都作為單個任務的一部分執行,因此瀏覽器必須等到此任務完成才能響應使用者的互動。一個十分明顯的解決方案是將這些程式碼拆解為多個任務,但這說起來容易做起來難。

乍一看,明顯的解決方案是將 main() 函式中的每個元件分配優先順序(它們實際上已經按優先順序順序排列了),立即初始化優先順序最高的元件,然後將其他元件的初始化推遲到後續任務中。

雖然這可能有一些作用,但它的可操作行並不強,而且難以應用到大型網站中。原因如下:

  • 推遲 UI 元件初始化的方法僅在元件尚未渲染時才有用。推遲初始化元件的方法會造成風險:使用者有可能遇到元件沒有渲染完成的情況。
  • 在許多情況下,所有 UI 元件要麼同等重要,要麼彼此依賴,因此它們都需要同時進行初始化。
  • 有時單個元件需要足夠長的時間來初始化,即使它們各自在自己的任務中執行,也會阻塞主執行緒。

實際情況是,通常我們很難讓每個元件在各自的任務中初始化,而且這種做法往往不可能實現。我們經常需要的是在每個元件內部的初始化過程中拆解任務。

貪婪的元件

從下面的效能跟蹤圖可以看出,我們是否真的需要把元件初始化程式碼進行拆分,讓我們來看一個比較好的例子:在 main() 函式的中間,你會看到我的一個元件使用了 Intl.DateTimeFormat API:

建立一個 Intl.DateTimeFormat 例項需要 13.47 毫秒!

建立一個 Intl.DateTimeFormat 例項需要 13.47 毫秒!

建立此物件需要 13.47 毫秒!

問題是,雖然 Intl.DateTimeFormat 例項是在元件的建構函式中建立的,但實際上在其他元件用它來格式化日期之前,它都沒有被使用過。可是由於該元件不知道何時會引用 Int.DateTimeFormat 物件,因此它選擇立即初始化該物件。

但這是正確的程式碼求值策略嗎?如果不是,那什麼是正確的程式碼求值策略?

程式碼求值策略

在選擇求值策略時,大多數開發人員會從如下兩種策略中做出選擇:

  • 立即求值 你可以立即執行耗時的程式碼。
  • 惰性求值 等到你的程式裡的其他部分需要這段耗時程式碼的結果時,再去執行它。

這兩種求值策略可能是目前最受歡迎的,但在我重構了我的網站後,我認為這兩個策略可能是最糟糕兩個選擇。

立即求值的缺點

從我網站上的效能問題可以很好地看出,立即求值有一個缺點:如果使用者在程式碼執行時與你的頁面進行互動,瀏覽器必須等到程式碼執行完成後才能做出響應。

當你的頁面看起來已經準備好響應使用者輸入卻無法響應時,這個問題尤為突出。使用者會感覺你的頁面很卡,甚至以為頁面徹底崩潰了。

預先執行的程式碼越多,頁面互動所需的時間就越長。

惰性求值的缺點

如果立即執行所有程式碼是不好的,那麼一個顯而易見的解決方案就是等到需要的時候再執行。這樣就不會提前執行不必要的程式碼,尤其是一些從未被使用過的程式碼。

當然,等到使用者需要的時候再執行的問題是:你必須確保你的高耗時的程式碼能夠阻止使用者輸入。

對於某些情況(比如另外載入網路資源),將其推遲到使用者請求時再載入是有意義的。但對於你的大多數程式碼(例如從 localStorage 讀取資料,處理大型資料集等等)而言,你肯定希望它在使用者互動之前就執行完畢。

其他選擇

其他可選擇的求值策略介於立即求值和惰性求值之間。我不確定以下兩種策略是否有官方名稱,我把它們稱作延遲求值和空閒求值:

  • 延遲求值: 使用 setTimeout 之類的函式,在後續任務中來執行你的程式碼。
  • 空閒求值: 一種延遲求值策略,你可以使用像 requestIdleCallback 這樣的 API 來組織程式碼執行。

這兩個選項通常都比立即求值或惰性求值好,因為它們不太可能由於單個長任務阻塞使用者輸入。這是因為,雖然瀏覽器不能中斷任何單個任務來響應使用者輸入(這樣做很可能會破壞網站),但是它們可以在計劃任務佇列之間執行任務,而且大多數瀏覽器會優先處理由使用者輸入觸發的任務。這稱為輸入優先

換句話說:如果確保所有程式碼都執行在耗時短、不同的任務中(最好小於 50 毫秒),你的程式碼就再也不會阻塞使用者輸入了。

重要! 雖然瀏覽器能夠在任務佇列中優先執行輸入回撥函式,但是瀏覽器無法將這些輸入回撥函式在排列好的微任務之前執行。由於 promise 和 async 函式作為微任務執行,將你的同步程式碼轉換為基於 promise 的程式碼不會起到緩解使用者輸入阻塞的作用。

如果你不熟悉任務和微任務之間的區別,我強烈建議你觀看我的同事傑克關於事件迴圈的精彩演講。

鑑於我剛才所說的,可以使用 setTimeout()requestIdleCallback() 來重構我的 main() 函式,將我的初始化程式碼拆解為單獨的任務:

const main = () => {
  setTimeout(() => drawer.init(), 0);
  setTimeout(() => contentLoader.init(), 0);
  setTimeout(() => breakpoints.init(), 0);
  setTimeout(() => alerts.init(), 0);

  requestIdleCallback(() => analytics.init());
};

main();
複製程式碼

然而,雖然這比以前更好(許多小任務 vs. 一個長任務),正如我上文解釋的那樣,它可能還不夠好。例如,如果我延遲我 UI 元件(特別是 contentLoaderdrawer)的初始化過程,雖然它們幾乎不會阻塞使用者輸入,但是當使用者嘗試與它們互動時,它們也存在未準備好的風險!

雖然使用 requestIdleCallback () 來延遲我的 analytics 方法可能是一個好主意,但在下一個空閒時間之前我關心的任何互動都將被遺漏。而且如果在使用者離開頁面之前,瀏覽器都沒有空閒時間,這些回撥函式可能永遠不會執行!

因此,如果所有這些求值策略都有缺點,那麼我們該作何選擇呢?

空閒執行,緊急優先

在長時間思考這個問題之後,我意識到我真正想要的求值策略是:先把程式碼推遲到空閒時間執行,但是一旦程式碼被呼叫則立即執行。換句話說:“空閒執行,緊急優先”。

“空閒執行,緊急優先”的策略迴避了我在上一節中指出的大多數缺點。在最壞的情況下,它與延遲計算具有完全相同的效能特徵;在最好的情況下,它完全不會阻塞使用者互動,因為在空閒時間裡,程式碼都已經執行完畢了。

我還得提一點,這個策略既適用於單任務(在空閒時間求值),也適用於多工(建立一個有序的任務佇列,可以空閒時間執行佇列中的任務)。我先解釋一下單任務(空閒值)變體,因為它更容易理解。

空閒值

我在上文提到過,初始化 Int.DateTimeFormat 物件可能非常耗時,因此若不需要立即呼叫該例項,最好在空閒時間去初始化。當然,一旦需要它,你就希望它已經存在了。所以這是一個可以用“空閒執行,緊急優先”策略來解決的完美的例子。

如下是我們重構以使用新策略的簡化版元件的例子:

class MyComponent {
  constructor() {
    addEventListener('click', () => this.handleUserClick());

    this.formatter = new Intl.DateTimeFormat('en-US', {
      timeZone: 'America/Los_Angeles',
    });
  }

  handleUserClick() {
    console.log(this.formatter.format(new Date()));
  }
}
複製程式碼

上面的 MyComponent 例項在其建構函式中做了兩件事:

  • 為使用者互動新增事件偵聽器。
  • 建立 Intl.DateTimeFormat 物件。

該元件很好地說明了為什麼我們經常需要在單個元件內部拆解任務(而不僅僅在元件級別拆解任務)。

在這種情況下,事件監聽器立即執行非常重要,但在事件處理函式需要之前,建立 Intl.DateTimeFormat 例項是不必要的。當然我們也不想在事件處理函式中建立Intl.DateTimeFormat 物件,因為這樣會使事件處理函式變得很慢。

下面就是使用“空閒執行,緊急優先”策略修改後的程式碼。需要注意的是,這裡使用了 IdleValue 幫助類,後續我會進行講解:

import {IdleValue} from './path/to/IdleValue.mjs';

class MyComponent {
  constructor() {
    addEventListener('click', () => this.handleUserClick());

    this.formatter = new IdleValue(() => {
      return new Intl.DateTimeFormat('en-US', {
        timeZone: 'America/Los_Angeles',
      });
    });
  }

  handleUserClick() {
    console.log(this.formatter.getValue().format(new Date()));
  }
}
複製程式碼

如你所見,此程式碼和先前的版本沒有太大的區別,但在新程式碼中,我沒有將 this.formatter 賦值給新的Intl.DateTimeFormat 物件,而是將 this.formatter 賦值給了 IdleValue 物件,在 IdleValue 內部進行 Intl.DateTimeFormat 的初始化過程。

IdleValue 類的工作方式是排程初始化函式,使其在瀏覽器的下一個空閒時間執行。如果空閒時間在引用 IdleValue 例項之前,則不會發生阻塞,而且可以在請求時立即返回該值。但另一方面,如果在下一個空閒時間之前引用了 IdleValue 例項,則取消初始化函式在空閒時間中的排程任務,並且立即執行初始化函式。

下面是如何實現 IdleValue 類的要點(注意:我已經發布了這段程式碼,它是idlize的一部分,idlize 裡面包含了本文出現的所有幫助類):

export class IdleValue {
  constructor(init) {
    this._init = init;
    this._value;
    this._idleHandle = requestIdleCallback(() => {
      this._value = this._init();
    });
  }

  getValue() {
    if (this._value === undefined) {
      cancelIdleCallback(this._idleHandle);
      this._value = this._init();
    }
    return this._value;
  }

  // ...
}
複製程式碼

雖然在上面的示例中包含 IdleValue 類並不需要很多修改,但是它在技術上改變了公共 API( this.formatter vs. this.formatter.getValue())。

如果你無法修改公共 API,但是還想要使用 IdleValue 類,則可以將 IdleValue 類與 ES2015 的 getters 一起使用:

class MyComponent {
  constructor() {
    addEventListener('click', () => this.handleUserClick());

    this._formatter = new IdleValue(() => {
      return new Intl.DateTimeFormat('en-US', {
        timeZone: 'America/Los_Angeles',
      });
    });
  }

  get formatter() {
    return this._formatter.getValue();
  }

  // ...
}
複製程式碼

或者,如果你不介意抽象一點,你可以使用 defineIdleProperty() 幫助類(底層使用的是 Object.defineProperty()):

import {defineIdleProperty} from './path/to/defineIdleProperty.mjs';

class MyComponent {
  constructor() {
    addEventListener('click', () => this.handleUserClick());

    defineIdleProperty(this, 'formatter', () => {
      return new Intl.DateTimeFormat('en-US', {
        timeZone: 'America/Los_Angeles',
      });
    });
  }

  // ...
}
複製程式碼

對於執行非常耗時的個別屬性值,沒有理由不使用此策略,特別是你不用為了使用此策略而去修改你的 API!

雖然這個例子使用了 Intl.DateTimeFormat 物件,但如下情況使用本策略也是一個好的選擇:

  • 處理大量資料集。
  • 從 localStorage(或 cookie)中獲取值。
  • 執行 getComputedStyle()getBoundingClientRect() 或任何其他可能需要在主執行緒上重繪樣式或佈局的 API。

空閒任務佇列

上文中的技術適用於可以通過單個函式計算出來的屬性,但在某些情況下,邏輯可能無法寫到單個函式裡,或者,即使技術上可行,您仍然希望將其拆分為更小的一些函式,以免其長時間阻塞主執行緒。

在這種情況下,我們真正需要的是一種佇列,在瀏覽器有空閒時間時,可以安排多個任務(函式)按照順序執行。佇列將在可能的情況下執行任務,並且當需要回到瀏覽器時(比如使用者正在進行互動)能夠暫停執行任務。

為了解決這個問題,我構建了一個 IdleQueue 類,可以像這樣使用它:

import {IdleQueue} from './path/to/IdleQueue.mjs';

const queue = new IdleQueue();

queue.pushTask(() => {
  // 一些耗時的函式可以在空閒時間執行...
});

queue.pushTask(() => {
  // 其他一些依賴上面函式的任務
  // 耗時函式已經執行...
});
複製程式碼

注意: 將同步的 JavaScript 程式碼拆解單獨的任務和程式碼分割不同:前者被拆解的任務為可作為任務佇列的一部分,並非同步執行;而程式碼分割則是將較大的 JavaScript 包拆分為較小的檔案的過程(它對於提高效能也很重要)。

與上面提到的的空閒時間初始化屬性的策略一樣,空閒任務佇列也可以在需要立刻得到結果的情況下立即執行(“緊急”情況)。

同樣,最後一點非常重要;不僅僅因為有時我們需要儘快計算出某些結果,還有一個原因是我們通常都整合了同步的第三方 API,我們需要能夠同步執行任務,以保證相容性。

在理想的情況下,所有 JavaScript API 都是非阻塞的、非同步的、程式碼量小的,並且由能夠返回主執行緒。但在實際情況下,由於遺留的程式碼庫或整合了無法控制的第三方庫,我們通常別無選擇,只能使用同步。

正如我之前所說,這是“空閒執行,緊急優先”策略的巨大優勢之一。它可以輕鬆應用於大多數程式,而無需大規模重寫架構。

保證緊急任務執行

我在上文提到過,requestIdleCallback() 不能保證回撥函式一定會執行。這也是我在與開發人員討論 requestIdleCallback() 時,得到的他們不使用 requestIdleCallback() 的主要原因。在許多情況下,程式碼可能無法執行足以成為不使用它的理由 —— 開發人員寧願保險地保持程式碼同步(即使會發生阻塞)。

網站分析程式碼就是一個很好的例子。網站分析程式碼的問題在於,很多情況下,在頁面解除安裝時,網站分析程式碼就要執行(例如,跟蹤外鏈點選等),在這種情況下,顯然使用 requestIdleCallback() 不合適,因為回撥函式根本不會執行。而且由於開發人員不清楚分析庫的 API 在頁面的生命週期中的呼叫時機,他們也傾向於求穩,讓所有程式碼同步執行(這很不幸,因為從使用者體驗方面來說這些分析程式碼毫無作用)。

但是使用“空閒執行,緊急優先”模式來解決這個問題就很簡單了。我們所要做的就是確保只要頁面處於將要解除安裝的狀態,就會立即執行佇列中的網站分析程式碼。

如果你熟悉我近期發表在 Page Lifecycle API 的文章裡面給出的建議,你就會知道在頁面被終止或丟棄之前,最後一個可靠的回撥函式visibilitychange 事件(因為頁面的 visibilityState 屬性會變為隱藏)。而且使用者無法在頁面隱藏的情況下進行互動,因此這正是執行空閒任務的最佳時機。

實際上,如果你使用了 IdleQueue 類,可以通過一個簡單的配置項傳遞給建構函式,來啟用該功能。

const queue = new IdleQueue({ensureTasksRun: true});
複製程式碼

對於渲染等任務,無需確保在頁面解除安裝之前執行任務,但對於儲存使用者狀態和傳送結束回話分析等任務,可以選擇將此選項設定為 true

注意: 監聽 visibilitychange 事件應該足以確保在解除安裝頁面之前執行任務,但是由於 Safari 的漏洞,當使用者關閉選項卡時,頁面隱藏和 visibilitychange 事件並不總是觸發,我們必須實現一個解決方案來適配 Safari 瀏覽器。這個解決方案已經在 IdleQueue 類中為你實現好了,但如果你需要自己實現它,則需注意這一點。

警告! 不要使用監聽 unload 事件的方式來執行頁面解除安裝前需要執行的佇列。unload 事件不可靠,在某些情況下還會降低效能。有關更多詳細資訊,請參閱我在Page Lifecycle API 上的文章

“空閒執行,緊急優先”策略的使用例項

每當要執行可能非常耗時的程式碼時,應該嘗試將其拆解為更小的任務。如果不需要立即執行該程式碼,但未來某些時候可能需要,那麼這就是一個使用“空閒執行,緊急優先”策略的完美場景。

在你自己的程式碼中,我建議做的第一件事是檢視所有建構函式,如果存在可能會很耗時的操作,使用 IdleValue 物件重構它們。

對於一些必需但又不用直接與使用者互動的邏輯部分程式碼,請考慮將這些邏輯新增到 IdleQueue 中。不用擔心,你可以在任何你需要的時候立即執行該程式碼。

特別適合使用該技術的兩個具體例項(並且與大部分網站相關)是持久化應用狀態(如 Redux)和網站分析。

注意: 這些使用例項的目的都是使任務在空閒時間執行,因此如果這些任務不立即執行則沒有問題。如果你需要處理高優先順序的任務,想要讓它們儘快執行(但仍然優先順序低於使用者輸入),那麼requestIdleCallback() 可能無法解決你的問題。

幸運的是,我的幾個同事開發出了新的 web 平臺 API(shouldYield()和原生的 Scheduling API)可以幫助我們解決這個問題。

持久化應用狀態

我們來看一個 Redux 應用程式,它將應用程式狀態儲存在記憶體中,但也需要將其儲存在持久化儲存(如 localStorage)中,以便使用者下次訪問頁面時可以重新載入。

大多數使用 localStorage 持久化儲存狀態的 Redux 應用程式使用了防抖技術,大致程式碼如下:

let debounceTimeout;

// 使用 1000 毫秒的抖動時間將狀態更改儲存到 localStorage 中。
store.subscribe(() => {
  // 清除等待中的寫入操作,因為有新的修改需要儲存。
  clearTimeout(debounceTimeout);

  // 在 1000 毫秒(防抖)之後執行儲存操作,
  // 頻繁的變化沒有必要儲存。
  debounceTimeout = setTimeout(() => {
    const jsonData = JSON.stringify(store.getState());
    localStorage.setItem('redux-data', jsonData);
  }, 1000);
});
複製程式碼

雖然使用防抖技術總比什麼都不做強,但它並不是一個完美的解決方案。問題是無法保證防抖函式的執行不會阻塞對使用者至關重要的主執行緒。

在空閒時間執行 localStorage 寫入會好得多。你可以將上述程式碼從防抖策略轉換為“空閒執行,緊急優先”策略,如下所示:

const queue = new IdleQueue({ensureTasksRun: true});

// 當瀏覽器空閒的時候儲存狀態更改,
// 為了避免多餘地執行程式碼我們只儲存最近發生的狀態更改。
store.subscribe(() => {
  // 清除等待中的寫入操作,因為有新的修改需要儲存。
  queue.clearPendingTasks();

  // 當空閒時執行儲存操作。
  queue.pushTask(() => {
    const jsonData = JSON.stringify(store.getState());
    localStorage.setItem('redux-data', jsonData);
  });
});
複製程式碼

請注意,此策略肯定比使用防抖策略更好,因為它能夠保證即使使用者離開頁面之前將狀態儲存好。如果使用上面的防抖策略的例子,在使用者離開頁面的情況下,很有可能造成寫入狀態失敗。

網站分析

另一個“空閒執行,緊急優先”策略適合的例項就是網站分析程式碼。下面的例子教你如何使用 IdleQueue 類來傳送你的網站分析資料,並且可以保證,即使使用者關閉了標籤頁或跳轉到了其他頁面,並且還沒有等到下次的空閒時間,這些資料也可以正常傳送

const queue = new IdleQueue({ensureTasksRun: true});

const signupBtn = document.getElementById('signup');
signupBtn.addEventListener('click', () => {
  // 將其新增到空閒佇列中,不再立即傳送事件。
  // 空閒佇列能夠保證事件被髮送,即使使用者
  // 關閉標籤頁或跳轉到了其他頁面。
  queue.pushTask(() => {
    ga('send', 'event', {
      eventCategory: 'Signup Button',
      eventAction: 'click',
    });
  });
});
複製程式碼

除了可以保證緊急情況之外,把這個任務新增到空閒時間佇列也能夠確保其不會阻塞響應使用者點選事件的其他程式碼。

實際上,我建議將你所有的網站分析程式碼放到空閒時間執行,包括初始化程式碼。而且像 analytics.js 這樣的庫,其 API 已經支援命令佇列,我們只需簡單地在我們的 IdleQueue 例項上新增這些命令。

例如,你可以將預設的 analytics.js 初始化程式碼片段的最後一部分:

ga('create', 'UA-XXXXX-Y', 'auto');
ga('send', 'pageview');
複製程式碼

修改為:

const queue = new IdleQueue({ensureTasksRun: true});

queue.pushTask(() => ga('create', 'UA-XXXXX-Y', 'auto'));
queue.pushTask(() => ga('send', 'pageview'));
複製程式碼

(你也可以像我做的一樣對 ga() 使用包裝器,使其能夠自動執行佇列命令)。

requestIdleCallback 的瀏覽器相容性

在撰寫本文時,只有 Chrome 和 Firefox 支援 requestIdleCallback()。雖然真正的 polyfill 是不可能的(只有瀏覽器可以知道它何時空閒),但是使用 setTimeout 作為一個備用方案還是很容易的(本文提到的所有幫助類和方法都使用這個備用方案)。

而且即使在不原生支援 requestIdleCallback() 的瀏覽器中,使用 setTimeout 這種備用方案也比不用強,因為瀏覽器仍然是優先處理使用者輸入,然後再處理通過 setTimeout() 函式建立的佇列中的任務。

使用本策略實際上提高了多少效能?

在本文開頭我提到我想出了這個策略,因為我試圖提高我網站的 FID 值。我嘗試拆分那些頁面開始載入就執行的程式碼,並且還得保證一些使用了同步 API 的第三方庫(如 analytics.js)的正常執行。

上文已經提到,在我使用“空閒執行,緊急優先”策略之前,我所有初始化程式碼集中在了一個任務中,耗費了 233 毫秒。在使用了“空閒執行,緊急優先”策略之後,可以看到出現了更多耗時更短的任務。實際上,最長的一個任務也僅僅耗時 37 毫秒!

我網站的 JavaScript 效能跟蹤圖,上面展示了很多短任務。

我網站的 JavaScript 效能跟蹤圖,上面展示了很多短任務。

需要重點強調的是,使用新策略重構的程式碼和之前執行的任務的數量是相同的,變化僅僅是將其拆分為了多個任務,並且在空閒時間裡執行它們。

因為所有任務都不超過 50 毫秒,所以沒有任何一個任務影響我的互動時間(TTI),這對我的 lighthouse 得分很有幫助:

使用了“空閒執行,緊急優先”策略後,我的 lighthouse 報告。 - 全部 100 分!

使用了“空閒執行,緊急優先”策略後,我的 lighthouse 報告。

最後, 由於本工作的目的是提高我網站的 FID, 在將這些變更上線之後, 經過分析,我非常興奮地看到:對於 99% 的頁面,FID 減少了 67%!

Code version FID (p99) FID (p95) FID (p50)
Before idle-until-urgent 254ms 20ms 3ms
After Idle-until-urgent 85ms 16ms 3ms

總結

在理想情況下,我們的網站再也不會不必要地阻塞主執行緒了。我們會使用 web worker 來處理我們非 UI 的工作,而且我們還有瀏覽器內建好的 shouldYield() 和原生的 Scheduling API

但在實際情況下,我們網站工程師往往沒有選擇,只能將非 UI 的程式碼放到主執行緒去執行,這導致了網頁出現無響應的問題。

希望這篇文章已經說服了你,是時候去打破我們的長耗時 JavaScript 任務了。而且“空閒執行,緊急優先”策略能夠把看起來同步的 API 轉到空閒時間執行,能夠和全部我們已知的和使用中的工具庫結合,“空閒執行,緊急優先”是一個極好的解決方案。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章