[譯] React 中的排程

徐二斤發表於2019-04-02

在現代的應用程式中,使用者介面通常要同時處理多個任務。例如,一個搜尋元件可能要在響應使用者輸入的同時自動補全結果,一個互動式儀表盤可能需要在從伺服器載入資料並將分析資料傳送到後端的同時更新圖表。

所有這些並行的步驟都有可能導致互動介面響應緩慢甚至無響應,拉低使用者的滿意度,所以讓我們學習下如何解決這個問題。

使用者介面中的排程

我們的使用者期望及時反饋。無論是點選開啟模態框的按鈕還是在輸入框中新增文字,他們都不想在看到某種確認狀態之前進行等待。例如,點選按鈕可以立即開啟模態框,輸入框可以立即顯示剛剛輸入的關鍵字。

為了想象在並行操作下會發生什麼,讓我們來看看 Dan Abramov 在 JSConf Iceland 2018 上以超越 React 16 為主題的演講中演示的應用程式。

這個應用程式的工作原理如下:你在輸入框中的輸入越多,下面的圖表中的細節就會越多。由於兩個更新(輸入框和圖表)同時進行,所以瀏覽器必須進行大量計算以至於會丟棄其中的一些幀。這會導致明顯的延時以及糟糕的使用者體驗。

[譯] React 中的排程

視訊地址

但是,當有新鍵入時,優先更新使用者介面上輸入框的版本對使用者來說似乎執行得更快。因為使用者能收到及時的反饋,儘管它們需要相同的計算時間。

[譯] React 中的排程

視訊地址

不幸的是,當前的使用者介面體系架構使得實現這種優先順序變得非常重要,解決此問題的一種方法是通過防抖(debouncing)進行圖表更新。這種方法的問題在於當防抖函式的回撥函式執行時,圖表依舊在同步地渲染,這會再次導致使用者介面在一段時間內無響應。我們可以做的更好!

瀏覽器事件迴圈

在我們學習如何正確地實現更新優先順序之前,讓我們深入挖掘並理解瀏覽器為何在處理使用者互動時存在問題。

JavaScript 程式碼在單執行緒中執行,意味著在任意給定的時間段內只有一行 JavaScript 程式碼可以執行。同時,這個執行緒也負責處理其他文件的生命週期,例如佈局和繪製。1意味著每當 JavaScript 程式碼執行時,瀏覽器會停止做任何其他的事情。

為了保證使用者介面的及時響應,在能夠接收下一次輸入之前,我們只有很短的一個時間段進行準備。在 2018 年的 Chrome Dev 峰會(Chrome Dev Summit 2018)上,Shubhie Panicker 和 Jason Miller 發表了以保證響應性的追求(A Quest to Guarantee Responsiveness)為主題的演講。在演講中,他們對瀏覽器事件迴圈進行了視覺化描述,我們可以看到在繪製下一幀之前我們只有 16ms(在典型的 60Hz 螢幕上),緊接著瀏覽器就需要處理下一個事件:

[譯] React 中的排程

大多數 JavaScript 框架(包括當前版本的 React)將同步進行更新。我們可以將此行為視為一個函式 render(),而此函式只有在 DOM 更新後才會返回。在此期間,主執行緒被阻塞。

當前解決方案存在的問題

有了上面的資訊,我們可以擬定兩個必須解決的問題,以便獲得更具響應性的使用者介面:

  1. 長時間執行的任務會導致幀丟失。 我們需要確保所有任務都很小,可以在幾毫秒內完成,以便可以在一幀內執行。

  2. 不同的任務有不同的優先順序。 在上面的示例應用程式中,我們看到優先考慮使用者輸入可以帶來更好的整體體驗。為此,我們需要一種方法來定義優先順序的排序並按照排序進行任務排程。

併發的 React 和排程器

⚠️ 警告:下面的 API 尚不穩定並且會發生變化。我會盡可能地保持更新。

