React 架構的演變 - 從遞歸到循環

這篇文章是 React 架構演變的第二篇,上一篇主要介紹了更新機制從同步修改成異步,這一篇重點介紹 Fiber 架構下經過循環遍歷更新的過程,之因此要使用循環遍歷的方式,是由於遞歸更新過程一旦開始就不能暫停,只能不斷向下,直到遞歸結束或者出現異常。html

遞歸更新的實現

React 15 的遞歸更新邏輯是先將須要更新的組件放入髒組件隊列(這裏在上篇文章已經介紹過,沒看過的能夠先看看《React 架構的演變 - 從同步到異步》),而後取出組件進行一次遞歸,不停向下尋找子節點來查找是否須要更新。react

下面使用一段代碼來簡單描述一下這個過程:瀏覽器

updateComponent (prevElement, nextElement) {
  if (
        // 若是組件的 type 和 key 都沒有發生變化,進行更新
    prevElement.type === nextElement.type &&
    prevElement.key === nextElement.key
  ) {
    // 文本節點更新
    if (prevElement.type === 'text') {
        if (prevElement.value !== nextElement.value) {
            this.replaceText(nextElement.value)
        }
    }
    // DOM 節點的更新
    else {
      // 先更新 DOM 屬性
      this.updateProps(prevElement, nextElement)
      // 再更新 children
      this.updateChildren(prevElement, nextElement)
    }
  }
  // 若是組件的 type 和 key 發生變化,直接從新渲染組件
  else {
    // 觸發 unmount 生命週期
    ReactReconciler.unmountComponent(prevElement)
    // 渲染新的組件
    this._instantiateReactComponent(nextElement)
  }
},
updateChildren (prevElement, nextElement) {
  var prevChildren = prevElement.children
  var nextChildren = nextElement.children
  // 省略經過 key 從新排序的 diff 過程
  if (prevChildren === null) { } // 渲染新的子節點
  if (nextChildren === null) { } // 清空全部子節點
  // 子節點對比
  prevChildren.forEach((prevChild, index) => {
    const nextChild = nextChildren[index]
    // 遞歸過程
    this.updateComponent(prevChild, nextChild)
  })
}

爲了更清晰的看到這個過程,咱們仍是寫一個簡單的Demo,構造一個 3 * 3 的 Table 組件。數據結構

Table

// https://codesandbox.io/embed/react-sync-demo-nlijf
class Col extends React.Component {
  render() {
    // 渲染以前暫停 8ms,給 render 製造一點點壓力
    const start = performance.now()
    while (performance.now() - start < 8)
    return <td>{this.props.children}</td>
  }
}

export default class Demo extends React.Component {
  state = {
    val: 0
  }
  render() {
    const { val } = this.state
    const array = Array(3).fill()
    // 構造一個 3 * 3 表格
    const rows = array.map(
      (_, row) => <tr key={row}>
        {array.map(
          (_, col) => <Col key={col}>{val}</Col>
        )}
      </tr>
    )
    return (
      <table className="table">
        <tbody>{rows}</tbody>
      </table>
    )
  }
}

而後每秒對 Table 裏面的值更新一次,讓 val 每次 + 1,從 0 ~ 9 不停循環。架構

Table Loop

// https://codesandbox.io/embed/react-sync-demo-nlijf
export default class Demo extends React.Component {
    tick = () => {
    setTimeout(() => {
      this.setState({ val: next < 10 ? next : 0 })
      this.tick()
    }, 1000)
  }
  componentDidMount() {
    this.tick()
  }
}

完整代碼的線上地址: https://codesandbox.io/embed/react-sync-demo-nlijf。Demo 組件每次調用 setState,React 會先判斷該組件的類型有沒有發生修改,若是有就整個組件進行從新渲染,若是沒有會更新 state,而後向下判斷 table 組件,table 組件繼續向下判斷 tr 組件,tr 組件再向下判斷 td 組件,最後發現 td 組件下的文本節點發生了修改,經過 DOM API 更新。異步

Update

經過 Performance 的函數調用堆棧也能清晰的看到這個過程,updateComponent 以後 的 updateChildren 會繼續調用子組件的 updateComponent,直到遞歸完全部組件,表示更新完成。async

調用堆棧

遞歸的缺點很明顯,不能暫停更新,一旦開始必須從頭至尾,這與 React 16 拆分時間片,給瀏覽器喘口氣的理念明顯不符,因此 React 必需要切換架構,將虛擬 DOM 從樹形結構修改成鏈表結構。函數

可循環的 Fiber

這裏說的鏈表結構就是 Fiber 了,鏈表結構最大的優點就是能夠經過循環的方式來遍歷,只要記住當前遍歷的位置,即便中斷後也能快速還原,從新開始遍歷。oop

咱們先看看一個 Fiber 節點的數據結構:post

