寫給本身看的React源碼解析(三):Fiber架構下更新鏈路以及Concurrent模式實現原理

前言

本文內容涉及到不少渲染鏈路中的原理以及源碼方法,因此在看本文以前,須要對於Reactrender渲染流程有大體的瞭解。不清楚的同窗能夠先看個人第一篇源碼解析文章。react

寫給本身看的React源碼解析(一):你的React代碼是怎麼渲染成DOM的? git

本文主要解析fiber架構更新鏈路的雙緩衝模式以及Concurrent模式下時間切片優先級的實現原理。github

雙緩衝

雙緩衝模式的主要用處,是可以幫咱們較大限度地實現Fiber節點的複用,減小性能方面的開銷。瀏覽器

在以前的文章中,咱們知道在首次渲染的時候會建立出兩顆樹,current樹與workInProgress樹。current樹與workInProgress樹,其實就是兩套緩衝數據:當current樹被渲染到頁面上時,全部的數據更新都會由workInProgress樹來承接。workInProgress樹將會在內存裏悄悄地完成全部改變,直到下次進行渲染的commit階段執行完畢以後,fiberRoot對象的current會指向workInProgress樹,workInProgress樹就會變成渲染到頁面上的current樹。性能優化

咱們用一個實際例子來幫助理解:markdown

import { useState } from 'react';
function App() {
  const [state, setState] = useState(0)
  return (
    <div className="App"> <div onClick={() => { setState(state + 1) }}> <p>{state}</p> </div> </div>
  );
}
複製代碼

初始化

這個例子的功能很簡單,就是點擊一次,數字加1。上面的demo在render階段結束後,commit階段結束前的兩顆fiber樹以下圖所示架構

commit階段完成,workInProgress樹被渲染到頁面上,這時候fiberRoot對象的current會指向workInProgress樹,這個當前被渲染的fiber樹。app

第一次更新

點擊一次數字,咱們進入第一次的更新流程。重點看beginWork調用鏈路中的createWorkInProgress方法。異步

上圖中,workInProgress樹下面的子節點的current.alternate對應的就是current樹的子節點,可是current樹目前沒有子節點,因此爲null,進入等於null的流程。按照workInProgress的子節點的屬性給current樹建立出相同的子節點。ide

而後在commit階段結束後,current樹會被渲染到頁面上,fiberRoot對象的current會指回到current樹,具體以下圖

第二次更新

再點擊一次數字,觸發state的第二次更新,仍是看以前的createWorkInProgress方法。

這時候,由於兩顆樹都已經構建完成,因此current.alternate是存在的。因此以後每次經過beginWork 觸發createWorkInProgress調用時,都會一致地走入else裏面的邏輯,也就是直接複用現成的節點。 這也就是雙緩衝機制實現節點複用的方法。

更新鏈路要素

React源碼解析第一篇分析了首次渲染的鏈路,更新的鏈路其實跟首次渲染大體同樣。

首次渲染能夠理解爲一種特殊的更新,ReactDOM.render,setState,useState同樣,都是一種觸發更新的姿式。這些方法發起的調用鏈路很類似,是由於它們最後「異曲同工」,都會經過建立update對象來進入同一套更新工做流。

按demo的流程來,點擊數字以後,會觸發一個dispatchAction方法,在該方法中,會完成update對象的建立

update建立完成以後,會跟首次渲染同樣,進入updateContainer方法(首次渲染鏈路中的update會在這個方法裏建立),這裏主要是兩個方法

enqueueUpdate(current, update);
scheduleUpdateOnFiber(current, lane, eventTime);
複製代碼
  • enqueueUpdate:將update入隊。每個Fiber節點都會有一個屬於它本身的updateQueue,用於存儲多個更新,這個updateQueue是以鏈表的形式存在的。在render階段,updateQueue的內容會成爲 render階段計算Fiber節點的新state的依據。

  • scheduleUpdateOnFiber:調度update。這個方法後面緊跟的就是performSyncWorkOnRoot所觸發的render階段。

