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

幾周前,我開始查看我網站的一些性能指標。具體來講,我想看看個人網站在最新的性能指標 —— 首次輸入延遲 (FID)上的表現如何。 個人網站只是一個博客(並無運行不少的 JavaScript),因此我本來預期會獲得至關不錯的結果。javascript

用戶通常對於小於 100 毫秒的輸入延遲沒有感知,所以咱們推薦的性能目標(以及我但願在個人分析中看到的數字)是對於 99% 的頁面加載,FID 小於 100 毫秒。前端

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

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

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

說到上文中的第二點,當時我試圖解決個人問題時,偶然發現了一個很是有趣的性能策略,特別想分享給你們(這也是我寫這篇文章的主要緣由)。webpack

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

個人性能問題

首次輸入延遲(FID)是一個網站性能指標,指用戶與網站首次交互(像我這樣的博客,最有可能的首次交互是點擊連接)和瀏覽器響應此交互(請求加載下一頁面)之間的時間。git

存在延遲是因爲瀏覽器的主線程正在忙於作其餘事情(一般是在執行 JavaScript 代碼)。所以,要診斷這個高於預期的 FID,咱們首先須要在網站加載時啓動性能跟蹤(啓用 CPU 降頻和網絡限速),而後在主線程上找到耗時長的任務。一旦肯定了這些耗時長的任務,咱們就能夠嘗試將它們拆解爲更小的任務。github

如下是我在對網站啓用性能跟蹤後的發現:web

個人網站加載時的 JavaScript 性能跟蹤圖(啓用網絡限速/ CPU 降頻)

個人網站加載時的 JavaScript 性能跟蹤圖(啓用網絡限速和 CPU 降頻)。json

能夠注意到,主要腳本包在瀏覽器中單獨執行時,它須要耗時 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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索