為了使用 React 實現排程得宜的使用者介面,我們必須看看以下兩個即將推出的 React 新功能:

  • 併發(Concurrent)React,也稱為時間分片(Time Slicing)。 在 React 16 改寫的新 Fiber 架構幫助下,React 現在可以允許渲染過程分段完成,中間可以返回2至主執行緒執行其他任務。

    我們將在之後聽到更多有關併發 React 的訊息。現在重要的是理解,當啟用這個模式之後,React 會把同步渲染的 React 元件切分成小塊,然後在多個幀上執行。

    ➡️ 使用這個模式,在將來我們就可以將需要長時間渲染的任務分成小任務塊。

  • 排程器。 它是由 React Core 團隊開發的通用協作主執行緒排程程式,可以在瀏覽器中註冊具有不用優先順序的回撥函式。

    目前,優先順序有這麼幾種:

    • Immediate 立即執行優先順序,需要同步執行的任務。
    • UserBlocking 使用者阻塞型優先順序(250 ms 後過期),需要作為使用者互動結果執行的任務(例如,按鈕點選)。
    • Normal 普通優先順序(5 s 後過期),不必讓使用者立即感受到的更新。
    • Low 低優先順序(10 s 後過期),可以推遲但最終仍然需要完成的任務(例如,分析通知)。
    • Idle 空閒優先順序(永不過期),不必執行的任務(例如,隱藏介面以外的內容)。

    每個優先順序都有對應的過期時間,這些過期時間是必須的,這樣才能確保即使在高優先順序任務多得可以連續執行的情況下,優先順序較低的任務仍能執行。在排程演算法中,這個問題被稱為飢餓(starvation)。過期時間可以保證每一個排程任務最終都可以被執行。例如,即使我們的應用中有正在執行的動畫,我們也不會錯過任何一個分析通知。

    在引擎中,排程器將所有已經註冊的回撥函式按照過期時間(回撥函式註冊的時間加上該優先順序的過期時間)排序然後儲存在列表中。接著,排程器將自己註冊在瀏覽器繪製下一幀之後的回撥函式裡。3在這個回撥函式中,瀏覽器將執行儘可能多的已註冊回撥函式,直到瀏覽開始繪製下一幀為止。

    ➡️ 通過這個特性,我們可以排程具有不同優先順序的任務。

方法中的排程

讓我們來看看如何使用這些特性讓應用程式更具響應性。為此,我們先來看看 ScheduleTron 3000,這是我自己構建的應用程式,它允許使用者在姓名列表中高亮搜尋詞。我們先來看一下它的初始實現:

// 應用程式包含一個搜尋框以及一個姓名列表。
// 列表的顯示內容由 searchValue 狀態變數控制。
// 該變數由搜尋框進行更新。
function App() {
  const [searchValue, setSearchValue] = React.useState();

  function handleChange(value) {
    setSearchValue(value);
  }

  return (
    <div>
      <SearchBox onChange={handleChange} />
      <NameList searchValue={searchValue} />
    </div>
  );
}

// 搜尋框渲染了一個原生的 HTML input 元素,
// 用 inputValue 變數對它進行控制。
// 當一個新的按鍵按下時,它會首先更新本地的 inputValue 變數,
// 然後它會更新 App 元件的 searchValue 變數,
// 接著模擬一個發向伺服器的分析通知。
function SearchBox(props) {
  const [inputValue, setInputValue] = React.useState();

  function handleChange(event) {
    const value = event.target.value;

    setInputValue(value);
    props.onChange(value);
    sendAnalyticsNotification(value);
  };

  return (
    <input
      type="text"
      value={inputValue}
      onChange={handleChange}
    />
  );
}

ReactDOM.render(<App />, container);
複製程式碼

ℹ️ 這個例子使用了 React Hooks。如果你對這個新特性沒有那麼熟悉的話,可以看看 CodeSandbox code。此外,你可能想知道為什麼在這個示例中我們使用了兩個不同的狀態變數。接下來我們一起來找找看原因。

試試看!在下面的搜尋框中輸入一個名字(例如,“Ada Stewart”),然後看看它是怎麼工作的:

CodeSandbox 中檢視

你可能注意到介面響應沒有那麼快。為了放大這個問題,我故意加長了列表的渲染時間。由於這個列表很大,它會應用程式的效能影響很大。

我們的使用者希望得到即時反饋,但是在按下按鍵後相當長的一段時間內,應用程式是沒有響應的。為了瞭解正在發生的事情,我們來看看開發者工具的 Performance 選項卡。這是當我在輸入框中輸入姓名“Ada”時錄製的螢幕截圖:

[譯] React 中的排程

我們可以看到有很多紅色的三角形,這通常不是什麼好訊號。對於每一次鍵入,我們都會看到一個 keypress 事件被觸發。所有的事件在一幀內被觸發,5導致幀的持續時間延長到 733 ms。這遠高於我們 16 ms 的平均幀預算。

在這個 keypress 事件中,會呼叫我們的 React 程式碼,更新 inputValue 以及 searchValue,然後傳送分析通知。反過來,更新後的狀態值會致使應用程式重新渲染每一個姓名項。任務相當繁重但是必須完成,如果使用原生的方法,它會阻塞主程式。