這裏有一個點須要提示一下:dispatchAction中,調度的是當前觸發更新的節點,這一點和掛載過程須要區分開來。在掛載過程當中,updateContainer會直接調度根節點。其實,對於更新這種場景來講,大部分的更新動做確實都不是由根節點觸發的,而render階段的起點則是根節點。因此在scheduleUpdateOnFiber 中,有這樣一個方法

它會從當前Fiber節點開始,向上遍歷直至根節點,並將根節點返回。因此,咱們說React的更新流程,是從根節點開始,從新遍歷整個fiber樹,這也是爲何咱們平時的性能優化的重點都在減小組件的從新render上。

scheduleUpdateOnFiber中,還有一個重要的判斷,那就是對於同步和異步的判斷邏輯。

以前咱們分析同步的首次渲染流程的時候,走的是performSyncWorkOnRoot方法,可是對於異步模式,會運行ensureRootIsScheduled方法。來看下一段核心邏輯

if (newCallbackPriority === SyncLanePriority) {
    // 同步更新的 render 入口
    newCallbackNode = scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
  } else {
    // 將當前任務的 lane 優先級轉換爲 scheduler 可理解的優先級
    var schedulerPriorityLevel = lanePriorityToSchedulerPriority(newCallbackPriority);
    // 異步更新的 render 入口
    newCallbackNode = scheduleCallback(schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root));
  }
複製代碼

從這段邏輯中咱們能夠看出,React會以當前更新任務的優先級類型爲依據,決定接下來是調度 performSyncWorkOnRoot仍是performConcurrentWorkOnRoot。這裏調度任務用到的函數分別是 scheduleSyncCallbackscheduleCallback,這兩個函數在內部都是經過調用 unstable_scheduleCallback方法來執行任務調度的。這個方法是Scheduler(調度器)中導出的一個核心方法。

Scheduler的核心能力,就是讓fiber架構實現了時間切片優先級調度這兩個核心特徵。

時間切片

先來了解一下時間切片究竟是作了什麼事情?

import React from 'react';
function App() {
  const arr = new Array(1000).fill(0);
  return (
    <div className="App"> <div className="container"> { arr.map((i, index) => <p>{`測試文本第${index}行`}</p>) } </div> </div>
  );
}
複製代碼

上面的代碼就是渲染1000條p標籤到頁面上,當咱們使用ReactDOM.render進行渲染,由於它是一個同步的過程,全部的鏈路都會在一個宏任務裏執行掉。根據不一樣用戶電腦和瀏覽器的性能不一樣,這個宏任務的執行時間,多是100ms、200ms、300ms甚至更多。由於js線程和渲染線程是互斥的,在執行這個比較長時間的宏任務時,咱們瀏覽器的渲染線程將被阻塞。咱們知道瀏覽器的刷新頻率爲60Hz也就是說每16.6ms就會刷新一次,這種長時間的宏任務致使的渲染線程阻塞,將會產生明顯的卡頓、掉幀。

而時間切片,就是把這段須要較長時間運行的宏任務「切」開,變成一段段儘可能保證運行時間在瀏覽器刷新間隔時間之下的宏任務。給渲染線程留出時間,保證渲染的流暢度。咱們來看兩張圖,第一張是同步模式下的調用棧

下一張是把ReactDOM.render調用改成createRoot,用Concurrent(異步)模式來進行渲染

咱們能夠看到,原本一個長時間的「大任務」被切成了一個個短期的「小任務」。

時間切片是如何實現的?

根據上文對scheduleUpdateOnFiber的分析,在同步的模式下,React會調用performSyncWorkOnRoot,在這個鏈路下,會經過workLoopSync方法來循環建立Fiber節點、構建Fiber樹。

function workLoopSync() {
  // 若 workInProgress 不爲空
  while (workInProgress !== null) {
    // 針對它執行 performUnitOfWork 方法
    performUnitOfWork(workInProgress);
  }
}
複製代碼

