完全搞懂React源碼調度原理(Concurrent模式)

自上一篇寫關於diff的文章到如今已通過了二十天多,利用業餘時間和10天婚假的閒暇,終於搞懂了React源碼中的調度原理。當費勁一番周折終於調試到將更新與調度任務鏈接在一塊兒的核心邏輯那一刻,憂愁的嘴角終於露出欣慰的微笑。html

最先以前,React尚未用fiber重寫,那個時候對React調度模塊就有好奇。而如今的調度模塊對於以前沒研究過它的我來講更是帶有一層神祕的色彩,色彩中朦朧浮現出兩個字:「困難」。react

截至目前react的Concurrent(同時)調度模式依然處在實驗階段(期待中),還未正式發佈,但官網已有相關簡單介紹的文檔,相信不久以後就會發布(參考hooks)。git

在研究的時候也查閱了網上的相關資料,但可參考的很少。緣由一個是調度模塊源碼變更較大,以前的一些文章和如今的源碼實現對不上(不過不少文章對時間切片和優先級安排的概念講解很到位),另外一個是如今可參考的列出調度流程相應源碼的文章幾乎沒有。github

因此本文主要是經過本身對源碼的閱讀,推理和驗證,加上大量時間做爲催化劑,將React源碼中的調度原理展示給各位讀者。web

React使用當前最新版本:16.13.1算法

今年會寫一個「搞懂React源碼系列」,把React最核心的內容用最易懂的方式講清楚。2020年搞懂React源碼系列:瀏覽器

  • React Diff原理
  • (當前)React 調度原理
  • 搭建閱讀React源碼環境-支持React全部版本斷點調試細分文件
  • React Hooks原理

同步調度模式

React目前只有一種調度模式:同步模式。只有等Concurrent調度模式正式發佈,才能使用第兩種模式。微信

沒有案例的講解是沒有靈魂的。咱們先來看一個此處和後續講優先級都將用到的案例:多線程

假設有一個按鈕和有8000個包含一樣數字的文本標籤,點擊按鈕後數字會加2。(使用8000個文本標籤是爲了加長react單次更新任務的計算時間,以便直觀觀察react如何執行多任務)app

咱們用類組件實現案例。

渲染內容:

<div>
  <button ref={this.buttonRef} onClick={this.handleButtonClick}>增長2</button>
  <div>
    {Array.from(new Array(8000)).map( (v,index) =>
      <span key={index}>{this.state.count}</span>
    )}
  </div>
</div>
複製代碼

添加按鈕點擊事件:

handleButtonClick = () => {
  this.setState( prevState => ({ count: prevState.count + 2 }) )
}
複製代碼

並在componentDidMount中添加以下代碼:

const button = this.buttonRef.current
setTimeout( () => this.setState( { count: 1 } ), 500 )
setTimeout( () => button.click(), 500 )
複製代碼

ReactDOM初始化組件:

ReactDOM.render(<SyncSchedulingExample />, document.getElementById("container")); 複製代碼

添加2個setTimeout是爲了展現同步模式的精髓: 500毫秒後有兩個異步的setState的任務,因爲react要計算和渲染8000個文本標籤,那麼任何一個任務光計算的時間都要幾百毫秒,那麼react會如何處理這兩個任務?

運行案例後,查看Chrome性能分析圖:

從結果可知,儘管兩個任務理應「同時」運行,但react會先把第一個任務執行完後再執行第二個任務,這就是react同步模式:

多個任務時,react都會按照任務順序一個一個執行,它沒法保證後面的任務能在本應執行的時間執行。(其實就是JS自己特性EventLoop的展示。好比只要一個while循環足夠久,理應在某個時刻執行的方法就會被延遲到while循環結束後才運行。)

Concurrent(同時)調度模式

Concurrent調度模式是一種支持同時執行多個更新任務的調度模式。

它的特色是任何一個更新任務均可以被更高優先級中斷插隊,在高優先級任務執行以後再執行。

很重要的一點,"同時執行多個更新任務"指的是同時將多個更新任務添加到React調度的任務隊列中,而後React會一個個執行,而不是相似多線程同時工做那種方式。

如何理解模式名字:Concurrent(同時)?

React官網用了一個很形象的版本管理案例來形容「同時」模式。

當咱們沒有版本管理軟件的時候,若一我的要修改某個文件,須要通知其餘人不要修改這個文件,只有等他修改完以後才能去修改。沒法作到多我的同時修改一個文件。

但有了版本管理軟件,咱們每一個人均可以拉一個分支,修改同一個文件,而後將本身修改的內容合併到主分支上,作到多人「同時」修改一個文件。

因此,若是React也能作到「同時」執行多個更新任務,作到每個更新任務的執行不會阻塞其餘更新任務的加入,豈不是很方便。

這能夠看做是「同時」模式名字的由來。

