iT邦幫忙

2021 iThome 鐵人賽

DAY 29
0
Modern Web

用30天更加認識 React.js 這個好朋友系列 第 29

Day29-淺談 React 18 的優化和新的 API(Fiber、Suspense、useTransition、useDeferredValue)

  • 分享至 

  • xImage
  •  

這篇會介紹 React 18 的一些更新和優化。

專有名詞

因為接下來的介紹會涉及到一些專有名詞,所以在這邊先幫讀者複習一下。

renderer(渲染器)

讀者有沒有想過為什麼在開發 Web 專案時,為什麼安裝 React package 外還要再另外安裝 react-dom?

實際上 React 將渲染的工作拆分出來成為另一個 package,react-dom, react-dom/server, react-native, react-test-renderer, react-art 等都是,並被稱為 renderers,並且它們會共用一些相同的程式碼,像是後面會提到的 reconciler。

例如用 react-dom 渲染根節點(元件)是用這個語法:

import ReactDOM from 'react-dom';

ReactDOM.render(<App />, document.getElementById('root'));

而使用 react-native 渲染根節點(元件)是用這個語法:

const { AppRegistry } = require('react-native');

AppRegistry.registerComponent('app', () => MainComponent);

以上這樣的設計,讓我們可以在不同的環境上用 React 做開發,而 React 這包 package 本身負責的是抽象邏輯,提供的是 React 指定功能,例如定義元件、hooks、diffing。

舉 useState 的更新為例,React hooks 從 React package 引入,它會回傳一個 dispatcher 物件,調用 useState 時會因此轉發這個 dispatcher,React DOM 會根據這個 dispatcher 做對應的處理。

// React 原始碼簡化後的內容
const React = {
  // React 實際上沒有 __currentDispatcher api,只是用一個代稱去表示使用這些 hooks 時會出現 dispatch 去轉發
  __currentDispatcher: null,

  useState(initialState) {
    return React.__currentDispatcher.useState(initialState);
  },

  useEffect(initialState) {
    return React.__currentDispatcher.useEffect(initialState);
  },
  // ...
};

// React DOM 内部
const prevDispatcher = React.__currentDispatcher;
React.__currentDispatcher = ReactDOMDispatcher;
let result;
try {
  result = YourComponent(props);
} finally {
  React.__currentDispatcher = prevDispatcher;
}

React useState 部分原始碼:

import ReactCurrentOwner from './ReactCurrentOwner';

function resolveDispatcher() {
  const dispatcher = ReactCurrentOwner.currentDispatcher;
  invariant(
    dispatcher !== null,
    'Hooks can only be called inside the body of a function component.',
  );
  return dispatcher;
}

export function useState<S>(initialState: (() => S) | S) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

這種將 hooks 依附到 dispatcher 底下的設計我們叫做 dependency injection(依賴注入),用比較好理解的話來說,並不是從 React import 一個 hook 時就將該 hook 的整個原始碼引入,而是根據不同的 dispatcher 去將該 hook 的功能細節給引入到 React package。

從以下不同的 renderer 中,我們可以看到它們各自匯出了各自的 dispatcher,並實作了各自的 hook:
ReactPartialRendererHooks.js
ReactFiberDispatcher.js

Reconciliation 和 reconciler

在 React 中因為狀態的改變而要重新渲染時,決定哪些 DOM 元素要做更新到更新完成的過程,就是 Reconciliation。包含以下的步驟:

  1. React 內部儲存一份一開始的 Virtual DOM,並在網頁上渲染實際的 DOM
  2. 當網頁上某狀態有任何改變時,會產生新的一組 Virtual DOM
  3. 透過 Diffing 演算法,將新舊 Virtual DOM 比對後在記憶體內部算出要更新的 Virtual DOM
  4. 最後在網頁上的 Real DOM 只會更新需要更新的 DOM

reconciler 主要就是執行 Diffing 演算法,找出 React virtual dom 更新的部分。

從 Fiber 角度看更新:

Reconciler 使用 Diff 算法對比 current Fiber 和 React Element,生成 workInProgress Fiber,這個階段可中斷和排定任務優先權,然後透過 Renderer 把 workInProgress Fiber 轉換成真正的 DOM 節點。

