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

ES2049發表於2018-09-30

Idle Until Urgent(閒置直到緊急)

譯者注:大家耳熟能詳的優化策略已經談論了好多年了,用 Chrome 效能分析工具發現瓶頸並針對性優化的文章網路上也有不少,但是從執行時排程策略來思考優化方式的卻鳳毛麟角,正如我們之前只知道使用 setTimeout 來進行 throttling 和 debounce。因此在偶然看到這篇文章時,我有一種__豁然開朗__的感覺:原來我們還可以在這麼細緻的粒度上進行排程。

原文:philipwalton.com/articles/id…

幾周前,我正著手檢視我網站的一些效能指標。具體來說,我想看看我在我們最新的效能標準,即首次輸入延遲 (FID)上的表現。由於我的網站只是一個部落格(並沒有執行很多 JavaScript ),所以我希望我能看到一個相當不錯的結果。

小於 100 毫秒的輸入延遲通常被使用者視為即時響應,因此我們建議的效能目標(以及我希望在我的分析中看到的數字)是:對於 99% 的頁面載入來說,FID <100ms。

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

總而言之,在不刪除我網站的任何功能的情況下,我需要能夠在第 99 百分位數下將我的FID控制在 100 毫秒以下。但我確信,你們這些讀者更感興趣的是以下資訊:

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

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

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

我的效能問題

首次輸入延遲(FID)是一個度量標準,用於衡量使用者首次與您的網站進行互動的時間(對於像我這樣的部落格來說,最有可能的情況是點選連結)以及瀏覽器能夠響應該互動的時間(對於我部落格的點選互動來說,就是請求載入下一頁)。

這裡可能存在延遲的原因是瀏覽器的主執行緒正在忙於做其他事情(通常是執行 JavaScript 程式碼)。因此,要診斷出高於預期的 FID,您首先應當做的是在頁面載入時啟用站點的效能跟蹤(同時啟用 CPU 和網路限制),然後在主執行緒上查詢需要執行很長時間的各個任務。一旦確定了這些長任務,你就可以嘗試將它們分解為更小的任務。

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

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

相關文章