「譯」代碼優化策略 — Idle Until Urgent

Idle Until Urgent(閒置直到緊急)

譯者注:你們耳熟能詳的優化策略已經談論了好多年了,用 Chrome 性能分析工具發現瓶頸並針對性優化的文章網絡上也有很多,可是從運行時調度策略來思考優化方式的卻百裏挑一,正如咱們以前只知道使用 setTimeout 來進行 throttling 和 debounce。所以在偶然看到這篇文章時,我有一種__豁然開朗__的感受:原來咱們還能夠在這麼細緻的粒度上進行調度。javascript

原文:philipwalton.com/articles/id…java

幾周前,我正着手查看我網站的一些性能指標。具體來講,我想看看我在咱們最新的性能標準,即首次輸入延遲 (FID)上的表現。因爲個人網站只是一個博客(並無運行不少 JavaScript ),因此我但願我能看到一個至關不錯的結果。webpack

小於 100 毫秒的輸入延遲一般被用戶視爲即時響應,所以咱們建議的性能目標(以及我但願在個人分析中看到的數字)是:對於 99% 的頁面加載來講,FID <100ms。git

令我驚訝的是,個人網站在第 99 百分位數下的 FID 爲 254 毫秒。雖然那並不可怕,但個人完美主義性格卻令我沒法鬆懈。嗯,我必須解決它!github

總而言之,在不刪除我網站的任何功能的狀況下,我須要可以在第 99 百分位數下將個人FID控制在 100 毫秒如下。但我確信,大家這些讀者更感興趣的是如下信息:web

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

對於上面的第二點,當我試圖解決個人問題時,我偶然發現了一個讓我想分享的,很是有趣的性能策略(這就是我寫這篇文章的主要緣由)。json

我將這個策略稱之爲:idle-until-urgent(閒置直到緊急) 。redux

個人性能問題

首次輸入延遲(FID)是一個度量標準,用於衡量用戶首次與您的網站進行交互的時間(對於像我這樣的博客來講,最有可能的狀況是點擊連接)以及瀏覽器可以響應該互動的時間(對於我博客的點擊交互來講,就是請求加載下一頁)。windows

這裏可能存在延遲的緣由是瀏覽器的主線程正在忙於作其餘事情(一般是執行 JavaScript 代碼)。所以,要診斷出高於預期的 FID,您首先應當作的是在頁面加載時啓用站點的性能跟蹤(同時啓用 CPU 和網絡限制),而後在主線程上查找須要執行很長時間的各個任務。一旦肯定了這些長任務,你就能夠嘗試將它們分解爲更小的任務。api

如下是我在對網站進行性能跟蹤時的發現:

idle-until-urget-before-1400w-efc9f3a53c.png
一份加載我網站時的 JavaScript 性能跟蹤(啓用網絡/ CPU限制)。

請注意,在 main 腳本 bundle 執行時,它做爲單個任務須要 233 毫秒才能運行完成。

idle-until-urget-before-eval-1400w-7a455de908.png
執行我網站的 main bundle 須要 233 毫秒。

這些代碼中的一些是 webpack boilerplate 和 babel polyfill,但大多數代碼來自我腳本中的 main() 入口函數,它自己須要 183 毫秒才能完成:

idle-until-urget-before-main-1400w-08fe4dd1c5.png
執行個人站點的main()入口函數須要 183 毫秒。

然而我並無在個人 main() 函數中作什麼奇怪的事情。我在函數中只是初始化個人 UI 組件,而後運行分析:

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

  analytics.init();
};
複製代碼

那麼究竟是什麼花了這麼長時間在運行?

好吧,若是你看一下這個火焰圖的尾部,你不會看到有任何函數在執行時明顯地佔據了大部分時間。大多數單個函數會在不到 1 毫秒的時間內運行,可是當你將它們所有添加起來以後,在單個同步調用堆棧中運行它們就須要花費超過 100 毫秒。

這就是殺千刀的 JavaScript。

因爲問題是全部這些功能都做爲單個任務的一部分在運行,所以瀏覽器必須等到此任務完成纔可以響應用戶的交互。很明顯,解決方案是將這些代碼分解爲多個任務。但這提及來容易,作起來難。

乍一看,彷佛顯而易見的解決方案是給 main() 函數中的每一個組件排個優先級(它們實際上已經按優先級順序排列),最快初始化最高優先級的組件,而後將其餘組件的初始化推遲到後續的任務去作。