Fiber 介紹

Fiber 的由來

一個功能的產生想必是為了解決某個問題,Fiber 也是如此,所以先瞭解一下在 Fiber 出現前的 React 有什麼問題?

在 React V15 中的 React,使用的是 Stack reconciler 去比對更新前後的 DOM Tree 哪裡不一樣。

不過這個時期的 Stack reconciler 有些缺點,無法中斷和切分渲染的任務,例如讓某些優先級較高的任務先處理完成,而且渲染大量 DOM Nodes 會佔據主線程 block,進而導致有些卡幀的情況。

舉個範例,以下的連結網頁呈現了用 Stack reconciler 和 Fiber reconciler 兩個方式頻繁改變 Sierpinski triangle 內容的差異,可以看到 Stack reconciler 的方式會造成明顯的卡頓,Fiber reconciler 則不會。

點我看範例 : Fiber vs Stack Demo

Fiber 便是為了解決上述的問題而生。

Fiber 功用

在 React V16 中,Fiber reconciler 取代了 Stack reconciler,這個東西可以根據整個要渲染的 DOM Tree 創造出多個 Fiber,也會和 React Element 一樣生成 tree(Fiber Tree),並會共享一些屬性。

由多個 Fiber 構成的渲染任務可以被中斷、重用、捨棄,並且不同的渲染任務可以被設定優先權做渲染,因此具有將渲染任务拆分為小任务並將任务分配到多個幀上的能力。

這裡的任務包括改變 state、DOM 發生變化

而單獨的一個 Fiber 是具有一些特定屬性的物件,關於 Fiber 相關的屬性可以參考以下 React 的原始碼 FiberNode,以下就它的屬性作個簡單的介紹:

  • tag: Fiber 和某種東西具有一對一的關係,而 Fiber 將其對應關係的類型儲存在 tag 屬性內,具有 24 種類型,不同類型的 Fiber 處理的事情也不同,可以從這個連結看到 Fiber 的所有類型。
  • child: 前面提到很多 Fiber 也能組成 Fiber Tree,而這個屬性就是 父 Fiber 對應其子 Fiber 的 reference,同階層的 childFiber 則像 Linked List 連接。

並且 Fiber 也能儲存一些元件的資料和狀態,例如 memorizedState 屬性儲存一個 linked list,裡面就儲存了 hooks 相關資料。

https://ithelp.ithome.com.tw/upload/images/20230610/20116883gJJ18t0Mv0.png
https://ithelp.ithome.com.tw/upload/images/20230610/20116883HveffBsFkm.png

Fiber 和渲染階段

Day23 的文章我有提到在 React 渲染時,會分成 Render Phase & Commit Phase,在這兩個階段時,Fiber 也會進行一些事情。

Render Phase

在此階段 Fiber reconciler 會建立 Fiber,產生 Fiber Tree,若有需要更新的 DOM,會再建立出一個新的 Fiber Tree,稱為 workInProgress Tree。

Commit Phase

在此階段,會為 Fiber 執行在它們上面記錄的 side effect,並且在此階段運行時不可以中斷。

當 workInProgress Tree 完成更新時,會變成新的 Fiber Tree,等下次更新時又會出現新的 orkInProgress Tree,如此循環下去,這樣的過程稱為 Double Buffering。

參考資料 & 推薦閱讀

以下的影片和文章都解說的蠻詳細的,若有興趣更加了解的讀者可以進一步閱讀。

React 运行时优化方案的演进

好文

What Is React Fiber?

React Fiber 淺談

fiber reconciler 漫谈

(譯)深入瞭解React Fiber的內部


useTransition

常和 Suspense 搭配使用,它可以延遲渲染指定的元件,讓優先度較低或是比較要耗時的元件稍後渲染。

語法

const [isPending, startTransition] = useTransition();

  • isPending 是用來判斷 transition 狀態是否完成的 boolean 值。
  • startTransition 用來標記要更新的 state 是否 transition。

使用

