React16性能改善的原理(二)

前情提要

上一篇我們提到如果 setState 之后,虛擬 dom diff 比較耗時(shí),那么導(dǎo)致瀏覽器 FPS 降低,使得用戶(hù)覺(jué)得頁(yè)面卡頓。那么 react 新的調(diào)度算法就是把原本一次 diff 的過(guò)程切分到各個(gè)幀去執(zhí)行,使得瀏覽器在 diff 過(guò)程中也能響應(yīng)用戶(hù)事件。接下來(lái)我們具體分析下新的調(diào)度算法是怎么回事。

原虛擬DOM問(wèn)題

假設(shè)我們有一個(gè) react 應(yīng)用如下:

class App extends React.Component {
  render() {
    return (
      <div>
        <div>{this.props.name}</div>
        <ul>
          <li>{this.props.items[0]}</li>
          <li>{this.props.items[1]}</li>
        </ul>
      </div>
    );
  }
}

整個(gè) app 的虛擬 dom 大致是這樣的:

var rootHost = {
  type: 'div',
  children: [ {
    type: 'div',
    children: [ {type: 'text'} ]
  }. {
    type: 'ul',
    children: [
      { type: 'li', children:[ {type: 'text'} ] },
      { type: 'li', children:[ {type: 'text'} ] }
    ]
  } ]
}

當(dāng)更新發(fā)生 diff 兩棵新老虛擬 dom 樹(shù)的時(shí)候是遞歸的逐層比較(如下圖)。這個(gè)過(guò)程是一次完成的,如果要按上一篇我們說(shuō)的把 diff 過(guò)程切割成好多時(shí)間片來(lái)執(zhí)行,難度是如何記住狀態(tài)且恢復(fù)現(xiàn)場(chǎng)。譬如說(shuō)你 diff 到一半函數(shù)返回了,等下一個(gè)時(shí)間片繼續(xù) diff。如果只記住上次遞歸到哪個(gè)節(jié)點(diǎn),那么你只能順著他的 children 繼續(xù) diff,而它的兄弟節(jié)點(diǎn)就丟失了。如果要完美恢復(fù)現(xiàn)場(chǎng)保存的結(jié)構(gòu)估計(jì)得挺復(fù)雜。所以 react16 改造了虛擬dom的結(jié)構(gòu),引入了 fiber 的鏈表結(jié)構(gòu)。


image.png

現(xiàn)在解決方案 - fiber

fiber 節(jié)點(diǎn)相當(dāng)于以前的虛擬 dom 節(jié)點(diǎn),結(jié)構(gòu)如下:

const Fiber = {
  tag: HOST_COMPONENT,
  type: "div",
  return: parentFiber,
  child: childFiber,
  sibling: null,
  alternate: currentFiber,
  stateNode: document.createElement("div")| instance,
  props: { children: [], className: "foo"},
  partialState: null,
  effectTag: PLACEMENT,
  effects: []
};

先講重要的幾個(gè)屬性: return 存儲(chǔ)的是當(dāng)前節(jié)點(diǎn)的父節(jié)點(diǎn)(元素),child 存儲(chǔ)的是第一個(gè)子節(jié)點(diǎn)(元素),sibling 存儲(chǔ)的是他右邊第一個(gè)的兄弟節(jié)點(diǎn)(元素)。alternate 保存是當(dāng)更新發(fā)生時(shí)候同一個(gè)節(jié)點(diǎn)帶有新的 props 和 state 生成的新 fiber 節(jié)點(diǎn)。 那么虛擬 dom 的存儲(chǔ)結(jié)構(gòu)用鏈表的形式描述了整棵樹(shù)。


image.png

從頂層開(kāi)始左序深度優(yōu)先遍歷如下圖所示:


image.png