雖然這個方法可能對某些人有所幫助,但它並非每一個人均可以實施的通用解決方案,也不能很好地擴展到一個很是大的網站中。緣由以下:

  • 推遲 UI 組件初始化僅在組件還沒有渲染時纔有用。若是它已經被渲染過了,那麼延遲這個組件的初始化運行,則會帶來在用戶交互時組件並未準備好的風險。
  • 在許多狀況下,全部 UI 組件要麼同等重要,要麼彼此依賴,所以它們都須要同時進行初始化。
  • 有時單個組件須要足夠長的時間來初始化,此時即便它們只在本身的任務中運行,它們也會阻塞主線程。

實際狀況是,在本身的任務中初始化每一個組件一般是不夠高效的,而且每每是不可能的。咱們一般須要把任務分解到每一個被初始化的組件中。

貪婪的組件

一個真正須要將其初始化代碼分解的組件的完美示例能夠經過將此性能跟蹤結果進一步縮放觀察看到。在 main() 函數的中間,你會看到個人一個組件使用 Intl.DateTimeFormat API:

idle-until-urget-before-date-time-format-1400w-c67615763f.png
建立 Intl.DateTimeFormat 實例花了 13.47ms!

建立此對象須要 13.47 毫秒!

問題在於, Intl.DateTimeFormat 實例雖然在組件的構造函數中被建立了,但實際上並無被引用,直到其餘組件將其用於格式化日期爲止。可是,此組件不知道它何時會被引用,所以它只能謹慎行事,當即實例化 Int.DateTimeFormat 對象。

但這真的是正確的代碼求值策略嗎?若是不是,那正確的應該是什麼?

代碼求值策略

在爲可能執行代價高昂的代碼選擇求值策略時 ,大多數開發人員會選擇如下其中一項:

  • 及早求值(Eager evaluation):您能夠當即運行代價高昂的代碼。
  • 惰性求值(Lazy evaluation):你等到程序的另外一部分須要這段代價高昂代碼的結果時,你才運行它。

這也許是兩種最受歡迎​​的求值策略,但在重構完個人網站後,我如今認爲這些多是你最糟糕的選擇。

及早求值的缺點

我網站上的性能問題很好地說明了及早求值的一個缺點,即若是用戶在代碼求值時嘗試與您的頁面進行交互,瀏覽器必須等到代碼完成求值才能作出響應。

若是您的頁面看起來已準備好響應用戶輸入,但實際上卻沒法響應,在這種狀況下,這尤爲成問題。用戶會認爲您的頁面緩慢甚至徹底是壞的。

你預先求值的代碼越多,您的頁面達到能夠交互所需的時間就越長。

惰性求值的缺點

若是當即運行全部代碼是很差的,那麼最明顯的解決方案就是等到實際須要它的時候再運行。這樣就不會沒必要要地運行代碼,特別是在用戶實際上從未須要它的狀況下。

固然,等到用戶須要該代碼的結果時再運行代碼的問題在於,用戶輸入確定會被你那些代價高昂的代碼給堵塞住。

對於某些事情(好比從網絡加載其餘內容),將其推遲到用戶請求時再執行是有意義的。可是對於您正在運行的大多數代碼(例如從 localStorage 讀取數據,處理大型數據集等),您確定但願在須要它的用戶交互開始以前就能開始執行。

其餘選擇

你也能夠在及早和惰性求值之間選取一種其它求值策略,我不肯定如下兩種策略是否有官方名稱,但我會稱之爲延遲求值和空閒求值:

  • 延遲求值(Deferred evaluation):使用相似於 setTimeout 之類的方法將代碼安排在一個將來的任務裏執行
  • 空閒求值(Idle evaluation):一種延遲求值,您可使用像 requestIdleCallback 這樣的API來安排代碼執行。

這兩個選項一般都比及早或惰性求值更好,由於它們不太可能致使阻止輸入的單個長任務發生。這是由於,雖然瀏覽器沒法中斷任何一個任務以響應用戶輸入時(這樣作將極有可能讓頁面崩潰),但它能夠在計劃任務的隊列之間運行任務,大多數瀏覽器會將用戶輸入引起的任務這麼安排。這稱爲輸入優先級 。

換句話說:若是可以確保全部代碼都運行在簡短,不一樣的任務中(最好少於 50 毫秒 ),您的代碼將永遠不會堵塞用戶輸入。

重要! 雖然瀏覽器能夠在排隊任務以前執行輸入的回調,但它們沒法在排隊的微任務以前運行輸入回調。因爲 promises 和 async 函數會做爲微任務運行,因此將同步代碼轉換爲基於 promise 的代碼並不會避免它堵塞用戶輸入!