同時調度模式的應用場景

下方爲React團隊成員Dan在作同時模式分享時用的DEMO。一樣的快速輸入幾個數字,在同步模式和同時模式可發現明顯區別。

Dan-Concurrent Mode Demo:

同步模式下,卡頓現象明顯,而且會出現UI阻塞狀態:Input中的光標再也不閃爍,而是卡住。

同時模式下,只有輸入內容較長才會出現稍微的卡頓狀況和UI阻塞。性能獲得明顯改善。

同時模式很好的解決了連續頻繁更新狀態場景下的卡頓和UI阻塞問題。固然,同時模式下還有其餘實用功能,好比Suspense,由於本文主要講調度原理和源碼實現,因此就不展開講Suspense了。

同步調度模式如何實現

React是如何實現同步調度模式的?這也是本文的核心。接下來將先講時間切片模式,以及React如何實現時間切片模式,而後再講調度中的優先級,以及如何實現優先級插隊,最後講調度的核心參數:expirationTime(過時時間)。

時間切片

什麼是時間切片

最先是從Lin Clark分享的經典Fiber演講中瞭解到的時間切片。時間切片指的是一種將多個粒度小的任務放入一個個時間切片中執行的一種方法。

時間切片的做用

在剛執行完一個時間切片準備執行下一個時間切片前,React可以:

  • 判斷是否有用戶界面交互事件和其餘須要執行的代碼,好比點擊事件,有的話則執行該事件

  • 判斷是否有優先級更高的任務須要執行,若是有,則中斷當前任務,執行更高的優先級任務。也就是利用時間前片來實現高優先級任務插隊。

即時間切片有兩個做用:

  1. 在執行任務過程當中,不阻塞用戶與頁面交互,當即響應交互事件和須要執行的代碼

  2. 實現高優先級插隊

React源碼如何實現時間切片

  1. 首先在這裏引入當前React版本中的一段註釋說明:

// 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. let yieldInterval = 5;

註釋對象是聲明yieldInterval變量的表達式,值爲5,即5毫秒。其實這就是React目前的單位時間切片長度。

註釋中說一個幀中會有多個時間切片(顯而易見,一幀~=16.67ms,包含3個時間切片還多),切片時間不會與幀對齊,若是要與幀對齊,則使用requestAnimationFrame

從2019年2月27號開始,React調度模塊移除了以前的requestIdleCallback膩子腳本相關代碼

因此在一些以前的調度相關文章中,會提到React如何使用requestAnimationFrame實現requestIdleCallback膩子腳本,以及計算幀的邊界時間等。由於當時的調度源碼的確使用了這些來實現時間切片。不過如今的調度模塊代碼已精簡許多,而且用新的方式實現了時間切片。

  1. 瞭解時間切片實現方法前需掌握的知識點:
  • Message Channel:瀏覽器提供的一種數據通訊接口,可用來實現訂閱發佈。其特色是其兩個端口屬性支持雙向通訊和異步發佈事件(port.postMessage(...))。
const channel = new MessageChannel()
const port1 = channel.port1
const port2 = channel.port2

port1.onmessage = e => { console.log( e.data ) }
port2.postMessage('from port2')
console.log( 'after port2 postMessage' )

port2.onmessage = e => { console.log( e.data ) }
port1.postMessage('from port1')
console.log( 'after port1 postMessage' )

// 控制檯輸出: 
// after port2 postMessage
// after port1 postMessage
// from port2
// from port1
複製代碼
  • Fiber: Fiber是一個的節點對象,React使用鏈表的形式將全部Fiber節點鏈接,造成鏈表樹,即虛擬DOM樹。 當有更新出現,React會生成一個工做中的Fiber樹,並對工做中Fiber樹上每個Fiber節點進行計算和diff,完成計算工做(React稱之爲渲染步驟)以後,再更新DOM(提交步驟)。
  1. 下面讓咱們來看React究竟如何實現時間切片。

首先React會默認有許多微小任務,即全部的工做中fiber節點。

在執行調度工做循環和計算工做循環時,執行每個工做中Fiber。可是,有一個條件是每隔5毫秒,會跳出工做循環,運行一次異步的MessageChannelport.postMessage(...)方法,檢查是否存在事件響應、更高優先級任務或其餘代碼須要執行,若是有則執行,若是沒有則從新建立工做循環,執行剩下的工做中Fiber。

可是,爲何性能圖上顯示的切片不是精確的5毫秒?

由於一個時間切片中有多個工做中fiber執行,每執行完一個工做中Fiber,都會檢查開始計時時間至當前時間的間隔是否已超過或等於5毫秒,若是是則跳出工做循環,但算上檢查的最後一個工做中fiber自己執行也有一段時間,因此最終一個時間切片時間必定大於或等於5毫秒。

時間切片和其餘模塊的實現原理對應源碼位於本文倒數第二章節「源碼實探」。