我們?cè)诒闅v dom 樹(shù) diff 的時(shí)候,即使中斷了,我們只需要記住中斷時(shí)候的那么一個(gè)節(jié)點(diǎn),就可以在下個(gè)時(shí)間片恢復(fù)繼續(xù)遍歷并 diff。這就是 fiber 數(shù)據(jù)結(jié)構(gòu)選用鏈表的一大好處。我先用文字大致描述下 fiber diff 算法的過(guò)程再來(lái)看代碼。從跟節(jié)點(diǎn)開(kāi)始遍歷,碰到一個(gè)節(jié)點(diǎn)和 alternate 比較并記錄下需要更新的東西,并把這些更新提交到當(dāng)前節(jié)點(diǎn)的父親。當(dāng)遍歷完這顆樹(shù)的時(shí)候,再通過(guò) return 回溯到根節(jié)點(diǎn)。這個(gè)過(guò)程中把所有的更新全部帶到根節(jié)點(diǎn),再一次更新到真實(shí)的 dom 中去。


image.png

從根節(jié)點(diǎn)開(kāi)始:

  1. div1 通過(guò) child 到 div2。
  2. div2 和自己的 alternate 比較完把更新 commit1 通過(guò) return 提交到 div1。
  3. div2 通過(guò) sibling 到 ul1。
  4. ul1 和自己的 alternate 比較完把更新 commit2 通過(guò) return 提交到 div1。
  5. ul1 通過(guò) child 到 li1。
  6. li1 和自己的 alternate 比較完把更新 commit3 通過(guò) return 提交到 ul1。
  7. li1 通過(guò) sibling 到 li2。
  8. li2 和自己的 alternate 比較完把更新 commit4 通過(guò) return 提交到 ul1。
  9. 遍歷完整棵樹(shù)開(kāi)始回溯,li2 通過(guò) return 回到 ul1。
  10. 把 commit3 和 commit4 通過(guò) return 提交到 div1。
  11. ul1 通過(guò) return 回到 div1。
  12. 獲取到所有更新 commit1-4,一次更新到真是的 dom 中去。

使用fiber算法更新的代碼實(shí)現(xiàn)

React.Component.prototype.setState = function( partialState, callback ) {
  updateQueue.pus( {
    stateNode: this,
    partialState: partialState
  } );
  requestIdleCallback(performWork); // 這里就開(kāi)始干活了
}

function performWork(deadline) {
  workLoop(deadline)
  if (nextUnitOfWork || updateQueue.length > 0) {
    requestIdleCallback(performWork) //繼續(xù)干
  }
}

setState 先把此次更新放到更新隊(duì)列 updateQueue 里面,然后調(diào)用調(diào)度器開(kāi)始做更新任務(wù)。performWork 先調(diào)用 workLoop 對(duì) fiber 樹(shù)進(jìn)行遍歷比較,就是我們上面提到的遍歷過(guò)程。當(dāng)此次時(shí)間片時(shí)間不夠遍歷完整個(gè) fiber 樹(shù),或者遍歷并比較完之后 workLoop 函數(shù)結(jié)束。接下來(lái)我們判斷下 fiber 樹(shù)是否遍歷完或者更新隊(duì)列 updateQueue 是否還有待更新的任務(wù)。如果有則調(diào)用 requestIdleCallback 在下個(gè)時(shí)間片繼續(xù)干活。nextUnitOfWork 是個(gè)全局變量,記錄 workLoop 遍歷 fiber 樹(shù)中斷在哪個(gè)節(jié)點(diǎn)。

function workLoop(deadline) {
  if (!nextUnitOfWork) {
    //一個(gè)周期內(nèi)只創(chuàng)建一次
    nextUnitOfWork = createWorkInProgress(updateQueue)
  }

  while (nextUnitOfWork && deadline.timeRemaining() > EXPIRATION_TIME) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
  }

  if (pendingCommit) {
    //當(dāng)全局 pendingCommit 變量被負(fù)值
    commitAllwork(pendingCommit)
  }
}

