關鍵詞:react react-scheduler scheduler 時間切片 任務調度 workLoop
背景
本文所有關於 React 源碼的討論,基於 React v17.0.2 版本。
文章背景
工作中一直有在用 React 相關的技術棧,但卻一直沒有花時間好好思考一下其底層的運行邏輯,碰巧身邊的小伙伴們也有類似的打算,所以決定組團卷一波,對 React 本身探個究竟。
本文是基於眾多的源碼分析文章,加入自己的理解,然后輸出的一篇知識梳理。如果你也感興趣,建議多看看參考資料中的諸多引用文章,相信你也會有不一樣的收獲。
本文不會詳細說明 React 中 react-reconciler 、 react-dom 、fiber 、dom diff、lane 等知識,僅針對 scheduler 這一細節進行剖析。
知識點背景
在我嘗試理解 React 中 Scheduler 模塊的過程中,發現有很多概念理解起來比較繞,也是在不斷問自己為什么的過程中,發現如果自頂向下的先有一些基本的認知,再深入理解 Scheduler 在 React 中所做的事情,就變得容易很多。
瀏覽器的 EventLoop 簡單說明
此處默認你已經知道了 EventLoop 及瀏覽器渲染的相關知識
一個 frame 渲染(幀渲染)的過程,按 60fps來計算,大概有16.6ms,在這個過程中瀏覽器要做很多東西,包括 “執行 JS -> 空閑 -> 繪制(16ms)”,在執行 JS 的過程中,即是瀏覽器的 JS 線程執行 eventloop 的過程,里面包括了 marco task 和 mirco task 的執行,其中執行多少個 macro task 的數量是由瀏覽器決定的,而這個數量並沒有明確的限制。
因為 whatwg 規范標准中只是建議瀏覽器盡可能保證 60fps 的渲染體驗,因此,不同的瀏覽器的實現也並沒有明確說明。同時需要注意,並不是每一幀都會執行繪制操作。如果某一個 macro task 及其后執行 mirco task 時間太長,都會延后瀏覽器的繪制操作,也就是我們常見的掉幀、卡頓。
React 的 Scheduler 的簡單說明
React 為了解決 15 版本存在的問題:組件的更新是遞歸執行,所以更新一旦開始,中途就無法中斷。當層級很深時,遞歸更新時間超過了16ms,用戶交互就會卡頓。
React 引入了 Fiber 的架構,同時配合 Schedduler 的任務調度器,在 Concurrent 模式下可以將 React 的組件更新任務變成可中斷、恢復的執行,就減少了組件更新所造成的頁面卡頓。
目錄
- 常見問題
- Scheduler 是什么,作用是什么
- 實際生產中我們的 React 庫有用到 Scheduler 調度嗎
- 為什么用 MessageChannel ,而不用 setTimeout ?
- 為什么不用 Generator、Webworkers 來做任務調度
- 核心邏輯解析
- 概念說明
- 核心流程圖
- 如何實現的任務切片
- 如何實現任務的中斷
- 如何實現任務的恢復
- 個人的一點理解
- Demo 示例
- 利用 Scheduler 任務調度的示例
- 不用 Scheduler 任務調度的示例
- 設置切片時間為 0ms 時 的情景
- 實現一個 Scheduler 核心邏輯——判斷單個任務的完成狀態
- 拓展
- Scheduler 的開源計划
- Scheduler 為瀏覽器提供規范
- React 18 的離屏渲染
- Vue 和 React 的兩種方案的選擇
常見問題
Scheduler 是什么,作用是什么
Scheduler是一個獨立的包,不僅僅在React中可以使用。
Scheduler 是一個任務調度器,它會根據任務的優先級對任務進行調用執行。 在有多個任務的情況下,它會先執行優先級高的任務。如果一個任務執行的時間過長,Scheduler 會中斷當前任務,讓出線程的執行權,避免造成用戶操作時界面的卡頓。在下一次恢復未完成的任務的執行。
Scheduler 是 React 團隊開發的一個用於事務調度的包,內置於 React 項目中。其團隊的願景是孵化完成后,使這個包獨立於 React,成為一個能有更廣泛使用的工具。
實際生產中我們的 React 庫有用到 Scheduler 調度嗎
這個問題,其實是我個人想說明的一個點
因為在我看的很多文章中,大家都在不斷強調 Scheduler 的各種好處,各種原理,以至於我最開始也以為只要引入了 React 16-17 的版本,就能體會到這樣的“優化”效果。但是當我開啟源碼調試時,就產生了困惑,因為完全沒有按照套路來輸出我辛辛苦苦打的 console.log 。
直到我使用 Concurrent 模式才體會到 Scheduler 的任務調度核心邏輯。這個模式直到 React 17 都沒有暴露穩定的 API,只是提供了一個非穩定版的 unstable_createRoot 方法。
結論:Scheduler 的邏輯有被 React 使用,但是其核心的切片、任務中斷、任務恢復並沒有在穩定版中采用,你可以理解現在的 React 在執行 Fiber 任務時,還是一擼到底。
為什么用 MessageChannel ,而不首選 setTimeout
如果當前環境不支持 MessageChannel 時,會默認使用 setTimeout
- MessageChannel 的作用
- 生成瀏覽器 Eventloops 中的一個宏任務,實現將主線程還給瀏覽器,以便瀏覽器更新頁面
- 瀏覽器更新頁面后能夠繼續執行未完成的 Scheduler 中的任務
- tips:不用微任務迭代原因是,微任務將在頁面更新前全部執行完,達不到將主線程還給瀏覽器的目的
- 選擇 MessageChannel 的原因是因為 setTimeout(fn,0) 所創建的宏任務,會有至少 4ms 的執行時差,setInterval 同理
- 代碼示例:MessageChannel 總會在 setTimeout 任務之前執行,且執行消耗的時間總會小於 setTimeout
// setTimeout 的執行示例 var date1 = Date.now() console.log('setTimeout 執行的時間戳1:',date1) setTimeout(()=>{ var date2 = Date.now() console.log('setTimeout 執行的時間戳2:',date2) console.log('setTimeout 時差:',date2 - date1) },0) // messageChannel 的執行示例 var channel = new MessageChannel() var port1 = channel.port1; var port2 = channel.port2; port1.onmessage = ()=>{ var cTime2 = Date.now() console.log('messageChannel 執行的時間戳2:',cTime2) console.log('messageChannel 時差:', cTime2-cTime1) } var cTime1 = Date.now() console.log('messageChannel 執行的時間戳1:',cTime1) port2.postMessage(null) 復制代碼
React v16.10.0 之后完全使用 postMessage:github.com/facebook/re…
- 不選擇 requestIdelCallback 的原因
從 React 的 issues 及之前版本(在 15.6 的源碼中能搜到)中可以看到,requestIdelCallback 方法也被 React 嘗試過,只是后來因為兼容性、不同機器及瀏覽器執行效率的問題又被 requestAnimationFrame + setTimeout 的 polyfill 方法替代了
- 不選擇 requestAnimationFrame 的原因
在 React 16.10.0 之前還是使用的 requestAnimationFrame + setTimeout 的方法,配合動態幀計算的邏輯來處理任務,后來也因為這樣的效果並不理想,所以 React 團隊才決定徹底放棄此方法
requestAnimationFrame 還有個特點,就是當頁面處理未激活的狀態下,requestAnimationFrame 會停止執行;當頁面后面再轉為激活時,requestAnimationFrame 又會接着上次的地方繼續執行。
為什么不用 Generator、Webworkers 來做任務調度
針對 Generator ,其實 React 團隊為此做過一些努力
- Generator 不能在棧中間讓出。比如你想在嵌套的函數調用中間讓出, 首先你需要將這些函數都包裝成 Generator,另外這種棧中間的讓出處理起來也比較麻煩,難以理解。除了語法開銷,現有的生成器實現開銷比較大,所以不如不用。
- Generator 是有狀態的, 很難在中間恢復這些狀態。
針對 Webworkers , React 團隊同樣做過一些分析和討論
關於在 React 中引入 Webworkers 的討論,我這里僅貼一下在 issues 中看到的部分,因為沒有深入去研究來龍去脈,暫不做翻譯,感興趣的同學可以去訪問相關內容:github.com/facebook/re…
- How do you start a worker?
For now I can see the following solutions for this problem:
- separate file that includes only what is necessary for the worker, which would require extra build steps
- create a worker on the fly (blob), which will not work in every browser and I expect would have performance penalties. Also resolving dependencies here for the worker is going to be painful - if not impossible without extra build steps.
- start the entire build in multiple workers, still this would still require the usage of a build tool
So yeah, for now I don't see this working without a build tool. My preference would go to the first one.
- How do you determine the root to render into?
I would expect the "main" React to always start in the main thread, and components leaving stubs in this thread to which they can write when they want to. Of course writing to the DOM still needs to be done via the normal React reconciliation mechanism.
It should be possible to have a single worker which is used for multiple components, which makes it a bit more challenging. Probably an extra id needs to be given to communicate to the right component.
- How do we unit test the system?
If you would be testing a render function, it would initially only show the webworker stubs - and testing the result of a webworker would be something different. Something like a callback for a webworker result could work here (waitFor(webworkerId) comes to mind).
If there are other options here or I'm missing something, I would definitely like to hear it!
核心邏輯解析
概念說明
為了方便后續的理解,先對源碼中常見的概念或代碼塊做一個解讀
- Concurrent 模式:
- 將渲染工作分解為多個部分,對任務進行暫停和恢復操作以避免阻塞瀏覽器。這意味着 React 可以在提交之前多次調用渲染階段生命周期的方法,或者在不提交的情況下調用它們(默認情況下未啟用)
- 整個 Scheduler 的任務調度、時間切片、任務中斷及恢復都是依賴於 Concurent 模式及 Fiber 數據結構。
- Scheduler task
- task 對象
// 一個 scheduler 的任務 var newTask = { id: taskIdCounter++, // 任務id,在 react 中是一個全局變量,每次新增 task 會自增+1 callback: callback, // 在調度過程中被執行的回調函數 priorityLevel: priorityLevel, // 通過 Scheduler 和 React Lanes 優先級融合過的任務優先級 startTime: startTime, // 任務開始時間 expirationTime: expirationTime, // 任務過期時間 sortIndex: -1 // 排序索引, 全等於過期時間. 保證過期時間越小, 越緊急的任務排在最前面 }; 復制代碼
- task 執行的本質
- 執行邏輯在 scheduler 包中的 workLoop 方法中,代碼如下:
function workLoop(hasTimeRemaining, initialTime) { // ... 其他邏輯 while (currentTask !== null && !(enableSchedulerDebugging )) { // ... 其他邏輯 if (typeof callback === 'function') { // ... 其他邏輯 // 此處即執行 callback var continuationCallback = callback(didUserCallbackTimeout); // ... 其他邏輯 } } // ... 其他邏輯 } 復制代碼
- task 執行的方法實質
newTask
中的callback
是由unstable_scheduleCallback(priorityLevel, callback, options)
傳入unstable_scheduleCallback
方法中的callback
是在scheduleCallback(reactPriorityLevel, callback, options)
方法中傳入scheduleCallback
方法中的callback
是在ensureRootIsScheduled
中的newCallbackNode = scheduleCallback(schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root));
設置- 因此可以看到
newTask
本質執行的方法是performConcurrentWorkOnRoot
,即構建 Fiber 樹的任務函數- timerQueue 與 taskQueue
- timerQueue:依據任務的過期時間(expirationTime)排序,過期時間越早,說明越緊急,過期時間小的排在前面。過期時間根據任務優先級計算得出,優先級越高,過期時間越早。
- taskQueue:依據任務的開始時間(startTime)排序,開始時間越早,說明會越早開始,開始時間小的排在前面。任務進來的時候,開始時間默認是當前時間,如果進入調度的時候傳了延遲時間,開始時間則是當前時間與延遲時間的和。
- 兩者的聯系
- 在創建新的 task 時,如果發現這個任務的執行時間並不緊急,則會將其先放入 timerQueue 隊列
- 優先執行的 task 在 taskQueue 隊列中
- 在不同的執行階段會通過
advanceTimers
方法,從 timerQueue 中將快過期的任務讓如到 taskQueue 隊列
function advanceTimers(currentTime) { // Check for tasks that are no longer delayed and add them to the queue. var timer = peek(timerQueue); while (timer !== null) { if (timer.callback === null) { // Timer was cancelled. pop(timerQueue); } else if (timer.startTime <= currentTime) { // Timer fired. Transfer to the task queue. pop(timerQueue); timer.sortIndex = timer.expirationTime; push(taskQueue, timer); } else { // Remaining timers are pending. return; } timer = peek(timerQueue); } } 復制代碼
- Scheduler 與 React 的聯系
- 說明:因為 Scheduler 本質可以和 React 分離,在 Scheduler 中也有其自己的任務優先級定義,而 React 中也利用 Lanes 的優先級模型,所以 React 在使用 Scheduler 的任務調度時,需要有一個任務優先級的轉換過程
- 源碼示例:
function scheduleCallback(reactPriorityLevel, callback, options) { // 將 React 的任務優先級轉換為 Scheduler 的任務優先級 var priorityLevel = reactPriorityToSchedulerPriority(reactPriorityLevel); return Scheduler_scheduleCallback(priorityLevel, callback, options); } 復制代碼
核心邏輯解析
仍然推薦大家看一下 7kms 大佬的 React 核心流程圖,每深入一個模塊,再回過頭來看這張圖都會有不一樣的理解。
核心流程圖
- 使用了 Scheduler 任務調度的流程圖(Conurrent模式)
- 沒有使用 Scheduler 任務的調度的流程圖(默認模式,Legacry 模式)
- 從源碼可以看到,區別非常簡單,就是循環中多了有一個
!shouldYield()
的判斷,用於做時間切片
// concurrent 模式 function workLoopConcurrent() { // Perform work until Scheduler asks us to yield while (workInProgress !== null && !shouldYield()) { performUnitOfWork(workInProgress); } } // legacy 模式 function workLoopSync() { // Already timed out, so perform work without checking if we need to yield. while (workInProgress !== null) { performUnitOfWork(workInProgress); } } 復制代碼
如何實現的任務切片
- 判斷條件
- 定義了 yieldInterval 變量,默認寫死的是 5ms
- 導出了條件方法 unstable_shouldYield
- 代碼部分
var yieldInterval = 5; var deadline = 0; // TODO: Make this configurable { // `isInputPending` is not available. Since we have no way of knowing if // there's pending input, always yield at the end of the frame. exports.unstable_shouldYield = function () { return exports.unstable_now() >= deadline; }; // Since we yield every frame regardless, `requestPaint` has no effect. requestPaint = function () {}; } 復制代碼
- 核心邏輯
- 在 react-reconciler 中的 workLoopConcurrent 中應用如下
// shouldYield() 方法即 unstable_shouldYield 本身 function workLoopConcurrent() { while (workInProgress !== null && !shouldYield()) { performUnitOfWork(workInProgress); } } 復制代碼
- 在 react-scheduler 中的 workLoop 中應用如下
function workLoop(hasTimeRemaining, initialTime) { var currentTime = initialTime; advanceTimers(currentTime); currentTask = peek(taskQueue); // unstable_shouldYield 用於判斷是否要中斷 while (currentTask !== null && !(enableSchedulerDebugging )) { if (currentTask.expirationTime > currentTime && (!hasTimeRemaining || exports.unstable_shouldYield())) { // This currentTask hasn't expired, and we've reached the deadline. break; } // 省略其他代碼 } 復制代碼
- 說明:
- 在理解切片的過程中,我一直思考錯了方向,總想着是先把任務按時間切好之后,再順次執行,以此達到切片的效果
- 其實是另一種實現,舉個例子:比如我們切蘿卜,並不是先標記好每一段在哪才下手,而是達到一定長度就下手,最終實現了按將蘿卜切成相似的一段一段
- 有了這層思考之后,理解切片,其實就是到時間點就停止,到時間點就停止,以此循環,最終看到的結果便是按一定時間段切割的效果
如何實現任務的中斷
在理解了上述任務的切片之后,再理解任務的中斷就變得非常容易,任務的中斷即在 reconciler 和 scheduler 中兩個 workLoop 循環的 break
在任務中斷的同時,還有兩處需要注意的邏輯,即 react 是如何保存中斷那一時刻的任務,以便后續恢復
- 在 scheduler 中,在每次執行 workLoop 中的循環時,是在執行 performConcurrentWorkOnRoot 方法
function workLoop(hasTimeRemaining, initialTime) { var currentTime = initialTime; advanceTimers(currentTime); currentTask = peek(taskQueue); // 針對 taskQueue 方法進行循環遍歷 while (currentTask !== null && !(enableSchedulerDebugging )) { if (currentTask.expirationTime > currentTime && (!hasTimeRemaining || exports.unstable_shouldYield())) { // This currentTask hasn't expired, and we've reached the deadline. break; } // 從當前的 task 中獲取執行的方法 var callback = currentTask.callback; // 如果執行的方法存在,則繼續 if (typeof callback === 'function') { currentTask.callback = null; currentPriorityLevel = currentTask.priorityLevel; var didUserCallbackTimeout = currentTask.expirationTime <= currentTime; // 此時,執行 callback,即 performConcurrentWorkOnRoot 方法 // 在執行 performConcurrentWorkOnRoot 方法的過程中,如果 reconciler 中的 workLoop 中斷了 // 會返回 performConcurrentWorkOnRoot 自身方法,也就是 continuationCallback 會被放到當前 task 的 callback // 此時 workLoop 的 while 循環中斷,但是由於當前 task 並沒有從隊列中出來, // 所以下一次執行 workLoop 時,仍然會執行本次存儲的 continuationCallback var continuationCallback = callback(didUserCallbackTimeout); currentTime = exports.unstable_now(); if (typeof continuationCallback === 'function') { currentTask.callback = continuationCallback; } else { if (currentTask === peek(taskQueue)) { pop(taskQueue); } } advanceTimers(currentTime); } // 執行的方法不存在,則將當前任務從 taskQueue 移除 else { pop(taskQueue); } // 獲取隊列中下一個方法 currentTask = peek(taskQueue); } // Return whether there's additional work if (currentTask !== null) { return true; } else { var firstTimer = peek(timerQueue); if (firstTimer !== null) { requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime); } return false; } } 復制代碼
- reconciler 中的 performConcurrentWorkOnRoot 方法,會在執行時,通過邏輯判斷,返回不同的值,當返回的值為其自身時,可以視作是一種中斷前的狀態保存
function performConcurrentWorkOnRoot(){ // 其他邏輯 // 當 fiber 鏈表的 callbackNode 在執行時,並沒有發生改變 // 則說明當前任務和之前是相同的任務,即上一次執行的任務還可以繼續 // 便將其自身返回,用於 scheduler 中的 continuationCallback if (root.callbackNode === originalCallbackNode) { // The task node scheduled for this root is the same one that's // currently executed. Need to return a continuation. return performConcurrentWorkOnRoot.bind(null, root); } // 其他邏輯 } 復制代碼
如何實現任務的恢復
其實到這里,可以發現,在了解了上述的任務切片和任務中斷之后,任務恢復的邏輯就很容易理解了。
換一個角度思考,即如果在 reconciler 中的 workLoopConcurrent 被中斷了,則會返回一個 performConcurrentWorkOnRoot 方法,在 scheduler 中的 workLoop 發現 continuationCallback 返回的值為一個方法,則會存下當前中斷的回調,且不讓當前執行的任務出棧,也就意味着當前的 task 沒有執行完,下一次循環時可以繼續執行,而執行的方法便是 continuationCallback 。
以此,實現了任務的恢復。
個人的一點理解
要理解 scheduler ,要從瀏覽器的 eventloop 開始理解,就會發現,這其實是3個 loop 循環的配合
- 一個比較泛的流程示例,僅給大家提供一些思考方向
在 React 中宏觀來看,針對瀏覽器、Scheduler 、Reconciler 其實是有3層 Loop。瀏覽器級別的 eventLoop,Scheduler 級別的 workLoop,Reconciler 級別 workLoopConcurrent 。
- 瀏覽器的 eventLoop 與 Scheduler 的關系
- 每次 eventLoop 會執行宏任務的隊列的宏任務,而 React 中的 Scheduler 就是用宏任務 messageChannel 觸發的。
- 當 eventLoop 開始執行跟 Scheduler 有關的宏任務時,Scheduler 會啟動一次 workloop,就是在遍歷執行 Scheduler 中已存在的 taskQueue 隊列的每個 task。
- Scheduler 與 Reconciler 的關系
- Scheduler中的 workLoop 中每執行一次 task,是通過調用 Reconciler 中的 performConcurrentWorkOnRoot 方法,即每一個 task 可以理解為是一個 performConcurrentWorkOnRoot 方法的調用。
- performConcurrentWorkOnRoot 方法每次調用,其本質是在執行 workLoopConcurrent 方法,這個方法是在循環 performUnitOfWork 這個構建 Fiber 樹中每個 Fiber 的方法。
因此可以梳理出來,3個大循環,從最開始的 eventLoop 的單個宏任務執行,會逐步觸發 Scheduler 和 Reconciler 的任務循環執行。
任務的中斷與恢復,實現中斷與恢復的邏輯分了2個部分,第一個是 Scheduler 中正在執行的 workloop 的任務中斷,第二個是 Reconciler 中正在執行的 workLoopConcurrent 的任務中斷
- Reconciler 中的任務中斷與恢復:在 workLoopConcurrent 的 while 循環中,通過 shouldYield() 方法來判斷當前構建 fiber 樹的執行過程是否超時,如果超時,則中斷當前的 while 循環。由於每次 while 執行的 fiber 構建方法,即 performUnitOfWork 是按照每個 fiberNode 來遍歷的,也就是說每完成一次 fiberNode 的 beginWork + completeWork 樹的構建過程,會設置下一次 nextNode 的值 ,可以理解為中斷時已經保留了下一次要構建的 fiberNode 指針,以至於不會下一次不知道從哪里繼續。
- Scheduler 中的任務中斷與恢復:當執行任務時間超時后,如果 Reconciler 中的 performConcurrentWorkOnRoot 方法沒有執行完成,會返回其自身。在 Scheduler 中,發現當前任務還有下一個任務沒有執行完,則不會將當前任務從 taskQueue 中取出,同時會把 reconciler 中返回的待執行的回調函數繼續賦值給當前任務,於是下一次繼續啟動 Scheduler 的任務時,也就連接上了。同時退出這次中斷的任務前,會通過 messageChannel 向 eventLoop 的宏任務隊列放入一個新的宏任務。
- 所以任務的恢復,其實就是從下一次 eventLoop 開始執行 Scheduler 相關的宏任務,而執行的宏任務也是 Reconciler 中斷前賦值的 fiberNode,也就實現了整體的任務恢復。
Demo 示例
示例僅采取了一些關鍵代碼的示例。
tips:如何調試 React 源碼,大家可以查看參考資料中的《React 技術揭秘》中的調試代碼環節
不用 Scheduler 任務調度的示例
- 代碼示例
- 創建 React 項目后的 index.js 代碼
import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; // React 默認的渲染模式,即 legacy 模式 // 此模式會使用到 Scheduler 的方法,但並不會做時間切片、任務中斷、恢復的相關邏輯 ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById('root') ); 復制代碼
- App.js 代碼示例
import List from './scheduler-demo/list' function App() { return ( <div className="App"> <List /> </div> ); } export default App; 復制代碼
- list.js 代碼示例
import React from 'react' export default function List () { return <ul> {Array(3000).fill(0).map((_, i) => <li>{i}</li>)} </ul> } 復制代碼
- 效果示例
- 結果說明
- 可以從圖中示例看到,在沒有任務調度的情況下,如果我們存在大量的 DOM 計算,則會將一次計算 DOM 相關的計算進行到底,之后統一輸出渲染,可以看到渲染 3000 個
<li>
節點,大約耗時 180ms- 主要關注 React 的邏輯處理,即
scheduleUpdateOnFiber
的入口函數- 可以看到主流程的邏輯,基本都帶有
xxxSync
的同步命名,也基本說明了在legacy
模式下執行的是同步處理邏輯利用 Scheduler 任務調度的示例
- 代碼示例
- 創建 React 項目后的 index.js 代碼
import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; // React 的 concurrent 渲染模式 // 此模式會使用到 Scheduler 的方法,並且會做時間切片、任務中斷、恢復的相關邏輯 ReactDOM.unstable_createRoot(document.getElementById('root')).render( <React.StrictMode> <App /> </React.StrictMode>); 復制代碼
- App.js 代碼示例、list.js 代碼示例不需要調整
- 效果示例
- 結果說明
- 可以從圖中示例看到,在有任務調度的情況下,會將 DOM 計算的過程切割成一段一段 5ms 左右的宏任務
- 主要關注 React 的邏輯處理,可以看到調用了很多帶有
xxxConcurrent
的 concurrent 模式特有的方法- 需要注意並不是每個任務都是完全按照 5ms 這個值進行切割的,會或多或少的類似 5.1 ms、5.2 ms 的切片,這是因為在做切割邏輯時,也會有 js 執行的時間損耗。
- 同時如果某個任務執行過程比較久,也會占用較為大的時間,比如在出現較為穩定的 5ms 切片任務前的第一個任務,大約耗時了 24 ms ,也是因為當前的執行邏輯還並未走進切片邏輯,是其他的 React 執行所耗時。
設置切片時間為 0ms 時 的情景
- 代碼示例
- index.js、App.js、list.js 的文件不需要調整,同 concurrent 模式
- 修改引入的 React 源碼,主要設置 yieldInterval 的賦值邏輯,示例如下:
// 在 scheduler 相關的源碼中 var isMessageLoopRunning = false; var scheduledHostCallback = null; var taskTimeoutID = -1; // Scheduler periodically yields in case there is other work on the main // thread, like user events. By default, it yields multiple times per frame. // It does not attempt to align with frame boundaries, since most tasks don't // need to be frame aligned; for those that do, use requestAnimationFrame. var yieldInterval = 0; // 將此處的值由原來的 5 改為 0 var deadline = 0; // TODO: Make this configurable 復制代碼
- 效果示例
- 結果說明
- 從效果示例中可以看到,當切片時間由 5ms 變為 0ms 后,渲染時長變的很長,大約是 5s 之后才將 DOM 渲染出來
- 從 Performance 中可以看出,任務根據 0ms 一段切割成了 n 個宏任務片段,並且很難找到(其實還是有)concurrent 模式下的 React 方法執行
- 所以可以得出一個結論,在 concurrent 模式下,將切片時間由 5ms 變為 0ms 后,Scheduler 還是會切割任務,由於 js 執行本身也是有時間損耗的,所以每一次的 task 執行完全依賴於瀏覽器內部對於這些產生的宏任務的處理,已經脫離了 Scheduler 本身能控制的范圍。即只要用了 concurrent 模式,都會有任務切割、中斷、回復,但是產生的效果如何,完全依賴於代碼邏輯以及瀏覽器執行底層的處理。
- 從 Scheduler 的角度出發,大家可以根據情況去設置這個時間切片的節點,還是不建議改為 0 (演示除外)
實現一個 Scheduler 核心邏輯
本示例的全部代碼,來自於文章:segmentfault.com/a/119000003…
- 示例代碼
const result = 3 let currentResult = 0 function calculate() { currentResult++ if (currentResult < result) { return calculate } return null } // 存放任務的隊列 const taskQueue = [] // 存放模擬時間片的定時器 let interval // 調度入口---------------------------------------- const scheduleCallback = (task, priority) => { // 創建一個專屬於調度器的任務 const taskItem = { callback: task, priority } // 向隊列中添加任務 taskQueue.push(taskItem) // 優先級影響到任務在隊列中的排序,將優先級最高的任務排在最前面 taskQueue.sort((a, b) => (a.priority - b.priority)) // 開始執行任務,調度開始 requestHostCallback(workLoop) } // 開始調度----------------------------------------- const requestHostCallback = cb => { interval = setInterval(cb, 1000) } // 執行任務----------------------------------------- const workLoop = () => { // 從隊列中取出任務 const currentTask = taskQueue[0] // 獲取真正的任務函數,即calculate const taskCallback = currentTask.callback // 判斷任務函數否是函數,若是,執行它,將返回值更新到currentTask的callback中 // 所以,taskCallback是上一階段執行的返回值,若它是函數類型,則說明上一次執行返回了函數 // 類型,說明任務尚未完成,本次繼續執行這個函數,否則說明任務完成。 if (typeof taskCallback === 'function') { currentTask.callback = taskCallback() console.log('正在執行任務,當前的currentResult 是', currentResult); } else { // 任務完成。將當前的這個任務從taskQueue中移除,並清除定時器 console.log('任務完成,最終的 currentResult 是', currentResult); taskQueue.shift() clearInterval(interval) } } // 把calculate加入調度,也就意味着調度開始 scheduleCallback(calculate, 1) 復制代碼
- 效果示例
// 輸出結果 // 正在執行任務,當前的currentResult 是 1 // 正在執行任務,當前的currentResult 是 2 // 正在執行任務,當前的currentResult 是 3 // 任務完成,最終的 currentResult 是 3 復制代碼
- 結果說明
- 本示例主要展示的是
如何判斷單個任務的完成狀態
- 本示例展示 Scheduler 中如何對任務中斷后如何進行恢復
typeof taskCallback === function
- 本示例主要展示了任務完成的邏輯處理
- 本示例並未加入切片的邏輯,其實要加入也並不復雜,即在
workLoop
加入循環的判斷條件即可,參考 Scheduler 源碼拓展
Scheduler 的開源計划
從 Scheduler 源碼的 README.md 中可以看到,React 團隊是希望它變得更通用,不僅僅服務於 React,只是現階段更多是用於 React 中。
- npm 地址:www.npmjs.com/package/sch…
- README.md 原文:
This is a package for cooperative scheduling in a browser environment. It is currently used internally by React, but we plan to make it more generic. The public API for this package is not yet finalized. 復制代碼
Scheduler 為瀏覽器提供規范
調度系統的限制:
- 調度系統只能有一個,如果同時存在兩個調度系統,就無法保證調度的正確性。
- 調度系統能力有限,只能在瀏覽器提供的能力范圍內進行調度,而無法影響比如 HTML 渲染、內存回收周期。
為了解決這個問題,Chrome 正在與 React、Polymer、Ember、Google Maps、Web Standars Community 共同創建一個瀏覽器調度規范,提供瀏覽器級別 API,可以讓調度控制更底層的渲染時機,也保證調度器的唯一性。
tips: 瀏覽器調度規范:github.com/WICG/schedu…
React 18 的離屏渲染
React 的離屏渲染是在 React 18 中的一個新 API,作用可以先視作 keep-alive 的實現
之所以在這里提一下離屏渲染,是因為這也是一種提升用戶體驗,減少用戶卡頓的優化體驗。如果說 Scheduler 任務調度器是為了能夠讓一個任務不至於將用戶頁面卡死,那么離屏渲染則是能夠讓用戶在看到頁面時就不需要再等待。
React 18 中提出的新 API
- 原文如下,防止變味不做硬翻
The main motivation for the new Offscreen API (and the effects changes described in this post) is to allow React to preserve state like this by hiding components instead of unmounting them. To do this React will call the same lifecycle hooks as it does when unmounting– but it will also preserve the state of both React components and DOM elements.
離屏渲染的拓展(此處的說明已與 React 無關):
- 概念:指的是 GPU 在當前屏幕緩沖區以外新開辟一個緩沖區(離屏緩存區)進行渲染操作。等所有數據都在離屏渲染區完成渲染后才會提交到幀緩存區,然后再被顯示。
- 應用場景:Android、IOS、Electron
- 個人理解:需要利用 GPU 做輔助渲染,方便 CPU 在使用時直接顯示。假如某一天瀏覽器(比如在 React)中要實現類似的功能,那么必然需要借助 Canvas 3D 模式 + WebGL 才有可能觸發 GPU 的計算和渲染,那時前端能做的事情將更加炫酷,當然這個和現在的圖形圖像方向並非一件事。
Vue 和 React 優化方案的選擇
JavaScript 是單線程運行的,它要負責頁面的 JS 解析和執行、繪制、事件處理、靜態資源加載和處理。
Javascript 引擎是單線程運行的。 嚴格來說,Javascript 引擎和頁面渲染引擎在同一個渲染線程,GUI 渲染和 Javascript執行 兩者是互斥的. 另外異步 I/O 操作底層實際上可能是多線程的在驅動。
它只是一個 JavaScript ,同時只能做一件事情,這個和 DOS 的單任務操作系統一樣的,事情只能一件一件的干。要是前面有一個任務長期霸占 CPU,后面什么事情都干不了,瀏覽器會呈現卡死的狀態,這樣的用戶體驗就會非常差。
對於“前端框架”來說,解決這種問題有三個方向:
- 優化每個任務,讓它有多快就多快。擠壓 CPU 運算量
- 快速響應用戶,讓用戶覺得夠快,不能阻塞用戶的交互
- 嘗試 Worker 多線程
Vue 選擇的是第1種,因為對於 Vue 來說,使用模板讓它有了很多優化的空間,配合響應式機制可以讓 Vue 可以精確地進行節點更新;而 React 選擇了第2種 。對於 Worker 多線程渲染方案也有人嘗試,要保證狀態和視圖的一致性相當麻煩。
個人理解:
- Vue 通過 Object.defineProperty/Proxy 等方式,控制每次執行的點,每次只需要更新需要的部分。因為每次可以只更新部分
- React 則是通過 Fiber、Scheduler 的結合,控制每次執行的量,每次盡可能不影響瀏覽器主流程的情況下盡可能多的執行任務,因為每次都會走一遍 Fiber 的遍歷
雜談
- React-Scheduler 的源碼中,也使用了數據結構和算法,timerQueue、taskQueue 就使用了小頂堆排序的數據結構及算法,感興趣的同學可以去深入了解
- 如果你要抓瀏覽器的 performance ,最好在無痕模式,因為這樣的話可以避免一些插件的干擾
- 在 React 的 issues 中搜索 requestIdleCallback、requestAnimateCallback、MessageChannel 可以看到很多關於這3個問題的漸進式迭代過程,以及相關的討論和原因
- 在探索 React 相關的問題中,有一個感受就是,在 React 不斷迭代的過程中,其團隊會在源碼中嘗試各種想法,但是並不影響其最終發版的文檔版本。比如從 15.6 版本中就出現了 Fiber,但是並未向外暴露,當我們去看最終穩定版時,並沒有相關源碼。所以當我們看到很多概念,在源碼中並沒有找到時,或者當你發現一些穩定版沒有的內容時,不要急於否定。因為開發版和穩定版往往是通過最終發包的不同做了區分。我們可以多去 issues 中探尋一些痕跡,會幫助我們理解 React 團隊的整個思考過程
- 學習方法建議:看文章一定要多看幾篇,尤其是要優先看官方文檔、源代碼,之后再配合一些成體系的文章、以及單篇的精講(比如本文),單篇的精講也要多找一些,兼聽則明。因為不同的作者在其研究相關知識點的過程中,除了一些共識點外,也會流露出一些他們思考的方式及思考的維度。而恰恰是這些值得發散的點,往往能幫助我們理解核心的細節。切記:不要背文章,也不用僅相信一篇文章(包括本文)。
- 建議大家有一個自己思考的過程,也建議大家可以多看看下面的參考資料。
參考資料
- 圖解React原理系列:7kms.github.io/react-illus…
- React技術揭秘:react.iamkasong.com/
- 精讀《Scheduling in React》:juejin.cn/post/684490…
- WICG/scheduling-apis:github.com/WICG/schedu…
- 這可能是最通俗的 React Fiber(時間分片) 打開方式:juejin.cn/post/684490…
- Couldn't you just use generator functions like other scheduling frameworks have done?:github.com/facebook/re…
- 使用 postMessage 的版本說明:github.com/facebook/re…
- 瀏覽器的 16ms 渲染幀:harttle.land/2017/08/15/…
- WHATWG-Event loops:html.spec.whatwg.org/multipage/w…
- 深入解析 EventLoop 和瀏覽器渲染、幀動畫、空閑回調的關系:zhuanlan.zhihu.com/p/142742003
- 一篇長文幫你徹底搞懂React的調度機制原理:segmentfault.com/a/119000003…
- MDN-requestIdleCallback:developer.mozilla.org/zh-CN/docs/…
- MDN-requestAnimationFrame :developer.mozilla.org/zh-CN/docs/…
- 從event loop規范探究javaScript異步及瀏覽器更新渲染時機:github.com/aooy/blog/i…
- React Fiber架構:zhuanlan.zhihu.com/p/37095662
- React Scheduler 為什么使用 MessageChannel 實現:juejin.cn/post/695380…
- 深入剖析 React Concurrent:zhuanlan.zhihu.com/p/60307571
- postMessage & Scheduler:www.yuque.com/docs/share/…
- React Scheduler 源碼詳解(1):juejin.cn/post/684490…
- React Scheduler 源碼詳解(2):juejin.cn/post/684490…
- React 關於 Webworkers 的討論:github.com/facebook/re…
- React 源碼解析之Scheduler:juejin.cn/post/700761…
- React-18:Adding Strict Effects to StrictMode:github.com/reactwg/rea…
- Electron-離屏渲染:www.electronjs.org/zh/docs/lat…
- 探究iOS離屏渲染原理:juejin.cn/post/685657…
歡迎關注前端早茶,與廣東靚仔攜手共同進階
前端早茶專注前端,一起結伴同行,緊跟業界發展步伐~