將描述和實際源碼分開,是爲了方便閱讀。先用大白話把原理實現流程講出來,不放難懂的源碼,最後再貼出對應源碼。

如何調度一個任務

講完時間切片,就能夠了解React如何真正的調度一個任務了。

requestIdleCallback(callback, { timeout: number })是瀏覽器提供的一種可讓回調函數執行在每幀(上圖2個vsync之間即爲1幀)末尾的空閒階段的方法,配置timeout後,若多幀持續沒有空閒時間,超過timeout時長後,該回調函數將當即被執行。

如今的React調度模塊雖沒有使用requestIdleCallback,但充分吸取了requestIdleCallback的理念。其unstable_scheduleCallback(priorityLevel, callback, { timeout: number })就是相似的實現,不過是針對不一樣優先級封裝的一種調度任務的方法。

在講調度流程前先簡單介紹調度中用到的相關參數:

  • 當前Fiber樹的root:擁有屬性「回調函數」

  • React中的調度模塊的任務: 擁有屬性 「優先級,回調函數,過時時間」

  • 過時時間標記:源碼中expirationTime有兩種類型,一種是標記類型:一個極大值,大小與時長成反比,能夠用來做優先級標記,值越大,優先級越高,好比:1073741551;另外一種是從網頁加載開始計時的具體過時時間:好比8000毫秒)。具體內容詳見後面的expirationTime章節

  • DOM調度配置: 由於react同時支持web端dom和移動端native兩種,核心算法一致,但有些內容是兩端獨有的,因此有的模塊有專門的DOM配置和Native配置。咱們這裏將用到調度模塊的DOM配置

  • requestHostCallback:DOM調度配置中使用Message Channel異步執行回調函數的方法

接下來看React如何調度一個任務。

初始化

  1. 當出現新的更新,React會運行一個確保root被安排任務的函數。

  2. 當root的回調函數爲空值且新的更新對應的過時時間標記是異步類型,根據當前時間和過時時間標記推斷出優先級和計算出timeout,而後根據優先級、timeout, 結合執行工做的回調函數,新建一個任務(這裏就是scheduleCallback),將該任務放入任務隊列中,調用DOM調度配置文件中的requestHostCallback,回調函數爲調度中心的清空任務方法。

運行任務

  1. requestHostCallback調用MessageChannel中的異步函數:port.postMessage(...),從而異步執行以前另外一個端口port1訂閱的方法,在該方法中,執行requestHostCallback的回調函數,即調度中心的清空任務方法。

2.清空任務方法中,會執行調度中心的工做循環,循環執行任務隊列中的任務。

有趣的是,工做循環並非執行完一次任務中的回調函數就繼續執行下一個任務的回調函數,而是執行完一個任務中的回調函數後,檢測其是否返回函數。若返回,則將其做爲任務新的回調函數,繼續進行工做循環;若未返回,則執行下一個任務的回調函數。

而且工做循環中也在檢查5毫秒時間切片是否到期,到期則從新調port.postMessage(...)

  1. 任務的回調函數是一個執行同時模式下root工做的方法。執行該方法時將循環執行工做中fiber,一樣使用5毫秒左右的時間切片進行計算和diff,5毫秒時間切片過時後就會返回其自身。

完成任務

  1. 在執行完全部工做中fiber後,React進入提交步驟,更新DOM。

  2. 任務的回調函數返回空值,調度工做循環所以(運行任務步驟中第二點:若任務的回調函數執行後返回爲空,則執行下一個任務)完成此任務,並將此任務從任務隊列中刪除。

如何實現優先級

目前有6種優先級(從高到低排序):

優先級類型 使用場景
當即執行ImmediatePriority React內部使用:過時任務當即同步執行;用戶自定義使用
用戶與頁面交互UserBlockingPriority React內部使用:用戶交互事件生成此優先級任務;用戶自定義使用
普通NormalPriority React內部使用:默認優先級;用戶自定義使用
低LowPriority 用戶自定義使用
空閒IdlePriority 用戶自定義使用
無NoPriority React內部使用:初始化和重置root;用戶自定義使用

表格中列出了優先級類型和使用場景。React內部用到了除低優先級和空閒優先級之外的優先級。理論上,用戶能夠自定義使用全部優先級,使用方法:

React.unstable_scheduleCallback(priorityLevel, callback, { timeout: <number> }) 複製代碼

不一樣優先級的做用就是讓高優先級任務優先於低優先級任務執行,而且因爲時間切片的特性(每5毫秒執行一次異步的port.postMessage(...),在執行相應回調函數前會執行檢測到的須要執行的代碼)高優先級任務的加入能夠中斷正在運行的低優先級任務,先執行完高優先級任務,再從新執行被中斷的低優先級讓任務。

高優先級插隊也是同時調度模式的核心功能之一。