這是一個沒法中斷的過程,開始了就沒法中止。

而在異步的模式下,React會調用performConcurrentWorkOnRoot,經過renderRootConcurrent調用 workLoopConcurrent來構建Fiber樹。

function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}
複製代碼

咱們能夠發現,異步的方法裏,其實就只是多了一個shouldYield()方法,當shouldYield()true的時候,while循環將中止,將主線程讓給渲染線程。

shouldYield的本體其實也是調度器裏導出的一個方法Scheduler.unstable_shouldYield,方法很簡單。源碼地址

export function unstable_shouldYield() {
  return getCurrentTime() >= deadline;
}
複製代碼

就是噹噹前時間大於deadline這個當前時間切片的到期時間時,就返回true,中止workLoopConcurrent循環。

咱們來看下deadline是怎麼定義的

deadline = getCurrentTime() + yieldInterval;
複製代碼

getCurrentTime()就是當前時間,而yieldInterval是一個常量,5ms,源碼地址

const yieldInterval = 5;
複製代碼

因此說,時間切片的間隔是5ms(實際應該都是比5ms稍大,由於必須等當前的fiber節點構建完成以後,纔會經過shouldYield()方法判斷是否到期)

workLoopConcurrent循環中斷以後,React會從新發起調度(setTimeout或者MessageChannel方式),檢查是否存在事件響應、更高優先級任務或其餘代碼須要執行,若是有則執行,若是沒有則從新建立工做循環workLoopConcurrent,執行剩下的工做中Fiber節點構建。

優先級調度

在更新鏈路中,不管是scheduleSyncCallback仍是scheduleCallback,最終都是經過調用 unstable_scheduleCallback來發起調度的。 unstable_scheduleCallbackScheduler導出的一個核心方法,它將結合任務的優先級信息爲其執行不一樣的調度邏輯。源碼地址

function unstable_scheduleCallback(priorityLevel, callback, options) {
  // 獲取當前時間
  var currentTime = getCurrentTime();
  // 聲明 startTime,startTime 是任務的預期開始時間
  var startTime;
  // 如下是對 options 入參的處理
  if (typeof options === 'object' && options !== null) {
    var delay = options.delay;
    // 若入參規定了延遲時間,則累加延遲時間
    if (typeof delay === 'number' && delay > 0) {
      startTime = currentTime + delay;
    } else {
      startTime = currentTime;
    }
  } else {
    startTime = currentTime;
  }
  // timeout 是 expirationTime 的計算依據
  var timeout;
  // 根據 priorityLevel,肯定 timeout 的值
  switch (priorityLevel) {
    case ImmediatePriority:
      timeout = IMMEDIATE_PRIORITY_TIMEOUT;
      break;
    case UserBlockingPriority:
      timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
      break;
    case IdlePriority:
      timeout = IDLE_PRIORITY_TIMEOUT;
      break;
    case LowPriority:
      timeout = LOW_PRIORITY_TIMEOUT;
      break;
    case NormalPriority:
    default:
      timeout = NORMAL_PRIORITY_TIMEOUT;
      break;
  }
  // 優先級越高,timout 越小,expirationTime 越小
  var expirationTime = startTime + timeout;
  // 建立 task 對象
  var newTask = {
    id: taskIdCounter++,
    callback,
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
  };
  if (enableProfiling) {
    newTask.isQueued = false;
  }
  // 若當前時間小於開始時間,說明該任務可延時執行(未過時)
  if (startTime > currentTime) {
    // 將未過時任務推入 "timerQueue"
    newTask.sortIndex = startTime;
    push(timerQueue, newTask);
    
    // 若 taskQueue 中沒有可執行的任務,而當前任務又是 timerQueue 中的第一個任務
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
      // All tasks are delayed, and this is the task with the earliest delay.
      if (isHostTimeoutScheduled) {
        // Cancel an existing timeout.
        cancelHostTimeout();
      } else {
        isHostTimeoutScheduled = true;
      }
      // 那麼就派發一個延時任務,這個延時任務用於檢查當前任務是否過時
      requestHostTimeout(handleTimeout, startTime - currentTime);
    }
  } else {
    // else 裏處理的是當前時間大於 startTime 的狀況,說明這個任務已過時
    newTask.sortIndex = expirationTime;
    // 過時的任務會被推入 taskQueue
    push(taskQueue, newTask);
    
    ......
    
    // 執行 taskQueue 中的任務
    requestHostCallback(flushWork);
  }
  return newTask;
}
複製代碼

