乐趣区

关于前端:React-架构的演变-从递归到循环

这篇文章是 React 架构演变的第二篇,上一篇次要介绍了更新机制从同步批改为异步,这一篇重点介绍 Fiber 架构下通过循环遍历更新的过程,之所以要应用循环遍历的形式,是因为递归更新过程一旦开始就不能暂停,只能一直向下,直到递归完结或者出现异常。

递归更新的实现

React 15 的递归更新逻辑是先将须要更新的组件放入脏组件队列(这里在上篇文章曾经介绍过,没看过的能够先看看《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 组件。

// 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 不停循环。

// 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 更新。

通过 Performance 的函数调用堆栈也能清晰的看到这个过程,updateComponent 之后 的 updateChildren 会持续调用子组件的 updateComponent,直到递归完所有组件,示意更新实现。

递归的毛病很显著,不能暂停更新,一旦开始必须从头到尾,这与 React 16 拆分工夫片,给浏览器喘口气的理念显著不符,所以 React 必须要切换架构,将虚构 DOM 从树形构造批改为链表构造。

可循环的 Fiber

这里说的链表构造就是 Fiber 了,链表构造最大的劣势就是能够通过循环的形式来遍历,只有记住以后遍历的地位,即便中断后也能疾速还原,从新开始遍历。

咱们先看看一个 Fiber 节点的数据结构:

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' // 子节点仅有文本节点
    }
  }
}

循环更新的实现

那么,在 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 中。

在一次遍历过程中,每个节点都会经验 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 的实现。

退出移动版