function FiberNode (tag, key) {
  // 節點 key,主要用於了優化列表 diff
  this.key = key
  // 節點類型;FunctionComponent: 0, ClassComponent: 1, HostRoot: 3 ...
  this.tag = tag

    // 子節點
  this.child = null
  // 父節點
  this.return = null 
  // 兄弟節點
  this.sibling = null
  
  // 更新隊列,用於暫存 setState 的值
  this.updateQueue = null
  
  // 節點更新過時時間,用於時間分片
  // react 17 改成:lanes、childLanes
  this.expirationTime = NoLanes
  this.childExpirationTime = NoLanes

  // 對應到頁面的真實 DOM 節點
  this.stateNode = null
  // Fiber 節點的副本,能夠理解爲備胎,主要用於提高更新的性能
  this.alternate = null
}

下面舉個例子,咱們這裏有一段普通的 HTML 文本:

<table class="table">
  <tr>
    <td>1</td>
    <td>1</td>
  </tr>
  <tr>
    <td>1</td>
  </tr>
</table>

在以前的 React 版本中,jsx 會轉化爲 createElement 方法,建立樹形結構的虛擬 DOM。

const VDOMRoot = {
  type: 'table',
  props: { className: 'table' },
  children: [
    {
      type: 'tr',
      props: { },
      children: [
        {
          type: 'td',
          props: { },
          children: [{type: 'text', value: '1'}]
        },
        {
          type: 'td',
          props: { },
          children: [{type: 'text', value: '1'}]
        }
      ]
    },
    {
      type: 'tr',
      props: { },
      children: [
        {
          type: 'td',
          props: { },
          children: [{type: 'text', value: '1'}]
        }
      ]
    }
  ]
}

Fiber 架構下,結構以下:

// 有所簡化,並不是與 React 真實的 Fiber 結構一致
const FiberRoot = {
  type: 'table',
  return: null,
  sibling: null,
  child: {
    type: 'tr',
    return: FiberNode, // table 的 FiberNode
    sibling: {
      type: 'tr',
      return: FiberNode, // table 的 FiberNode
      sibling: null,
      child: {
        type: 'td',
        return: FiberNode, // tr 的 FiberNode
        sibling: {
          type: 'td',
          return: FiberNode, // tr 的 FiberNode
          sibling: null,
          child: null,
          text: '1' // 子節點僅有文本節點
        },
        child: null,
        text: '1' // 子節點僅有文本節點
      }
    },
    child: {
      type: 'td',
      return: FiberNode, // tr 的 FiberNode
      sibling: null,
      child: null,
      text: '1' // 子節點僅有文本節點
    }
  }
}

Fiber

循環更新的實現

那麼,在 setState 的時候,React 是如何進行一次 Fiber 的遍歷的呢?

let workInProgress = FiberRoot

// 遍歷 Fiber 節點,若是時間片時間用完就中止遍歷
function workLoopConcurrent() {
  while (
    workInProgress !== null &&
    !shouldYield() // 用於判斷當前時間片是否到期
  ) {
    performUnitOfWork(workInProgress)
  }
}

function performUnitOfWork() {
  const next = beginWork(workInProgress) // 返回當前 Fiber 的 child
  if (next) { // child 存在
    // 重置 workInProgress 爲 child
    workInProgress = next
  } else { // child 不存在
    // 向上回溯節點
    let completedWork = workInProgress
    while (completedWork !== null) {
      // 收集反作用,主要是用於標記節點是否須要操做 DOM
      completeWork(completedWork)

      // 獲取 Fiber.sibling
      let siblingFiber = workInProgress.sibling
      if (siblingFiber) {
        // sibling 存在,則跳出 complete 流程,繼續 beginWork
        workInProgress = siblingFiber
        return;
      }

      completedWork = completedWork.return
      workInProgress = completedWork
    }
  }
}

function beginWork(workInProgress) {
  // 調用 render 方法,建立子 Fiber,進行 diff
  // 操做完畢後,返回當前 Fiber 的 child
  return workInProgress.child
}
function completeWork(workInProgress) {
  // 收集節點反作用
}

Fiber 的遍歷本質上就是一個循環,全局有一個 workInProgress 變量,用來存儲當前正在 diff 的節點,先經過 beginWork 方法對當前節點而後進行 diff 操做(diff 以前會調用 render,從新計算 state、prop),並返回當前節點的第一個子節點( fiber.child)做爲新的工做節點,直到不存在子節點。而後,對當前節點調用 completedWork 方法,存儲 beginWork 過程當中產生的反作用,若是當前節點存在兄弟節點( fiber.sibling),則將工做節點修改成兄弟節點,從新進入 beginWork 流程。直到 completedWork 從新返回到根節點,執行 commitRoot 將全部的反作用反應到真實 DOM 中。

Fiber work loop

在一次遍歷過程當中,每一個節點都會經歷 beginWorkcompleteWork ,直到返回到根節點,最後經過 commitRoot 將全部的更新提交,關於這部分的內容能夠看:《React 技術揭祕》

時間分片的祕密