若是您不熟悉任務和微任務之間的區別,我強烈建議您觀看個人同事 Jake 關於事件循環的精彩演講

鑑於我剛纔所說,我能夠重構個人 main() 函數,使用 setTimeout()requestIdleCallback() 將個人初始化代碼分解爲獨立的任務:

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

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

main();;
複製代碼

然而,雖然這比之前好了一點(許多小任務 vs 一項長任務),但正如我上面解釋的那樣,它可能仍然不夠好。例如,若是我推遲我 UI 組件(特別是 contentLoader 和 drawer)的初始化,它們將不太可能堵塞用戶輸入,可是當用戶嘗試與它們交互時,它們也存在未準備好的風險!

雖然使用 requestIdleCallback() 將個人 analytics 推遲多是一個好主意,但在下一個空閒時段以前我關心的任何交互都將被遺漏。若是在用戶離開頁面以前沒有空閒時段,這些回調代碼可能永遠不會運行!

所以,若是全部的求值策略都有缺點,你應該選擇哪個呢?

Idle Until Urgent (閒置直到緊急)

在花了不少時間思考這個問題以後,我意識到我真正想要的求值策略是讓個人代碼在最初時被推遲到空閒時段,可是在須要時可以當即運行。換句話說: idle-until-urgent 。

idle-until-urgent 策略避免了我在上一節中描述的大多數缺點。在最壞的狀況下,它具備與延遲求值徹底相同的性能特徵,而且在最好的狀況下它根本不堵塞交互性,由於代碼執行發生在空閒期間。

我還應當提到的一點是,這種策略既適用於單個任務(閒時計算值),也適用於多個任務(能夠在空閒時運行的一個有序任務隊列)。我將首先解釋單任務(空閒值)形式,由於它更容易理解。

空閒值

在上面,我向你們展現了初始化 Int.DateTimeFormat 對象可能代價很是昂貴,所以若是不是當即須要這個實例的話,最好在空閒期間初始化它。固然,一旦當它被須要時,你就想讓它存在,因此這是一個 idle-until-urgent 求值策略的完美候選對象。

考慮如下咱們要重構以使用此新策略的簡化組件示例:

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 對象,由於它的龜速會推遲該事件的運行。

因此,這就是咱們更新此代碼以使用 idle-until-urgent 策略的方法。注意,我正在使用 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 類起做用的方式是,它會調度在會下一個空閒期間運行的初始化函數。若是空閒階段發生在 IdleValue 被引用實例以前,則不會發生阻塞,而且能夠在請求時當即拿到該返回值。但另外一方面,若是在下一個空閒週期_以前_引用該值,則安排好的空閒回調會被取消,而且初始化函數會被當即調用。

下面是如何實現 IdleValue 類的要點(注意:我還發布了這段代碼做爲 idlize 包 的一部分,其中包含了本文中顯示的全部 helper 類):

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 與 this.formatter.getValue() )。