改進現在這個狀態的第一步是使用並不穩定的併發模式。實現方法是,使用 <React.unstable_ConcurrentMode> 元件把我們的 React 樹的一部分包裹起來,就像下面這樣4

- ReactDOM.render(<App />, container);
+ ReactDOM.render(
+  <React.unstable_ConcurrentMode>
+    <App />
+  </React.unstable_ConcurrentMode>,
+  rootElement
+ );
複製程式碼

但是,在這個例子中,僅僅使用併發模式並不會改變我們的體驗。React 仍然會同時收到兩個狀態值的更新,沒辦法知道哪一個更重要。

我們想要首先設定 inputValue,然後更新 searchValue 以及傳送分析通知,所以我們只需要在開始的時候更新輸入框。為此,我們使用了排程器暴露的 API(可以使用 npm i scheduler 進行安裝)對低優先順序的回撥函式進行排序:

import { unstable_next } from "scheduler";
function SearchBox(props) {
  const [inputValue, setInputValue] = React.useState();

  function handleChange(event) {
    const value = event.target.value;

    setInputValue(value);
    unstable_next(function() {      
      props.onChange(value);      
      sendAnalyticsNotification(value);    
    });  
  }
  
  return <input type="text" value={inputValue} onChange={handleChange} />;
}
複製程式碼

在我們使用的 API unstable_next() 中,所有的 React 更新都會被設定成 Normal 優先順序,這個優先順序低於 onChange 監聽器內部預設的優先順序。

事實上,通過這種改變,我們的輸入框響應速度已經快了不少,並且我們打字的時候不會再有幀被丟棄。讓我們再看看 Performance 選項卡:

[譯] React 中的排程

我們可以看到需要長時間執行的任務現在被分解成可以在單個幀內完成的較小任務。提示我們有幀丟棄的紅色三角也消失了。

但是,分析通知(在上面的截圖中高亮的部分)仍然不理想,它依舊在渲染的同時執行。因為我們的使用者不會看到這個任務,所以可以給它安排一個更低的優先順序。

import {
  unstable_LowPriority,
  unstable_runWithPriority,
  unstable_scheduleCallback
} from "scheduler";

function sendDeferredAnalyticsNotification(value) {
  unstable_runWithPriority(unstable_LowPriority, function() {
    unstable_scheduleCallback(function() {
      sendAnalyticsNotification(value);
    });
  });
}
複製程式碼

如果我們現在在搜尋框元件中使用 sendDeferredAnalyticsNotification(),然後再次檢視 Performance 選項卡,並拖動到末尾,我們可以看到在渲染工作完成後,分析通知才被髮送,程式中的所有任務都被完美地排程了:

[譯] React 中的排程

試試看:

CodeSandbox 中檢視

排程器的限制

使用排程器,我們可以控制回撥函式的執行順序。它內建於最新的 React 實現中,無需另行設定就能夠和併發模式協同使用。

這就是說,排程器有兩個限制:

  1. 資源搶奪。 排程器嘗試所有使用所有的可用資源。如果排程器的多個例項執行在同一個執行緒上並爭奪資源,就會導致問題。我們需要確保應用程式的所有部分使用的是同一個排程器例項。
  2. 通過瀏覽器工作平衡使用者定義的任務。 由於排程器在瀏覽器中執行,因此它只能訪問瀏覽器公開的API。文件生命週期(如渲染或垃圾回收)可能會以無法控制的方式干擾工作。

為了消除這些限制,Google Chrome 團隊正在與 React、Polymer、Ember、Google Maps 和 Web Standards Community 合作,在瀏覽器中建立 Scheduling API。是不是很讓人興奮!

總結

併發的 React 和排程器允許我們在應用程式中實現任務排程,這將使得我們可以建立響應迅速的使用者介面。

React 官方可能會在 2019 第二季度釋出這些功能。在此之前,大家可以使用這些不穩定的 API,但要密切關注它的變化。

如果您想成為第一個知道這些 API 何時更改或者編寫新功能文件的人,請訂閱 This Week in React ⚛️


1. MDN web docs 上有一篇關於這個問題很棒的文章

2. 這是一個超讚的詞,可以返回一個支援暫停之後繼續執行的方法。可以在 generator functions 上檢視相似的概念。

3.排程器的目前實現中,它通過在一個 requestAnimationFrame() 回撥函式中使用 postMessage() 實現。它會在幀渲染結束後立即被呼叫。

4. 這是另外一個可以實現併發模式的方法,使用新的 createRoot() API。

5. 在處理第一次的 keypress 事件時,瀏覽器會在它的佇列中檢視待處理事件,然後決定在渲染幀之前執行哪個事件監聽器。

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


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

相關文章