前面說過,Fiber 結構的遍歷是支持中斷恢復,爲了觀察這個過程,咱們將以前的 3 * 3 的 Table 組件改爲 Concurrent 模式,線上地址:https://codesandbox.io/embed/react-async-demo-h1lbz。因爲每次調用 Col 組件的 render 部分須要耗時 8ms,會超出了一個時間片,因此每一個 td 部分都會暫停一次。

class Col extends React.Component {
  render() {
    // 渲染以前暫停 8ms,給 render 製造一點點壓力
    const start = performance.now();
    while (performance.now() - start < 8);
    return <td>{this.props.children}</td>
  }
}

在這個 3 * 3 組件裏,一共有 9 個 Col 組件,因此會有 9 次耗時任務,分散在 9 個時間片進行,經過 Performance 的調用棧能夠看到具體狀況:

異步模式的調用棧

在非 Concurrent 模式下,Fiber 節點的遍歷是一次性進行的,並不會切分多個時間片,差異就是在遍歷的時候調用了 workLoopSync 方法,該方法並不會判斷時間片是否用完。

// 遍歷 Fiber 節點
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress)
  }
}

同步模式的調用棧

經過上面的分析能夠看出, shouldYield 方法決定了當前時間片是否已經用完,這也是決定 React 是同步渲染仍是異步渲染的關鍵。若是去除任務優先級的概念,shouldYield 方法能夠說很簡單,就是判斷了當前的時間,是否已經超過了預設的 deadline

function getCurrentTime() {
  return performance.now()
}
function shouldYield() {
  // 獲取當前時間
  var currentTime = getCurrentTime()
  return currentTime >= deadline
}

deadline 又是如何得的呢?能夠回顧上一篇文章(《React 架構的演變 - 從同步到異步》)提到的 ChannelMessage,更新開始的時候會經過 requestHostCallback(即:port2.send)發送異步消息,在 performWorkUntilDeadline (即:port1.onmessage)中接收消息。performWorkUntilDeadline 每次接收到消息時,表示已經進入了下一個任務隊列,這個時候就會更新 deadline

異步調用棧

var channel = new MessageChannel()
var port = channel.port2
channel.port1.onmessage = function performWorkUntilDeadline() {
  if (scheduledHostCallback !== null) {
    var currentTime = getCurrentTime()
    // 重置超時時間 
    deadline = currentTime + yieldInterval
    
    var hasTimeRemaining = true
    var hasMoreWork = scheduledHostCallback()

    if (!hasMoreWork) {
      // 已經沒有任務了,修改狀態 
      isMessageLoopRunning = false;
      scheduledHostCallback = null;
    } else {
      // 還有任務,放到下個任務隊列執行,給瀏覽器喘息的機會 
      port.postMessage (null);
    }
  } else {
    isMessageLoopRunning = false;
  }
}

requestHostCallback = function (callback) {
  //callback 掛載到 scheduledHostCallback
  scheduledHostCallback = callback
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true
    // 推送消息,下個隊列隊列調用 callback
    port.postMessage (null)
  }
}

超時時間的設置就是在當前時間的基礎上加上了一個 yieldInterval, 這個 yieldInterval 的值,默認是 5ms。

deadline = currentTime + yieldInterval

同時 React 也提供了修改 yieldInterval 的手段,經過手動指定 fps,來肯定一幀的具體時間(單位:ms),fps 越高,一個時間分片的時間就越短,對設備的性能要求就越高。

forceFrameRate = function (fps) {
  if (fps < 0 || fps > 125) {
    // 幀率僅支持 0~125
    return
  }

  if (fps > 0) {
    // 通常 60 fps 的設備
    // 一個時間分片的時間爲 Math.floor(1000/60) = 16
    yieldInterval = Math.floor(1000 / fps)
  } else {
    // reset the framerate
    yieldInterval = 5
  }
}

總結

下面咱們將異步邏輯、循環更新、時間分片串聯起來。先回顧一下以前的文章講過,Concurrent 模式下,setState 後的調用順序:

Component.setState()
  => enqueueSetState()
    => scheduleUpdate()
  => scheduleCallback(performConcurrentWorkOnRoot)
  => requestHostCallback()
  => postMessage()
  => performWorkUntilDeadline()

scheduleCallback 方法會將傳入的回調(performConcurrentWorkOnRoot)組裝成一個任務放入 taskQueue 中,而後調用 requestHostCallback 發送一個消息,進入異步任務。performWorkUntilDeadline 接收到異步消息,從 taskQueue 取出任務開始執行,這裏的任務就是以前傳入的 performConcurrentWorkOnRoot 方法,這個方法最後會調用workLoopConcurrentworkLoopConcurrent 前面已經介紹過了,這個再也不重複)。若是 workLoopConcurrent 是因爲超時中斷的,hasMoreWork 返回爲 true,經過 postMessage 發送消息,將操做延遲到下一個任務隊列。

流程圖

到這裏整個流程已經結束,但願你們看完文章能有所收穫,下一篇文章會介紹 Fiber 架構下 Hook 的實現。

image

相關文章
相關標籤/搜索