详解React的Transition工作原理原理
Transition 使用姿勢
Transition 是 react18 引入的新概念,用來區分緊急和非緊急的更新。
- 緊急的更新,指的是一些直接的用戶交互,如輸入、點擊等;
- 非緊急的更新,指的是 UI 界面從一個樣子過渡到另一個樣子;
react 官方的 demo 如下:
import {startTransition} from 'react';// Urgent: Show what was typed setInputValue(input);// Mark any state updates inside as transitions startTransition(() => {// Transition: Show the resultssetSearchQuery(input); });有 2 個 API:
- useTransition:hook,用在 function 組件或其他 hooks 中,能返回 isPending;
- startTransition:用在不能使用 hooks 的場景,如 class 組件中,相比 useTransition 不能獲取 isPending 狀態;
2 個 API 還有一個差別:當進行連續快速輸入時,使用 ?startTransition? 是無法觸發類似 throttle 的效果的。
Transition VS throttle、debounce
存在的問題:
- 到達指定時間后,更新開始處理,渲染引擎會被長時間阻塞,頁面交互會出現卡頓;
- throttle 的最佳時間不易掌握,是由開發者設置的時間。而這個預設的時間,在不同性能的設備上不一定能帶來最佳的體驗;
存在的問題:
- 會出現用戶輸入長時間得不到響應的情況,如上例中雖然輸入框中內容一直在變但下面區域內一直不變;
- 更新操作正式開始以后,渲染引擎仍然會被長時間阻塞,依舊會存在頁面卡死的情況;
用 transition 機制的效果:
- 用戶可以及時看到輸入內容,交互也較流暢;
- 用戶連續輸入時,不會一直得不到響應(最遲 5s 必會開始更新渲染列表);
- 開始更新渲染后,協調過程是可中斷的,不會長時間阻塞渲染引擎(進入瀏覽器渲染階段依然會卡住);
transition 相比前兩種方案的優勢:
- 更新協調過程是可中斷的,渲染引擎不會長時間被阻塞,用戶可以及時得到響應;
- 不需要開發人員去做額外的考慮,整個優化過程交給 react 和瀏覽器即可;
transition 實現原理
isPending 實現原理
我們看到頁面首先進入了 pending 狀態,然后才顯示為 transition 更新后的結果。這里發生了 2 次 react 更新。但我們只寫了一個 setState。
function App() {const [value, setValue] = useState("");const [isPending, startTransition] = useTransition();const handleChange = (e) => {const newVal = e.target.value;startTransition(() => setValue(newVal));};return (<div><input onChange={handleChange} /><div className={isPending ? 'loading' : ''}>{Array(50000).fill("a").map((item, index) => {return <div key={index}>{value}</div>;})}</div></div>); }我們看一下 useTransition 源碼:
參考React實戰視頻講解:進入學習
useTransition(): [boolean, (() => void) => void] {currentHookNameInDev = 'useTransition';mountHookTypesDev();return mountTransition(); },function mountTransition(): [boolean, (callback: () => void, options?: StartTransitionOptions) => void] {const [isPending, setPending] = mountState(false);// The `start` method never changes.const start = startTransition.bind(null, setPending);const hook = mountWorkInProgressHook();hook.memoizedState = start;return [isPending, start]; }function startTransition(setPending, callback, options) {const previousPriority = getCurrentUpdatePriority();setCurrentUpdatePriority(higherEventPriority(previousPriority, ContinuousEventPriority),);setPending(true);const prevTransition = ReactCurrentBatchConfig.transition;ReactCurrentBatchConfig.transition = {};...try {setPending(false);callback();} finally {setCurrentUpdatePriority(previousPriority);ReactCurrentBatchConfig.transition = prevTransition;...} }當調用 startTransition 時,會先通過 setPending 將 isPending 改為 true,然后再通過 setPending 將 isPending 改為 false,并在 callback 中觸發我們自己定義的更新。
這里有一個奇怪的地方,3 次 setState 并沒有合并在一起,而是觸發了 2 次 react 更新,setPending(true) 為 1 次,setPending(false) 和 callback() 為第二次。
這是因為
這句語句將更新的上下文變更為了 transition。使得 setPending(true) 和 后面的 2 次更新的上下文不同了。
為什么更新的上下文變化會影響 setState 的合并呢,下面簡單展開講一講 setState 時 react 在干什么。
WorkLoop
一次 react 更新,主核心的過程是 fiber tree 的協調(reconcile),協調的作用是找到 fiber tree 中發生變化的 fiber node,最小程度地對頁面的 dom tree 結構進行調整。
在進行協調時,react 提供了兩種模式:Legacy mode - 同步阻塞模式和 Concurrent mode - 并行模式。
這兩種模式,區別在于 fiber tree 的協調過程是否可中斷。 Legacy mode,協調過程不可中斷;Concurrent mode,協調過程可中斷。
Legacy mode:
Concurrent mode:
Concurrent mode 的意義在于:
- 協調不會長時間阻塞瀏覽器渲染;
- 高優先級更新可以中斷低優先級更新,優先渲染;
react 的調度機制是 workLoop 機制。偽代碼實現如下:
let taskQueue = []; // 任務列表 let shouldTimeEnd = 5ms; // 一個時間片定義為 5ms let channel = new MessageChannel(); // 創建一個 MessageChannel 實例function workLoop() {let beginTime = performance.now(); // 記錄開始時間while(true) { // 循環處理 taskQueue 中的任務let currentTime = performance.now(); // 記錄下一個任務開始時的時間if (currentTime - beginTime >= shouldTimeEnd) break; // 時間片已經到期,結束任務處理processTask(); // 時間片沒有到期,繼續處理任務}if (taskQueue.length) { // 時間片到期,通過調用 postMessage,請求下一個時間片channel.port2.postMessage(null);} }channel.port1.onmessage = workLoop; // 在下一個時間片內繼續處理任務 workLoop();workLoop 有 2 種,Legacy 模式下,是 workLoopSync;Concurrent 模式下,是 workLoopConcurrent。workLoopSync 中每個任務都要完成后才會釋放主進程,workLoopConcurrent 中每個任務在時間片耗盡后會釋放主進程等待下一個時間片繼續執行任務。
workLoopSync 對應 Legacy 模式。如果是在 event、setTimeout、network request 的 callback 中觸發更新,那么協調時會啟動 workLoopSync。在協調過程中,需要對 fiber tree 做深度優先遍歷,處理每一個 fiber node。workLoopSync 開始工作以后,要等到 stack 中收集的所有 fiber node 都處理完畢以后,才會結束工作,也就是 fiber tree 的協調過程不可中斷。
workLoopConcurrent 對應 Concurrent 模式。如果更新與 Suspense、useTransition、OffScreen 相關,那么協調時會啟動 workLoopConcurrent。 workLoopConcurrent 開始工作以后,每次協調 fiber node 時,都會判斷當前時間片是否到期。如果時間片到期,會停止當前 workLoopConcurrent、workLoop,讓出主線程,然后請求下一個時間片繼續協調。
相關源碼如下:
function workLoopSync() {// Already timed out, so perform work without checking if we need to yield.while (workInProgress !== null) {performUnitOfWork(workInProgress);} }function workLoopConcurrent() {// Perform work until Scheduler asks us to yieldwhile (workInProgress !== null && !shouldYield()) {performUnitOfWork(workInProgress);} }任務優先級
react 有 3 套優先級機制:
- React 事件優先級
- Scheduler 優先級
- Lane 優先級
React 事件優先級如下:
// 離散事件優先級,例如:點擊事件,input輸入等觸發的更新任務,優先級最高 export const DiscreteEventPriority: EventPriority = SyncLane; // 連續事件優先級,例如:滾動事件,拖動事件等,連續觸發的事件 export const ContinuousEventPriority: EventPriority = InputContinuousLane; // 默認事件優先級,例如:setTimeout觸發的更新任務 export const DefaultEventPriority: EventPriority = DefaultLane; // 閑置事件優先級,優先級最低 export const IdleEventPriority: EventPriority = IdleLane;react 在內部定義了 5 種類型的調度(Scheduler)優先級:
- ImmediatePriority, 直接優先級,對應用戶的 click、input、focus 等操作;
- UserBlockingPriority,用戶阻塞優先級,對應用戶的 mouseMove、scroll 等操作;
- NormalPriority,普通優先級,對應網絡請求、useTransition 等操作;
- LowPriority,低優先級(未找到應用場景);
- IdlePriority,空閑優先級,如 OffScreen;
5 種優先級的順序為: ImmediatePriority > UserBlockingPriority > NormalPriority > LowPriority > IdlePriority。
react 內部定義了 31 條 lane,lane 可以理解為每個任務所處的賽道。用二進制表示,按優先級從低到高依次為:
lane 對應的位數越小,優先級最高。如 SyncLane 為 1,優先級最高; OffscreenLane 為 31, 優先級最低。
react 先將 lane 的優先級轉換為 React 事件的優先級,然后再根據 React 事件的優先級轉換為 Scheduler 的優先級。
當通過 startTransition 的方式觸發更新時,更新對應的優先級等級為 NormalPriority。而在 NormalPriority 之上,還存在 ImmediatePriority 、UserBlockingPriority 這兩種級別更高的更新。通常,高優先級的更新會優先級處理,這就使得盡管 transition 更新先觸發,但并不會在第一時間處理,而是處于 pending - 等待狀態。只有沒有比 transition 更新優先級更高的更新存在時,它才會被處理。
Concurrent 模式下,如果在低優先級更新的協調過程中,有高優先級更新進來,那么高優先級更新會中斷低優先級更新的協調過程。
每次拿到新的時間片以后,workLoopConcurrent 都會判斷本次協調對應的優先級和上一次時間片到期中斷的協調的優先級是否一樣。如果一樣,說明沒有更高優先級的更新產生,可以繼續上次未完成的協調;如果不一樣,說明有更高優先級的更新進來,此時要清空之前已開始的協調過程,從根節點開始重新協調。等高優先級更新處理完成以后,再次從根節點開始處理低優先級更新。
setState 機制
調用 setState,并不會立即更新組件 state。state 的更新,其實是發生在 fiber tree 的協調過程中,這個過程如下:
上面 useTransition 的例子中,連續 3 次 setState,會生成 3 個 update 對象 - update1(setPending(true)),update2(setPending(false)),update3(callback 里的 setState 調用)。這三個 update 對象會按照創建的先后順序依次添加到 updateQueue 中。
update 對象結構:
export function createUpdate(eventTime: number, lane: Lane): Update<*> {const update: Update<*> = {eventTime,lane, // 這里為 update 綁定了優先級tag: UpdateState,payload: null,callback: null,next: null,};return update; }由于創建 update 對象的上下文不相同,導致 update 對象處理的時機不相同。第一次協調時,處理 update1;第二次協調時,處理 update2 和 update3。之所以這樣,是因為不同的上下文,為 update 對象綁定了的不同的 lane。
lane 決定了 update 對象的處理時機。
所以如上,update1 被分配的 lane 為 InputContinuousLane,而 update2、update3 被分配的 lane 為 TransitionLane。為每個 update 生成 lane 的源碼如下:
export function requestUpdateLane(fiber: Fiber): Lane {...const isTransition = requestCurrentTransition() !== NoTransition;if (isTransition) {if (currentEventTransitionLane === NoLane) {// All transitions within the same event are assigned the same lane.currentEventTransitionLane = claimNextTransitionLane();}return currentEventTransitionLane;}... }export function requestCurrentTransition(): Transition | null {return ReactCurrentBatchConfig.transition; }至此,已經可以看到,update2 和 update3 被分配了較低的優先級,因此 3 次 setState 被分開成了 2 次更新。
了解了上面的原理,就可以來回答這幾個問題了:
useTransition 為何能表現出 debounce 效果
高優先級更新會中斷低優先級更新,優先處理。
startTransition 方法執行過程中, setPending(true) 觸發的更新優先級較高,而 setPending(false)、callback 觸發的更新優先級較低。當 callback 觸發的更新進入協調階段以后,由于協調過程可中斷,并且用戶一直在輸入導致一直觸發 setPending(true),使得 callback 觸發的更新一直被中斷,直到用戶停止輸入以后才能被完整處理。
useTransition 為何能表現出 throttle 效果
如果你一直輸入,最多 5s,長列表必然會渲染,和 防抖 - throttle 效果一樣。
這是因為為了防止低優先級更新一直被高優先級更新中斷而得不到處理,react 為每種類型的更新定義了最遲必須處理時間 - timeout。如果在 timeout 時間內更新未被處理,那么更新的優先級就會被提升到最高 - ImmediatePriority,優先處理。
transition 更新的優先級為 NormalPriority,timeout 為 5000ms 即 5s。如果超過 5s, transition 更新還因為一直被高優先級更新中斷而沒有處理,它的優先級就會被提升為 ImmediatePriority,優先處理。這樣就實現了 throttle 的效果。
useTransition 和 startTransition 區別
用戶連續輸入時,使用 useTransition 會出現 debounce 的效果,而直接使用 startTransition 則不會。
因為 startTransition 的源碼:
function startTransition(scope) {var prevTransition = ReactCurrentBatchConfig.transition;ReactCurrentBatchConfig.transition = 1; // 修改更新上下文try {scope(); // 觸發更新} finally {...} }對比 useTransition 的 startTransition, 我們會發現 startTransition 中少了 setPending(true) 的過程。
使用 useTransition 時,transition 更新會一直被連續的 setPending(true) 中斷,每次中斷時都會被重置為未開始狀態,導致 transition 更新只有在用戶停止輸入(或者超過 5s)時才能得到有效處理,也就出現了類似 debounce 的效果。
而直接使用 startTransition 時, 盡管協調過程會每隔 5ms 中斷一次,但由于沒有 setPending(true) 的中斷, 連續的輸入并不會重置 transition 更新。當 transition 更新結束協調時,自然而然地就會開始瀏覽器渲染過程,不會出現類似 debounce 的效果。
Transition API 由來
React 如何優化性能
React ,它本身的思路是純 JS 寫法,這種方式非常靈活,但是,這也使它在編譯時很難做太多的事情,像上面這樣的編譯時優化是很難實現的。所以,我們可以看到 React 幾個大版本的的優化主要都在運行時。
進行運行時優化,關注的主要問題就是 CPU 和 IO。
- 首先,就是 CPU 的問題,主流瀏覽器的刷新頻率一般是 60Hz,也就是每秒刷新 60 次,大概 16.6ms 瀏覽器刷新一次。由于 GUI 渲染線程和 JS 線程是互斥的,所以 JS 腳本執行和瀏覽器布局、繪制不能同時執行。在這 16.6ms 的時間里,瀏覽器既需要完成 JS 的執行,也需要完成樣式的重排和重繪,如果 JS 執行的時間過長,超出了 16.6ms,這次刷新就沒有時間執行樣式布局和樣式繪制了,于是在頁面上就會表現為卡頓。
- IO 的問題就比較好理解了,很多組件需要等待一些網絡延遲,那么怎么樣才能在網絡延遲存在的情況下,減少用戶對網絡延遲的感知呢?就是 react 需要解決的問題。
React 引入 fiber 機制,可中斷協調階段,就是在 CPU 角度優化運行時性能。而 Suspense API 則是 IO 角度的優化,讓新內容替換成舊內容的過程不閃屏,內容切換更流暢。
Transition API 登場
Suspense 的作用,主要是 react 優化切換內容效果。而 Transition API 的最初提出,是為了配合 Suspense API 進行 IO 角度的優化。
useTransition 的前身是 withSuspenseConfig。Sebmarkbage 在 19 年五月份提的一個 PR 中引進了它。在 19 年 11 月更名為 useTransition。
Transition Hook 的作用是告訴 React,延遲更新 State 也沒關系。
初版的 useTransition 的實現源碼如下:
function updateTransition(config: SuspenseConfig | void | null, ): [(() => void) => void, boolean] {const [isPending, setPending] = updateState(false); // 相當于useStateconst startTransition = updateCallback( // 相當于useCallbackcallback => {setPending(true); // 設置 pending 為 true// 以低優先級調度執行Scheduler.unstable_next(() => {// ?? 設置suspenseConfigconst previousConfig = ReactCurrentBatchConfig.suspense;ReactCurrentBatchConfig.suspense = config === undefined ? null : config;try {// 還原 pendingsetPending(false);// 執行你的回調callback();} finally {// ?? 還原suspenseConfigReactCurrentBatchConfig.suspense = previousConfig;}});},[config, isPending],);return [startTransition, isPending]; }劃重點,雖然跟現在的版本有一些差別,但主要的思想依然是:以較低的優先級運行后 2 次 setState。
一路以來,主要的修改包含:在做兼容數據流狀態庫如 redux,修改優先級的實現方案。
總結
以上是生活随笔為你收集整理的详解React的Transition工作原理原理的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: C# 数组截取
- 下一篇: 快速验证矩阵求MP广义逆及最小范数解或最