若是您處於想要使用 IdleValue 類但沒法更改公共 API 的狀況,則能夠將 IdleValue 類與 ES2015 getter 一塊兒使用:

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() helper 類(它在底層使用了 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(() => {
  // Some expensive function that can run idly...
});

queue.pushTask(() => {
  // Some other task that depends on the above
  // expensive function having already run...
});
複製代碼

注意:將同步 JavaScript 代碼分解爲可做爲任務隊列的一部分異步運行的單獨任務與代碼分割不一樣,後者是將大型 JavaScript bundle 分解爲較小的文件(這對於提升性能也很重要)。

與上面顯示的空閒初始化屬性策略同樣,空閒任務隊列也能夠在須要當即執行結果的狀況下當即運行(即「緊急」狀況下)。

一樣,最後一點很是重要:有時不只是由於你須要儘快計算某些東西,而是一般由於你要與同步的第三方 API 集成,因此爲了兼容,你須要可以同步運行你的任務。

在一個完美的世界中,全部 JavaScript API 都是非阻塞的,異步的,而且由能夠隨意返回主線程的小塊代碼組成。但在現實世界中,因爲遺留代碼庫或與咱們沒法控制的第三方庫的集成,咱們一般別無選擇,只能保持同步。

正如我以前所說,這是 idle-until-urgent 模式的巨大優點之一。它能夠輕鬆應用於大多數程序,而無需大規模重寫架構。

保證緊急狀況

我在上面提到過 requestIdleCallback() 並無保證回調將會運行。在與開發人員討論 requestIdleCallback() 時,這是我聽到的他們不使用它的主要緣由。在許多狀況下,代碼沒法運行的可能性足以成爲不使用代碼的理由 - 爲了使代碼安全運行並保持代碼同步(固然同時也會阻塞)。

一個完美的例子就是分析代碼。分析代碼的問題是在不少狀況下須要在頁面卸載時運行(例如跟蹤出站連接點擊等),在這種狀況下, requestIdleCallback() 根本沒法做爲一個選項,由於回調永遠不會運行。因爲分析庫不知道他們的用戶什麼時候會在頁面生命週期中調用他們的 API,故而他們也傾向於安全並同步運行全部代碼(這很不幸,由於分析代碼對於用戶體驗來講並不關鍵)。

可是在 idle-until-urgent 模式下,有一個簡單的解決方案。咱們所要作的就是確保隊列只要當頁面處於可能很快卸載的狀態,就會當即運行。

若是您熟悉我在最近關於 Page Lifecycle API 的文章中給出的建議,您就會知道在頁面被終止或丟棄以前,最後一個開發者能夠依賴的可靠回調visibilitychange 事件(由於頁面的 visibilityState 變爲隱藏)。並且因爲在隱藏狀態下用戶沒法與頁面進行交互,所以這是運行任何排隊中的空閒任務的最佳時機。

實際上,若是使用 IdleQueue 類,則咱們可使用傳遞給構造函數的簡單配置項來啓用此功能。

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

對於渲染等任務,無需確保在頁面卸載以前運行任務,但對於保存用戶狀態和在會話結束時發送分析等任務,你可能但願將此選項設置爲 true

注意:監聽 visibilitychange 事件應該足以確保在卸載頁面以前運行任務,可是因爲 Safari 的漏洞,當用戶關閉選項卡時, pagehide 和 visibilitychange 事件並不老是觸發 ,你必須針對 Safari 實現一個應急方法。這個解決方法在 IdleQueue 類中已經爲您實現 ,但若是您本身實現它,則必須對它有足夠了解。

警告! 不要爲了在頁面卸載以前運行隊列而監聽 unload 事件。unload 事件並不可靠,而且在某些狀況下會有損性能。有關更多詳細信息,請參閱個人 Page Lifecycle API 文章 。

idle-until-urgent 的用例

每當你須要運行可能代價高昂的代碼時,你應該嘗試將其分解爲更小的任務。若是如今不須要當即使用該代碼,但將來某些時候可能須要該代碼,那麼它就是一個完美的,可以使用空閒直到緊急策略的用例 。

在你本身的代碼中,我建議作的第一件事就是查看你全部的構造函數,若是它們中的任何一個會運行可能很耗時的操做,那麼重構它們以使用 IdleValue 對象來代替。

對於其餘的一些邏輯,若是這些邏輯對於直接用戶交互是必要的,但並不必定是決定性的,那麼請考慮將該邏輯添加到 IdleQueue 。不用擔憂,若是你須要當即運行該代碼,你隨時能夠。

特別適合這種技術的兩個具體示例(而且與大部分網站相關)是持久化應用程序狀態(例如,使用Redux之類)和網站分析。

注意:這些用例想代表的意圖是任務應該在空閒期間運行,固然,若是它們沒有當即運行也沒有問題。若是您須要處理高優先級的任務,這些任務旨在儘快運行(但仍然須要響應輸入),那麼 requestIdleCallback() 可能沒法解決您的問題。

幸運的是,個人一些同事提出了新的Web平臺API( shouldYield() 和原生的 Scheduling API ),它們可能會對你有所幫助。

持久化應用狀態

考慮這樣一個 Redux 應用程序,它將應用程序狀態存儲在內存中,但也須要將其存儲在持久存儲(如 localStorage )中,以便下次用戶訪問頁面時能夠從新加載。

在 localStorage 中存儲狀態的大多數 Redux 應用程序使用的 debounce 技術大體是這樣的:

let debounceTimeout;

// Persist state changes to localStorage using a 1000ms debounce.
store.subscribe(() => {
  // Clear pending writes since there are new changes to save.
  clearTimeout(debounceTimeout);

  // Schedule the save with a 1000ms timeout (debounce),
  // so frequent changes aren't saved unnecessarily.
  debounceTimeout = setTimeout(() => {
    const jsonData = JSON.stringify(store.getState());
    localStorage.setItem('redux-data', jsonData);
  }, 1000);
});
複製代碼

雖然使用 debounce 技術確定比什麼都不用好,但它並非一個完美的解決方案。問題是你沒法保證當 debounced 函數運行時,它不會在對用戶來講很關鍵的時間點阻塞主線程。

在空閒時間安排 localStorage 寫入會好得多。你能夠將上述代碼從 debounce 策略轉換爲 idle-until-urgent 策略,以下所示:

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

// Persist state changes when the browser is idle, and
// only persist the most recent changes to avoid extra work.
store.subscribe(() => {
  // Clear pending writes since there are new changes to save.
  queue.clearPendingTasks();

  // Schedule the save to run when idle.
  queue.pushTask(() => {
    const jsonData = JSON.stringify(store.getState());
    localStorage.setItem('redux-data', jsonData);
  });
});
複製代碼

請注意,此策略確定比使用 debounce 更好,由於即便用戶離開頁面,它也能夠保證狀態獲得保存。而使用 debounce 的例子,寫入可能會在這種狀況下失敗。

網站分析

idle-until-urgent 的另外一個完美用例是分析代碼。下面是一個示例,說明如何使用 IdleQueue 類來安排發送分析數據,以確保即便用戶關閉選項卡或在下一個空閒時段以前導航網頁, 也會發送分析數據。

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

const signupBtn = document.getElementById('signup');
signupBtn.addEventListener('click', () => {
  // Instead of sending the event immediately, add it to the idle queue.
  // The idle queue will ensure the event is sent even if the user
  // closes the tab or navigates away.
  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() 函數建立一個可以自動把命令加入隊列的 wrapper,這就是我所作的 )。

requestIdleCallback 的瀏覽器支持

在撰寫本文時,只有 Chrome 和 Firefox 支持 requestIdleCallback() 。雖然真正的 polyfill 是不可能實現的(由於只有瀏覽器本身能夠知道它什麼時候空閒),可是很容易編寫一個退回 setTimeout 的 fallback(本文中提到的全部 helper 類和方法都使用了這個 fallback )。

即便在不原生支持 requestIdleCallback() 的瀏覽器中, 使用 setTimeout 的 fallback 確定比不使用此策略更好,由於瀏覽器仍然能夠在經過 setTimeout() 進行排隊的任務以前進行輸入優先級排序。

這實際上提升了多少性能?

在本文開頭我提到我想出了這個策略,是由於我試圖提升個人網站的 FID 值。我試圖將 main bundle 加載後的當即運行的全部代碼分割開,但我還須要確保個人網站繼續使用只有同步 API 的某些第三方庫(例如 analytics.js )。

我在實現 idle-until-urgent 以前作的跟蹤顯示出,我有一個包含全部初始化代碼的233ms 任務。在實現我在此描述的技術以後,你能夠看到我有多個更短期的任務。事實上,最長的一個如今只有37毫秒!

idle-until-urget-after-1400w-d526f6cca8.png
我網站的 JavaScript 的性能跟蹤顯示了許多簡短的任務。

這裏要強調的一個很是重要的一點是,它完成的工做和以前是同樣的,只是如今分散在多個任務上並在閒置期間運行。

由於沒有任何一項任務超過 50 毫秒,因此沒有一個任務影響個人交互時間(TTI),這對個人 lighthouse 得分頗有幫助:

lighthouse-report-4721b091da.png
我實施了 idle-until-urget 以後的 Lighthouse 報告。

最後,因爲全部這些工做的重點是提升個人FID,在將這些更改發佈到生產環境並查看結果後,我很高興發現我在第 99 百分位數下的 FID 值減小了67%!

代碼版本 FID(p99) FID(p95) FID(p50)
在 idle-until-urgent 以前 254ms 20ms 3ms
在 idle-until-urgent 以後 285ms 16ms 3ms

結論

在一個完美的世界中,咱們的網站永遠不會在沒必要要的時刻阻塞主線程。咱們都使用 Web worker 來完成非 UI 的工做,而且咱們在瀏覽器中內置了 shouldYield() 和原生 Scheduling API 。

可是在咱們當前的世界中,咱們的 Web 開發人員一般別無選擇,只能在主線程上運行非 UI 代碼,這會致使無響應。

但願本文可以說服你切分長期運行的 JavaScript 任務。並且,因爲 idle-until-urgent 能夠將看起來同步的 API 變成實際上在空閒時段運行的代碼,所以它是一個很好的解決方案,並適用於咱們今天普遍使用的庫。

若是您喜歡這篇文章並認爲其餘人也應該閱讀它,請在 Twitter 上分享 。

文章可隨意轉載,但請保留此 原文連接。 很是歡迎有激情的你加入 ES2049 Studio,簡歷請發送至 caijun.hcj(at)alibaba-inc.com

相關文章
相關標籤/搜索