程式碼來自官網範例,在這段程式碼中,點擊按鈕時會去取得 profile 的資料,但因為將設定取回資料的 setResource 放在了 startTransition 裡面,所以會延遲更新 state,而在更新 state 前,會先出現 isPending 條件式的內容。

function App() {
  const [resource, setResource] = useState(initialResource);
  const [isPending, startTransition] = useTransition({ timeoutMs: 2000 });
  return (
    <>
      <button
        disabled={isPending}
        onClick={() => {
          startTransition(() => {
            const nextUserId = getNextId(resource.userId);
            setResource(fetchProfileData(nextUserId));
          });
        }}
      >
        Next
      </button>
      {isPending ? " Loading..." : null}
      <Suspense fallback={<Spinner />}>
        <ProfilePage resource={resource} />
      </Suspense>
    </>
  );
}

推薦閱讀

React 18新特性优先看之初探useTransition()


useDeferredValue

這個 hook 主要是用來在下一次 UI 更新完成前,先維持住之前的 UI 內容,舉個例子,當我們用 google 搜尋某關鍵字後,會出現很多的資料內容,那如果再更新關鍵字內容,底下呈現的資料內容也會跟著做改變。

但頻繁的更新搜尋文字導致底下的資料內容不斷變更有可能不是我們想要的,說不定只是要打完所有關鍵字後呈現的資料結果,此時就可以使用這個 hook 去延遲更新 UI。

2022/11/24 更新(感謝邦友 @kmsheng 的回報)-useDeferredValue 的新版語法

const deferredValue = useDeferredValue(value);,第一個參數是設定要延遲的 value 值

使用範例

程式碼來自官網範例,範例輸入框中,我們先輸入 a,會出現 a 開頭的查詢列表,再輸入 b,此時輸入框內容變成 ab,列表文字會暫時維持先前 a 開頭的查詢列表,然後才輸出 ab 開頭的查詢列表。

import { Suspense, useState, useDeferredValue } from 'react';
import SearchResults from './SearchResults.js';

export default function App() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;
  return (
    <>
      <label>
        Search albums:
        <input value={query} onChange={e => setQuery(e.target.value)} />
      </label>
      <Suspense fallback={<h2>Loading...</h2>}>
        <div style={{ opacity: isStale ? 0.5 : 1 }}>
          <SearchResults query={deferredQuery} />
        </div>
      </Suspense>
    </>
  );
}

這裡再舉個範例,我們將頻繁輸入變更的 text 透過 useDeferredValue 處理後,這樣 SlowList 接受到的 props text 屬性就也不會頻繁變更,減少了渲染的次數。

// 記得要使用 memo!
const SlowList = memo(function SlowList({ text }) {
  // ...
});

function App() {
  const [text, setText] = useState('');
  const deferredText = useDeferredValue(text);
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <SlowList text={deferredText} />
    </>
  );
}

使用 useDeferredValue 注意事項

  1. useDeferredValue 的參數應該要是原始型別或是在渲染元件以外的物件
  2. useDeferredValue 沒有固定的延遲時間

Suspense

Suspense 在之前的 Day24 時就有介紹過,不過在 Concurrent Mode 出現之前,就有這個元件了,但只有動態載入的功能,而在 Concurrent Mode 中,增加了可以加入 Loading 狀態的元件,資料持續載入的期間就先暫時渲染 Loading 元件。

使用範例


終於只剩最後一天的鐵人賽!明天會整理一些不錯的 youtube react 學習資源清單給讀者做更多的延伸學習和參考~


上一篇
Day28-介紹 Redux DevTools
下一篇
Day30-還想學更多嗎?推薦 Youtube 上面免費的 React 學習資源
系列文
用30天更加認識 React.js 這個好朋友33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
kmsheng
iT邦新手 5 級 ‧ 2022-11-22 18:08:22

useDeferredValue 第二個參數好像被拔掉了,我是用版本 18.2.0
https://reactjs.org/docs/hooks-reference.html#usedeferredvalue

harry xie iT邦研究生 1 級 ‧ 2022-11-24 10:31:40 檢舉

是的,感謝你的回報,我再更新一下文章:)

我要留言

立即登入留言