剛開(kāi)始遍歷的時(shí)候判斷全局變量 nextUnitOfWork 是否存在?如果存在表示上次任務(wù)中斷了,我們繼續(xù),如果不存在我們就從更新隊(duì)列里面取第一個(gè)任務(wù),并生成對(duì)應(yīng)的 fiber 根節(jié)點(diǎn)。接下來(lái)我們就是正式的工作了,用循環(huán)從某個(gè)節(jié)點(diǎn)開(kāi)始遍歷 fiber 樹(shù)。performUnitOfWork 根據(jù)我們上面提到的遍歷規(guī)則,在對(duì)當(dāng)前節(jié)點(diǎn)處理完之后,返回下一個(gè)需要遍歷的節(jié)點(diǎn)。循環(huán)除了要判斷是否有下一個(gè)節(jié)點(diǎn)(是否遍歷完),還要判斷當(dāng)前給你的時(shí)間是否用完,如果用完了則需要返回,讓瀏覽器響應(yīng)用戶(hù)的交互事件,然后再在下個(gè)時(shí)間片繼續(xù)。workLoop 最后一步判斷全局變量 pendingCommit 是否存在,如果存在則把這次遍歷 fiber 樹(shù)產(chǎn)生的所有更新一次更新到真實(shí)的 dom 上去。注意 pendingCommit 在完成一次完整的遍歷過(guò)程之前是不會(huì)有值的。

function createWorkInProgress(updateQueue) {
  const updateTask = updateQueue.shift()
  if (!updateTask) return

  if (updateTask.partialState) {
    // 證明這是一個(gè)setState操作
    updateTask.stateNode._internalfiber.partialState = updateTask.partialState
  }

  const rootFiber =
    updateTask.fromTag === tag.HostRoot
      ? updateTask.stateNode._rootContainerFiber
      : getRoot(updateTask.stateNode._internalfiber)

  return {
    tag: tag.HostRoot,
    stateNode: updateTask.stateNode,
    props: updateTask.props || rootFiber.props,
    alternate: rootFiber // 用于鏈接新舊的 VDOM
  }
}

function getRoot(fiber) {
  let _fiber = fiber
  while (_fiber.return) {
    _fiber = _fiber.return
  }
  return _fiber
}

createWorkInProgress 拿出更新隊(duì)列 updateQueue 第一個(gè)任務(wù),然后看觸發(fā)這個(gè)任務(wù)的節(jié)點(diǎn)是什么類(lèi)型。如果不是根節(jié)點(diǎn),則通過(guò)循環(huán)迭代節(jié)點(diǎn)的 return 找到最上層的根節(jié)點(diǎn)。最后生成一個(gè)新的 fiber 節(jié)點(diǎn),這個(gè)節(jié)點(diǎn)就是當(dāng)前 fiber 節(jié)點(diǎn)的 alternate 指向的,也就是說(shuō)下面會(huì)在當(dāng)前節(jié)點(diǎn)和這個(gè)新生成的節(jié)點(diǎn)直接進(jìn)行 diff。

function performUnitOfWork(workInProgress) {
  const nextChild = beginWork(workInProgress)
  if (nextChild) return nextChild

  // 沒(méi)有 nextChild, 我們看看這個(gè)節(jié)點(diǎn)有沒(méi)有 sibling
  let current = workInProgress
  while (current) {
    //收集當(dāng)前節(jié)點(diǎn)的effect,然后向上傳遞
    completeWork(current)
    if (current.sibling) return current.sibling
    //沒(méi)有 sibling,回到這個(gè)節(jié)點(diǎn)的父親,看看有沒(méi)有sibling
    current = current.return
  }
}

performUnitOfWork 做的工作是 diff 當(dāng)前節(jié)點(diǎn),diff 完看看有沒(méi)有子節(jié)點(diǎn),如果沒(méi)有子節(jié)點(diǎn)則把更新先提交到父節(jié)點(diǎn)。然后再看有沒(méi)有兄弟節(jié)點(diǎn),如果有則返回出去當(dāng)作下次遍歷的節(jié)點(diǎn)。如果還是沒(méi)有,說(shuō)明整個(gè) fiber 樹(shù)已經(jīng)遍歷完了,則進(jìn)入到回溯過(guò)程,把所有的更新都集中到根節(jié)點(diǎn)進(jìn)行更新真實(shí) dom。