高優先級插隊

接下來,使用相似同步模式代碼的插隊案例。 渲染內容:

<div>
  <button ref={this.buttonRef} onClick={this.handleButtonClick}>增長2</button>
  <div>
    {Array.from(new Array(8000)).map( (v,index) =>
      <span key={index}>{this.state.count}</span>
    )}
  </div>
</div>
複製代碼

添加按鈕點擊事件:

handleButtonClick = () => {
  this.setState( prevState => ({ count: prevState.count + 2 }) )
}
複製代碼

並在componentDidMount中添加以下代碼(不一樣之處,第二次setTimeout的時間由500改成600):

const button = this.buttonRef.current
setTimeout( () => this.setState( { count: 1 } ), 500 )
setTimeout( () => button.click(), 600)
複製代碼

ReactDOM初始化組件(不一樣之處,使用React.createRoot開啓Concurrent模式):

ReactDOM.createRoot( document.getElementById('container') ).render( <ConcurrentSchedulingExample /> ) 複製代碼

爲何第二次setTimeout的時間由500改成600?

由於是爲了展現高優先級插隊。第二次setTimeout使用的用戶交互優先級更新,晚100毫秒,可保證第一次setTimeout對應的普通更新正在執行中,尚未完成,這個時候最能體現插隊效果。

運行案例後,頁面默認顯示8000個0,而後0變爲2(而不是變爲1),再變爲3。

經過DOM內容的變化已經能夠看出:第二次setTimeout執行的按鈕點擊事件對應的更新插了第一次setTimeout對應更新的隊。

接下來,觀察性能圖。 總覽:

被中斷細節:只執行了3個時間切片就被中斷:

如何實現高優先級插隊

  1. 延用上面的高優先級插隊案例,從觸發高優先級點擊事件(準備插隊)開始。

觸發點擊事件後,React會運行內部的合成事件相關代碼,而後執行一個執行優先級的方法,優先級參數爲「用戶交互UserBlockingPriority」,接着進行setState操做。

setState的關聯方法新建一個更新,計算當前的過時時間標記,而後開始安排工做。

  1. 在安排工做方法中,運行確保root被安排任務的方法。由於如今的優先級更高且過時時間標記不一樣,調度中心取消對以前低優先級任務的安排,並將以前低優先級任務的回調置空,確保它以後不會被執行(調度中心工做循環根據當前的任務的回調函數是否爲空決定是否繼續執行該任務)。

而後調度中心根據高優先級更新對應的優先級、過時時間標記、timeout等建立新的任務。

  1. 執行高優先級任務,當執行到開始計算工做中類Fiber(class ConcurrentSchedulingExample),執行更新隊列方法時,React將循環遍歷工做中類fiber的更新環狀鏈表。

當循環到以前低優先級任務對應更新時,由於低優先級過時時間標記小於當前渲染過時時間標記,故將該低優先級過時時間標記設爲工做中類fiber的過時時間標記(其餘狀況會將工做中類fiber的過時時間標記設爲0)。此處是以後恢復低優先級的關鍵所在。

  1. 在完成優先級任務過程的提交渲染DOM步驟中,渲染DOM後,會將root的callbackNode(其名字容易誤導其功能,其實就是調度任務,用callbackTask或許更合適)設爲空值。

在接下來執行確保root被安排任務的方法中,由於下一次過時時間標記不爲空(根本緣由就是上面第二點提到工做中類fiber的過時時間標記被設置爲低優先級過時時間標記)且root的callbackNode爲空值,因此建立新的任務,即從新建立一個新的低優先級任務。並將任務放入任務列表中。

  1. 從新執行低優先級任務。此處須要注意是從新執行而不是從以前中斷的地方繼續執行。畢竟React計算過程當中只有當前fiber樹和工做中fiber樹,執行高優先級時,工做中fiber樹已經被更新,因此恢復低優先級任務必定是從新完整執行一遍。

過時時間ExpirationTime

做爲貫穿整個調度流程的參數,過時時間ExpirationTime的重要性不言而喻。

但在調試過程當中,發現expirationTime卻不止一種類型。它的值有時是1073741121,有時又是6500,兩個值顯示對應不一樣類型。爲何會出現這種狀況?

事實上,當前Reac正在重寫ExpirationTime的功能,若是後續看到這篇文章發現跟源碼差異較大,歡迎閱讀我以後寫的解讀新ExpirationTime功能的文章(立個FLAG先,主要後面expirationTime一塊變化應該不小,值得研究)。

ExpirationTime的變化過程

以上方優先級插隊爲例,觀察expirationTime值及其相關值的變化。

  • 更新低優先級