unstable_scheduleCallback的主要工做是針對當前任務建立一個task,而後結合startTime信息將這個task推入timerQueuetaskQueue,最後根據timerQueuetaskQueue的狀況,執行延時任務或即時任務。

這裏須要知道幾個概念

  • startTime:任務的開始時間。
  • expirationTime:這是一個和優先級相關的值,expirationTime 越小,任務的優先級就越高。
  • timerQueue:一個以 startTime 爲排序依據的小頂堆,它存儲的是 startTime 大於當前時間(也就是待執行)的任務。
  • taskQueue:一個以 expirationTime 爲排序依據的小頂堆,它存儲的是 startTime 小於當前時間(也就是已過時)的任務。

堆是一種特殊的徹底二叉樹。若是對一棵徹底二叉樹來講,它每一個結點的結點值都不大於其左右孩子的結點值,這樣的徹底二叉樹就叫「小頂堆」。小頂堆自身特有的插入和刪除邏輯,決定了不管咱們怎麼增刪小頂堆的元素,其根節點必定是全部元素中值最小的一個節點。

咱們來看下核心邏輯

if (startTime > currentTime) {
    // 將未過時任務推入 "timerQueue"
    newTask.sortIndex = startTime;
    push(timerQueue, newTask);
    
    // 若 taskQueue 中沒有可執行的任務,而當前任務又是 timerQueue 中的第一個任務
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
      ...... 
      
      // 那麼就派發一個延時任務,這個延時任務用於檢查當前任務是否過時
      requestHostTimeout(handleTimeout, startTime - currentTime);
    }
  } else {
    // else 裏處理的是當前時間大於 startTime 的狀況,說明這個任務已過時
    newTask.sortIndex = expirationTime;
    // 過時的任務會被推入 taskQueue
    push(taskQueue, newTask);
    
    ......
    
    // 執行 taskQueue 中的任務
    requestHostCallback(flushWork);
  }
複製代碼

若判斷當前任務是未過時任務,那麼該任務會在sortIndex屬性被賦值爲startTime後,被推入timerQueuetaskQueue裏存儲的是已過時的任務,peek(taskQueue) 取出的任務若爲空,則說明taskQueue爲空、當前並無已過時任務。在沒有已過時任務的狀況下,若當前任務(newTask)就是timerQueue中須要最先被執行的未過時任務,那麼unstable_scheduleCallback會經過調用requestHostTimeout,爲當前任務發起一個延時調用。

注意,這個延時調用(也就是handleTimeout)並不會直接調度執行當前任務——它的做用是在當前任務到期後,將其從 timerQueue中取出,加入taskQueue中,而後觸發對flushWork的調用。真正的調度執行過程是在flushWork中進行的。flushWork中將調用workLoopworkLoop會逐一執行taskQueue中的任務,直到調度過程被暫停(時間片用盡,將從新發起Task調度)或任務所有被清空。

當下React發起Task調度的姿式有兩個:setTimeoutMessageChannel。在宿主環境不支持MessageChannel的狀況下,會降級到setTimeout。但不論是setTimeout仍是MessageChannel,它們發起的都是異步任務(宏任務,將在下次eventLoop中被調用)。

感謝

若是本文對你有所幫助,請幫忙點個贊,感謝!

相關文章
相關標籤/搜索