function completeWork(currentFiber) {
  if (currentFiber.tag === tag.classComponent) {
    // 用于回溯最高點(diǎn)的 root
    currentFiber.stateNode._internalfiber = currentFiber
  }

  if (currentFiber.return) {
    const currentEffect = currentFiber.effects || [] //收集當(dāng)前節(jié)點(diǎn)的 effect list
    const currentEffectTag = currentFiber.effectTag ? [currentFiber] : []
    const parentEffects = currentFiber.return.effects || []
    currentFiber.return.effects = parentEffects.concat(currentEffect, currentEffectTag)
  } else {
    // 到達(dá)最頂端了
    pendingCommit = currentFiber
  }
}

我們看到 completeWork 中當(dāng)判斷到當(dāng)前節(jié)點(diǎn)是根節(jié)點(diǎn)的時(shí)候才賦值 pendingCommit 整個(gè)全局變量。

function commitAllwork(topFiber) {
  topFiber.effects.forEach(f => {
    commitWork(f)
  })

  topFiber.stateNode._rootContainerFiber = topFiber
  topFiber.effects = []
  nextUnitOfWork = null
  pendingCommit = null
}

當(dāng)回溯完,有了 pendingCommit,則 commitAllwork 會(huì)被調(diào)用。它做的工作就是循環(huán)遍歷根節(jié)點(diǎn)的 effets 數(shù)據(jù),里面保存著所有要更新的內(nèi)容。commitWork 就是執(zhí)行具體更新的函數(shù),這里就不展開(kāi)了(因?yàn)檫@篇主要想講的是 fiber 更新的調(diào)度算法)。

所以你們看遍歷 dom 數(shù) diff 的過(guò)程是可以被打斷并且在后續(xù)的時(shí)間片上接著干,只是最后一步 commitAllwork 是同步的不能打斷的。這樣 react 使用新的調(diào)度算法優(yōu)化了更新過(guò)程中執(zhí)行時(shí)間過(guò)長(zhǎng)導(dǎo)致的頁(yè)面卡頓現(xiàn)象。

參考文獻(xiàn)

  1. 為 Luy 實(shí)現(xiàn) React Fiber 架構(gòu) - 更詳細(xì)的代碼實(shí)現(xiàn)可以看這片文章。
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 我的今日關(guān)注:20171128第270天 灰犀牛 一、概念 “灰犀?!笔侵改切┐蠹叶伎匆?jiàn)...
    牛革工舍閱讀 534評(píng)論 0 1
  • 親人包括愛(ài)人,不只是親戚的意思。 對(duì)每一個(gè)離家的游子來(lái)說(shuō),即使沒(méi)有過(guò)多的言語(yǔ),也能感受到父母的溫度。 對(duì)每一個(gè)守望...
    王子木閱讀 1,275評(píng)論 0 0
  • 還有喂飯機(jī)器,它可以喂人吃飯,做法是這樣:做一個(gè)機(jī)器的模樣,往里面裝上一把勺子和一根保姆的頭發(fā),就做成了喂飯...
    路繪很nice閱讀 318評(píng)論 1 0
  • 寶寶踐行:1早上起床磨耳朵聽(tīng)了My dad Dear zoo time for bed. 聽(tīng)了歌曲的也聽(tīng)了美語(yǔ)的。...
    Lynn_1f06閱讀 157評(píng)論 0 0
  • 本來(lái)是要陪姐姐好好逛逛的,結(jié)果王經(jīng)理一個(gè)留言,下午得去曲江匯報(bào)。不過(guò)今天收貨頗豐,發(fā)現(xiàn)逛博物館也不是一件無(wú)聊的事。...
    桐華韻錦閱讀 238評(píng)論 0 0

友情鏈接更多精彩內(nèi)容