過程 時間相關參數
setState(...) -->
var expirationTime = computeExpirationForFiber(currentTime, ...)
currentTime 1073741 641
expirationTime=computeExpirationForFiber(currentTime, ...) 1073741 121
ensureRootIsScheduled(...) -->
timeout = expirationTimeToMs(expirationTime) - now()
expirationTimeToMs(expirationTime) 7000
now() 1808
timeout = expirationTimeToMs(expirationTime) - now() 5192
startTime 1808
timeout 5192
expirationTime = startTime + timeout 7000

在設置更新時,會根據當前優先級和當前時間標記生成對應過時時間標記。

而此後,在確保和安排任務時,會將過時時間標記轉換爲實際過時時間。

表格的第二第三過程轉了一圈,最後仍是回到第一次計算的過時時間(由於js同步執行少許代碼過程當中,performance.now()的變化幾乎能夠忽略)。

  • 中斷低優先級更新,更新高優先級
過程 時間相關參數
setState(...) -->
var expirationTime = computeExpirationForFiber(currentTime, ...)
currentTime 1073741 630
expirationTime=computeExpirationForFiber(currentTime, ...) 1073741 571
ensureRootIsScheduled(...) --> timeout = expirationTimeToMs(expirationTime) - now() expirationTimeToMs(expirationTime) 2500
now() 1916
timeout = expirationTimeToMs(expirationTime) - now() 584
unstable_scheduleCallback() -->
var expirationTime = startTime + timeout
startTime 1916
timeout 584
expirationTime = startTime + timeout 2500
processUpdateQueue(...)-->
if ( updateExpirationTime < renderExpirationTime ){
newExpirationTime = updateExpirationTime
}
updateExpirationTime 1073741 121
renderExpirationTime 1073741 571
newExpirationTime = updateExpirationTime 1073741 121

執行高優先級時,低優先級被中斷。而可以讓低優先級被恢復的核心邏輯就是最後一個過程(執行更新隊列)中對updateExpirationTime(低優先級更新的過時時間標記)和renderExpirationTime(高優先級更新的過時時間標記)的判斷。

由於低優先級過時時間標記小於高優先級過時時間標記,即低優先級過時時間大於高優先級過時時間(過時時間標記與過時時間成反比,下面會講到),代表低優先級更新已經被插隊,須要從新執行。因此低優先級更新過時時間標記設爲工做中類fiber的過時時間標記。

  • 從新更新低優先級
過程 時間相關參數
ensureRootIsScheduled(...) -->
timeout = expirationTimeToMs(expirationTime) - now()
expirationTimeToMs(expirationTime) 7000
now() 2066
timeout = expirationTimeToMs(expirationTime) - now() 4934
unstable_scheduleCallback(...) -->
var expirationTime = startTime + timeout
startTime 2066
timeout 4934
expirationTime = startTime + timeout 7000

過時時間的兩種類型

經過觀察expirationTime值的變化過程,可知在設置更新時,計算的expiraionTime爲一種標記形式,而到安排任務的時候,任務的expirationTime已變爲實際過時時間。

expirationTime的2種類型:

  1. 時間標記:一個極大值,如1073741121

  2. 過時時間:從網頁加載開始計時的實際過時時間,單位爲毫秒

過時時間標記

React成員Andrew Clark在"Make ExpirationTime an opaque type "中提到了expirationTime做爲標記的計算方法和做用:

In the old reconciler, expiration times are computed by applying an offset to the current system time. This has the effect of increasing the priority of updates as time progresses.

他說ExpirationTime是經過給當前系統時間添加一個偏移量來計算,這樣的做用是隨着時間運行可以提高更新的優先級。

而源碼中,expirationTime的確是根據一個最大整數值偏移量來計算:

MAGIC_NUMBER_OFFSET - ceiling(MAGIC_NUMBER_OFFSET - currentTime + expirationInMs / UNIT_SIZE, bucketSizeMs / UNIT_SIZE)

其中:

  • MAGIC_NUMBER_OFFSET是一個極大常量: 1073741 821
  • UNIT_SIZE也是常量:10,用來將毫秒值除以10,好比1000毫秒轉爲1000/10=100,便於展現時間標記
  • ceiling(num, unit)的做用是根據單位長度進行特殊向上取整(對基礎值也向上取整,好比1.1特殊向上取整後爲2,而1特殊向上取整後也爲2, 能夠理解爲 Math.floor( num + 1 ) )
function ceiling(num, unit) {
  return ((num / unit | 0) + 1) * unit;
}
複製代碼

num | 0的做用相似Math.floor(num), 向下取整,而且加1能夠放入括號,因此代碼可轉換爲:

function ceiling(num, unit) {
  return Math.floor( num / unit + 1 ) * unit;
}
複製代碼

好比,若單位unit爲10,若數值num爲:

    • 10,則返回20
    • 11,也返回20

爲何要React要使用特殊向上取整方法?

由於這樣能夠實現」更新節流「:在單位時間(好比100毫秒)內,保證多個同等優先級更新計算出的expirationTime相同,只執行第一個更新對應的任務(但計算更新時會用到全部更新)。

