這篇會介紹 React 18 的一些更新和優化。
因為接下來的介紹會涉及到一些專有名詞,所以在這邊先幫讀者複習一下。
讀者有沒有想過為什麼在開發 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 本身負責的是抽象邏輯,提供的是例如 JSX、元件、hooks、diffing 等核心功能,不關注如何將 UI 渲染到瀏覽器(或其他環境),而是專注於描述 UI 結構。
舉 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;
}
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
在 React 中因為狀態的改變而要重新渲染時,決定哪些 DOM 元素要做更新到更新完成的過程,就是 Reconciliation。React re-render 包含以下的步驟,Reconciliation 出現在第 3 步驟:
reconciler 主要就是執行 Diffing 演算法,找出 React virtual dom 更新的部分。
一個功能的產生想必是為了解決某個問題,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 便是為了解決上述的問題而生。
在 React V16 中,Fiber reconciler 取代了 Stack reconciler,這個東西可以根據整個要渲染的 DOM Tree 創造出多個 Fiber,也會和 React Element 一樣生成 tree(Fiber Tree),並會共享一些屬性。
由多個 Fiber 構成的渲染任務可以被中斷、重用、捨棄,並且不同的渲染任務可以被設定優先權做渲染,因此具有將渲染任务拆分為小任务並將任务分配到多個幀上的能力。。
這裡的任務包括改變 state、DOM 發生變化
而單獨的一個 Fiber 是具有一些特定屬性的物件,關於 Fiber 相關的屬性可以參考以下 React 的原始碼 FiberNode,以下就它的屬性作個簡單的介紹:
並且 Fiber 也能儲存一些元件的資料和狀態,例如 memorizedState 屬性儲存一個 linked list,裡面就儲存了 hooks 相關資料。
Day23 的文章我有提到在 React 渲染時,會分成 Render Phase & Commit Phase,在這兩個階段時,Fiber 也會進行一些事情。
在此階段 Fiber reconciler 會建立 Fiber,產生 Fiber Tree。
Reconciler 使用 Diff 算法對比當前 Fiber Tree 和 React Element,若有找到需要更新的 DOM,會再建立出一個新的 Fiber Tree,稱為 workInProgress Tree,這個階段可中斷和排定任務優先權。
在此階段,會透過 Renderer 把 workInProgress Fiber 轉換成真正的 DOM 節點。
以下的影片和文章都解說的蠻詳細的,若有興趣更加了解的讀者可以進一步閱讀。
好文
常和 Suspense 搭配使用,它可以延遲渲染指定的元件,讓優先度較低或是比較要耗時的元件稍後渲染。
const [isPending, startTransition] = useTransition();
程式碼來自官網範例,在這段程式碼中,點擊按鈕時會去取得 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()
這個 hook 主要是用來在下一次 UI 更新完成前,先維持住之前的 UI 內容,舉個例子,當我們用 google 搜尋某關鍵字後,會出現很多的資料內容,那如果再更新關鍵字內容,底下呈現的資料內容也會跟著做改變。
但頻繁的更新搜尋文字導致底下的資料內容不斷變更有可能不是我們想要的,說不定只是要打完所有關鍵字後呈現的資料結果,此時就可以使用這個 hook 去延遲更新 UI。
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} />
</>
);
}
Suspense 在之前的 Day24 時就有介紹過,不過在 Concurrent Mode 出現之前,就有這個元件了,但只有動態載入的功能,而在 Concurrent Mode 中,增加了可以加入 Loading 狀態的元件,資料持續載入的期間就先暫時渲染 Loading 元件。
終於只剩最後一天的鐵人賽!明天會整理一些不錯的 youtube react 學習資源清單給讀者做更多的延伸學習和參考~
useDeferredValue 第二個參數好像被拔掉了,我是用版本 18.2.0
https://reactjs.org/docs/hooks-reference.html#usedeferredvalue
是的,感謝你的回報,我再更新一下文章:)