在確保root被安排好任務的函數中,會判斷新的更新expirationTime和正在執行的更新expirationTime是否相同,以及它們的優先級是否相同,若相同,則直接return。從而不會執行第一個更新以後更新對應的任務。

但這並非說以後的更新都不會執行。因爲第一個更新對應任務的執行是異步的(post.postMessage),在第一個更新執行更新隊列時,其餘更新早已被加入更新隊列,因此能確保計全部更新參與計算。

  • MAGIC_NUMBER_OFFSET - currentTime的值爲performance.now()/10
  • expirationInMs表示不一樣優先級對應的過時時長:
    • 普通/低優先級:5秒
    • 高優先級(用戶交互優先級):生產環境下爲150毫秒,開發環境下爲500毫秒
    • 當即優先級、空閒優先級不經過上面的公式計算,它們的過時時間標記值分別爲12,一個表示當即過時,另外一個表示永不過時。
  • bucketSizeMs: 即ceiling(num, unit)中的unit,做爲特殊向上取整的單位長度。高優先級爲100毫秒,普通/低優先級爲250毫秒。

爲了便於理解,不考慮更新節流,則:

過時時間標記值 = 極大數值 - ( 當前時間 + 優先級對應過時時長 ) / 10
複製代碼

當前時間 + 優先級對應過時時長就是實際過時時間,因此:

過時時間標記值 = 極大數值 - 過時時間 / 10
複製代碼

過時時間

過時時間就是:

當前時間 + 優先級對應過時時長 
複製代碼

過時時間標記轉換爲過時時間:

function expirationTimeToMs(expirationTime) {
    return (MAGIC_NUMBER_OFFSET - expirationTime) * UNIT_SIZE;
}
複製代碼

源碼實探

寫到此處,不知不覺已通過了好幾天。對於源碼展示這一塊,也有了不一樣的打算。以前計劃純用流程圖展示。但由於涉及關鍵代碼量大,流程圖不是很適用。因此此次直接用流程敘述+相關源碼,直觀的實現原理對應源碼。

時間切片源碼

在執行調度工做循環和計算工做循環時,執行每個工做中Fiber。可是,有一個條件是每隔5毫秒,會跳出工做循環,

function workLoop(...) {
    ...
    while (currentTask !== null && ...) {
        ....
    }
    ...
}
複製代碼

調度工做循環

function workLoopConcurrent() {
    while (workInProgress !== null && !shouldYield()) {
      workInProgress = performUnitOfWork(workInProgress);
    }
  }
複製代碼

計算工做循環中,shouldYield()即爲檢查5毫秒是否到期的條件

shouldYield(...) --> Scheduler_shouldYield(...) --> unstable_shouldYield(...)
--> shouldYieldToHost(...)
--> getCurrentTime() >= deadline
-->
  var yieldInterval = 5; var deadline = 0;
  var performWorkUntilDeadline = function() {
      ...
      var currentTime = getCurrentTime()
      deadline = currentTime + yieldInterval
      ...
  }
複製代碼

var yieldInterval = 5爲每隔5毫秒的體現

運行一次異步的MessageChannelport.postMessage(...)方法,檢查是否存在事件響應、更高優先級任務或其餘代碼須要執行,若是有則執行,若是沒有則從新建立工做循環,執行剩下的工做中Fiber。

var channel = new MessageChannel();
var port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
requestHostCallback = function(callback) {
    ...
    if (...) {
        ...
        port.postMessage(null);
    }
}
複製代碼

在執行調度任務過程當中,會執行requestHostCallback(...), 從而調用port.postMessage(...)

調度一個任務源碼

初始化

  1. 當出現新的更新,React會運行一個確保root被安排任務的函數。
setState(...) --> enqueueSetState(...) 
--> scheduleWork(...) --> ensureRootIsScheduled(...)
複製代碼
  1. 當root的回調函數爲空值且新的更新對應的過時時間標記是異步類型,根據當前時間和過時時間標記推斷出優先級和計算出timeout,
var currentTime = requestCurrentTimeForUpdate();
var priorityLevel = inferPriorityFromExpirationTime(currentTime, expirationTime);
if (expirationTime === Sync) {
    ...
} else {
    callbackNode = scheduleCallback(priorityLevel, performConcurrentWorkOnRoot.bind(null, root), 
    {
    timeout: expirationTimeToMs(expirationTime) - now()
    });
}
複製代碼

而後根據優先級、timeout, 結合執行工做的回調函數,新建一個任務(這裏就是scheduleCallback),

function unstable_scheduleCallback(priorityLevel, callback, options) {
    ...
    var expirationTime = startTime + timeout;
    var newTask = {
      id: taskIdCounter++,
      callback: callback,
      priorityLevel: priorityLevel,
      startTime: startTime,
      expirationTime: expirationTime,
      sortIndex: -1
    };
    ...
}
複製代碼

將該任務放入任務隊列中,調用DOM調度配置文件中的requestHostCallback,回調函數爲調度中心的清空任務方法。

push(taskQueue, newTask);
...
if (...) {
    ...
    requestHostCallback(flushWork);
 }
複製代碼

flushWork爲調度中心的清空任務方法,即將任務隊列中的任務執行後而後移除

運行任務

  1. requestHostCallback調用MessageChannel中的異步函數:port.postMessage(...)
var channel = new MessageChannel();
var port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
requestHostCallback = function (callback) {
  scheduledHostCallback = callback;

  if (...) {
    ...
    port.postMessage(null);
  }
};
複製代碼

從而異步執行以前另外一個端口port1訂閱的方法,在該方法中,執行requestHostCallback的回調函數,即調度中心的清空任務方法。

var performWorkUntilDeadline = function () {
    ...
    var hasMoreWork = scheduledHostCallback(...);
}
複製代碼

2.清空任務方法中,會執行調度中心的工做循環,循環執行任務隊列中的任務。

function flushWork(...) {
    ...
    return workLoop(...);
    ...
}
複製代碼

有趣的是,工做循環並非執行完一次任務中的回調函數就繼續執行下一個任務的回調函數,而是執行完一個任務中的回調函數後,檢測其是否返回函數。若返回,則將其做爲任務新的回調函數,繼續進行工做循環;若未返回,則執行下一個任務的回調函數。

function workLoop(...) {
    ...
    while (currentTask !== null && ...) {
        var callback = currentTask.callback;
        if (callback !== null) {
            currentTask.callback = null;
            ...
            var continuationCallback = callback(didUserCallbackTimeout)
            if (typeof continuationCallback === 'function') {
                currentTask.callback = continuationCallback;
                ...
            }    
        } else {
            pop(taskQueue)
        }
        currentTask = peek(taskQueue);
    }
    ...
}
複製代碼

而且工做循環中也在檢查5毫秒時間切片是否到期,到期則從新調port.postMessage(...)

while(currentTask !== null && ...) {
    ...
    if (... && (... || shouldYieldToHost())) {
        break;
    }
    ...
}
if (currentTask !== null) {
    return true;
}
複製代碼
var hasMoreWork = scheduledHostCallback(...);

if (!hasMoreWork) {
    ...
} else {
  port.postMessage(null);
}
複製代碼
  1. 任務的回調函數是一個執行同時模式下root工做的方法。執行該方法時將循環執行工做中fiber,一樣使用5毫秒左右的時間切片進行計算和diff,5毫秒時間切片過時後就會返回其自身。
function performConcurrentWorkOnRoot(...) {
    ...
    do {
    try {
      workLoopConcurrent();
      break;
    } catch (...) {
      ...
    }
    } while (true);
    ...
    return performConcurrentWorkOnRoot.bind(...);
}
複製代碼

完成任務

  1. 在執行完全部工做中fiber後,React進入提交步驟,更新DOM。
finishConcurrentRender(...)-->commitRoot(...)-->commitRootImpl(...)
複製代碼
  1. 任務的回調函數返回空值,調度工做循環所以(運行任務步驟中第二點:若任務的回調函數執行後返回爲空,則執行下一個任務)完成此任務,並將此任務從任務隊列中刪除。
function performConcurrentWorkOnRoot() {
    ...
    if (workInProgress !== null) { ... }
    else {
        ...
        finishConcurrentRender(root, finishedWork, workInProgressRootExitStatus, expirationTime);
    } 
    ...
    return null;
}
複製代碼
function workLoop(...) {
    ...
    while (currentTask !== null && ...) {
        var callback = currentTask.callback;
        if (callback !== null) {
            currentTask.callback = null;
            ...
            var continuationCallback = callback(didUserCallbackTimeout)
            if (typeof continuationCallback === 'function') {
                currentTask.callback = continuationCallback;
                ...
            }    
        } else {
            pop(taskQueue)
        }
        currentTask = peek(taskQueue);
    }
    ...
}
複製代碼

高優先級插隊

  1. 延用上面的高優先級插隊案例,從觸發高優先級點擊事件(準備插隊)開始。

觸發點擊事件後,React會運行內部的合成事件相關代碼,而後執行一個執行優先級的方法,優先級參數爲「用戶交互UserBlockingPriority」,接着進行setState操做。

onClick --> discreteUpdates 
--> runWithPriority(UserBlockingPriority, ...)
-->setState
複製代碼

setState的關聯方法新建一個更新,計算當前的過時時間標記,而後開始安排工做。

enqueueSetState: function (...) {
    ...
    var expirationTime = computeExpirationForFiber(...);
    var update = createUpdate(...);
    ...
    enqueueUpdate(fiber, update);
    scheduleWork(fiber, expirationTime);
}
複製代碼
  1. 在安排工做方法中,運行確保root被安排任務的方法。由於如今的優先級更高且過時時間標記不一樣,調度中心取消對以前低優先級任務的安排,並將以前低優先級任務的回調置空,確保它以後不會被執行(調度中心工做循環根據當前的任務的回調函數是否爲空決定是否繼續執行該任務)。
function ensureRootIsScheduled(...) {
     if (existingCallbackNode !== null) {
         ...
         cancelCallback(existingCallbackNode);
     }
     ...
}
複製代碼
function unstable_cancelCallback(task) {
    ...
    task.callback = null;
}
複製代碼

而後調度中心根據高優先級更新對應的優先級、過時時間標記、timeout等建立新的任務。

var expirationTime = startTime + timeout;
var newTask = {
  ...
  callback: callback,
  priorityLevel: priorityLevel,
  startTime: startTime,
  expirationTime: expirationTime,
  ...
};
複製代碼
  1. 執行高優先級任務,當執行到開始計算工做中類Fiber(class ConcurrentSchedulingExample),執行更新隊列方法時,React將循環遍歷工做中類fiber的更新環狀鏈表。當循環到以前低優先級任務對應更新時,由於低優先級過時時間標記小於當前渲染過時時間標記,故將該低優先級過時時間標記設爲工做中類fiber的過時時間標記(其餘狀況會將工做中類fiber的過時時間標記設爲0)。此處是以後恢復低優先級的關鍵所在。
function processUpdateQueue(...) {
    ...
    var newExpirationTime = NoWork;
    ...
    if (updateExpirationTime < renderExpirationTime) {
        if (updateExpirationTime > newExpirationTime) {
            newExpirationTime = updateExpirationTime;
        }
    } else { ... }
    ...
    workInProgress.expirationTime = newExpirationTime
    ...
}
複製代碼

NoWork0

  1. 在完成優先級任務過程的提交渲染DOM步驟中,渲染DOM後,會將root的callbackNode(其名字容易誤導其功能,其實就是調度任務,用callbackTask或許更合適)設爲空值。
function commitRootImpl(...) {
    ...
    root.callbackNode = null;
    ...
}
複製代碼

在接下來執行確保root被安排任務的方法中,由於下一次過時時間標記不爲空(根本緣由就是上面第二點提到工做中類fiber的過時時間標記被設置爲低優先級過時時間標記)且root的callbackNode爲空值,因此建立新的任務,即從新建立一個新的低優先級任務。並將任務放入任務列表中。

function ensureRootIsScheduled(...) {
    var expirationTime = getNextRootExpirationTimeToWorkOn(...);
    if (expirationTime === NoWork) { ... return }
    if (expirationTime === Sync) { ... }
    else {
        callbackNode = scheduleCallback(priorityLevel, performConcurrentWorkOnRoot.bind(null, root), 
      {
        timeout: expirationTimeToMs(expirationTime) - now()
      });
    }
}
複製代碼
function unstable_scheduleCallback(priorityLevel, callback, options) {
    ...
    var expirationTime = startTime + timeout;
    var newTask = {
      ...
      callback: callback,
      priorityLevel: priorityLevel,
      startTime: startTime,
      expirationTime: expirationTime,
     ...
    };
    ...
    push(taskQueue, newTask);
    ...
}
複製代碼
  1. 從新執行低優先級任務。此處須要注意是從新執行而不是從以前中斷的地方繼續執行。畢竟React計算過程當中只有當前fiber樹和工做中fiber樹,執行高優先級時,工做中fiber樹已經被更新,因此恢復低優先級任務必定是從新完整執行一遍。

最後寫點什麼

這次閱讀源碼的一些心得:

  1. 先自上而下,再自下而上。

自上而下是先了解源碼的總體結構,總的執行流程是怎樣,再一層一層往下研究。而自下而上是着重研究某個功能的細節,弄懂細節以後再研究其上層。

  1. 面向問題看源碼。

在研究某個功能時,先提出問題,再研究源碼解決問題。不過如有問題嘗試好久都沒法解決,能夠先放下,繼續研究其餘問題,以後再回來解決。

  1. 調試源碼。

對於很是簡單的功能,通常只看源碼就能弄懂。但其餘功能,每每只有通過調試才能能驗證和推理,從而真正弄懂。下一篇會寫如何搭建支持全部React版本斷點調試細分文件的React源碼調試環境。

感謝你花時間閱讀這篇文章。若是你喜歡這篇文章,歡迎點贊、收藏和分享,讓更多的人看到這篇文章,這也是對我最大的鼓勵和支持!

歡迎經過微信(掃描下方二維碼)或Github訂閱個人博客。

微信公衆號:蘇溪雲的博客
相關文章
相關標籤/搜索