React 架构的演变 - 从递归到循环

开发 架构
React 15 的递归更新逻辑是先将需要更新的组件放入脏组件队列(这里在上篇文章已经介绍过,没看过的可以先看看《React 架构的演变 - 从同步到异步》),然后取出组件进行一次递归,不停向下寻找子节点来查找是否需要更新。

[[344647]]

递归更新的实现

React 15 的递归更新逻辑是先将需要更新的组件放入脏组件队列(这里在上篇文章已经介绍过,没看过的可以先看看《React 架构的演变 - 从同步到异步》),然后取出组件进行一次递归,不停向下寻找子节点来查找是否需要更新。

下面使用一段代码来简单描述一下这个过程:

  1. updateComponent (prevElement, nextElement) { 
  2.   if ( 
  3.   // 如果组件的 type 和 key 都没有发生变化,进行更新 
  4.     prevElement.type === nextElement.type && 
  5.     prevElement.key === nextElement.key 
  6.   ) { 
  7.     // 文本节点更新 
  8.     if (prevElement.type === 'text') { 
  9.         if (prevElement.value !== nextElement.value) { 
  10.             this.replaceText(nextElement.value) 
  11.         } 
  12.     } 
  13.     // DOM 节点的更新 
  14.     else { 
  15.       // 先更新 DOM 属性 
  16.       this.updateProps(prevElement, nextElement) 
  17.       // 再更新 children 
  18.       this.updateChildren(prevElement, nextElement) 
  19.     } 
  20.   } 
  21.   // 如果组件的 type 和 key 发生变化,直接重新渲染组件 
  22.   else { 
  23.     // 触发 unmount 生命周期 
  24.     ReactReconciler.unmountComponent(prevElement) 
  25.     // 渲染新的组件 
  26.     this._instantiateReactComponent(nextElement) 
  27.   } 
  28. }, 
  29. updateChildren (prevElement, nextElement) { 
  30.   var prevChildren = prevElement.children 
  31.   var nextChildren = nextElement.children 
  32.   // 省略通过 key 重新排序的 diff 过程 
  33.   if (prevChildren === null) { } // 渲染新的子节点 
  34.   if (nextChildren === null) { } // 清空所有子节点 
  35.   // 子节点对比 
  36.   prevChildren.forEach((prevChild, index) => { 
  37.     const nextChild = nextChildren[index
  38.     // 递归过程 
  39.     this.updateComponent(prevChild, nextChild) 
  40.   }) 

为了更清晰的看到这个过程,我们还是写一个简单的Demo,构造一个 3 * 3 的 Table 组件。

Table

  1. // https://codesandbox.io/embed/react-sync-demo-nlijf 
  2. class Col extends React.Component { 
  3.   render() { 
  4.     // 渲染之前暂停 8ms,给 render 制造一点点压力 
  5.     const start = performance.now() 
  6.     while (performance.now() - start < 8) 
  7.     return <td>{this.props.children}</td> 
  8.   } 
  9.  
  10. export default class Demo extends React.Component { 
  11.   state = { 
  12.     val: 0 
  13.   } 
  14.   render() { 
  15.     const { val } = this.state 
  16.     const array = Array(3).fill() 
  17.     // 构造一个 3 * 3 表格 
  18.     const rows = array.map( 
  19.       (_, row) => <tr key={row}> 
  20.         {array.map( 
  21.           (_, col) => <Col key={col}>{val}</Col> 
  22.         )} 
  23.       </tr> 
  24.     ) 
  25.     return ( 
  26.       <table className="table"
  27.         <tbody>{rows}</tbody> 
  28.       </table
  29.     ) 
  30.   } 

然后每秒对 Table 里面的值更新一次,让 val 每次 + 1,从 0 ~ 9 不停循环。

Table Loop

  1. // https://codesandbox.io/embed/react-sync-demo-nlijf 
  2. export default class Demo extends React.Component { 
  3.  tick = () => { 
  4.     setTimeout(() => { 
  5.       this.setState({ val: next < 10 ? next : 0 }) 
  6.       this.tick() 
  7.     }, 1000) 
  8.   } 
  9.   componentDidMount() { 
  10.     this.tick() 
  11.   } 

完整代码的线上地址: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,直到递归完所有组件,表示更新完成。

调用堆栈

 

递归的缺点很明显,不能暂停更新,一旦开始必须从头到尾,这与 React 16 拆分时间片,给浏览器喘口气的理念明显不符,所以 React 必须要切换架构,将虚拟 DOM 从树形结构修改为链表结构。

可循环的 Fiber

这里说的链表结构就是 Fiber 了,链表结构最大的优势就是可以通过循环的方式来遍历,只要记住当前遍历的位置,即使中断后也能快速还原,重新开始遍历。

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

  1. function FiberNode (tag, key) { 
  2.   // 节点 key,主要用于了优化列表 diff 
  3.   this.key = key 
  4.   // 节点类型;FunctionComponent: 0, ClassComponent: 1, HostRoot: 3 ... 
  5.   this.tag = tag 
  6.  
  7.  // 子节点 
  8.   this.child = null 
  9.   // 父节点 
  10.   this.return = null  
  11.   // 兄弟节点 
  12.   this.sibling = null 
  13.    
  14.   // 更新队列,用于暂存 setState 的值 
  15.   this.updateQueue = null 
  16.    
  17.   // 节点更新过期时间,用于时间分片 
  18.   // react 17 改为:lanes、childLanes 
  19.   this.expirationTime = NoLanes 
  20.   this.childExpirationTime = NoLanes 
  21.  
  22.   // 对应到页面的真实 DOM 节点 
  23.   this.stateNode = null 
  24.   // Fiber 节点的副本,可以理解为备胎,主要用于提升更新的性能 
  25.   this.alternate = null 

下面举个例子,我们这里有一段普通的 HTML 文本:

  1. <table class="table"
  2.   <tr> 
  3.     <td>1</td> 
  4.     <td>1</td> 
  5.   </tr> 
  6.   <tr> 
  7.     <td>1</td> 
  8.   </tr> 
  9. </table

在之前的 React 版本中,jsx 会转化为 createElement 方法,创建树形结构的虚拟 DOM。

  1. const VDOMRoot = { 
  2.   type: 'table'
  3.   props: { className: 'table' }, 
  4.   children: [ 
  5.     { 
  6.       type: 'tr'
  7.       props: { }, 
  8.       children: [ 
  9.         { 
  10.           type: 'td'
  11.           props: { }, 
  12.           children: [{type: 'text', value: '1'}] 
  13.         }, 
  14.         { 
  15.           type: 'td'
  16.           props: { }, 
  17.           children: [{type: 'text', value: '1'}] 
  18.         } 
  19.       ] 
  20.     }, 
  21.     { 
  22.       type: 'tr'
  23.       props: { }, 
  24.       children: [ 
  25.         { 
  26.           type: 'td'
  27.           props: { }, 
  28.           children: [{type: 'text', value: '1'}] 
  29.         } 
  30.       ] 
  31.     } 
  32.   ] 

Fiber 架构下,结构如下:

  1. // 有所简化,并非与 React 真实的 Fiber 结构一致 
  2. const FiberRoot = { 
  3.   type: 'table'
  4.   returnnull
  5.   sibling: null
  6.   child: { 
  7.     type: 'tr'
  8.     return: FiberNode, // table 的 FiberNode 
  9.     sibling: { 
  10.       type: 'tr'
  11.       return: FiberNode, // table 的 FiberNode 
  12.       sibling: null
  13.       child: { 
  14.         type: 'td'
  15.         return: FiberNode, // tr 的 FiberNode 
  16.         sibling: { 
  17.           type: 'td'
  18.           return: FiberNode, // tr 的 FiberNode 
  19.           sibling: null
  20.           child: null
  21.           text: '1' // 子节点仅有文本节点 
  22.         }, 
  23.         child: null
  24.         text: '1' // 子节点仅有文本节点 
  25.       } 
  26.     }, 
  27.     child: { 
  28.       type: 'td'
  29.       return: FiberNode, // tr 的 FiberNode 
  30.       sibling: null
  31.       child: null
  32.       text: '1' // 子节点仅有文本节点 
  33.     } 
  34.   } 

Fiber

 

循环更新的实现

那么,在 setState 的时候,React 是如何进行一次 Fiber 的遍历的呢?

  1. let workInProgress = FiberRoot 
  2.  
  3. // 遍历 Fiber 节点,如果时间片时间用完就停止遍历 
  4. function workLoopConcurrent() { 
  5.   while ( 
  6.     workInProgress !== null && 
  7.     !shouldYield() // 用于判断当前时间片是否到期 
  8.   ) { 
  9.     performUnitOfWork(workInProgress) 
  10.   } 
  11.  
  12. function performUnitOfWork() { 
  13.   const next = beginWork(workInProgress) // 返回当前 Fiber 的 child 
  14.   if (next) { // child 存在 
  15.     // 重置 workInProgress 为 child 
  16.     workInProgress = next 
  17.   } else { // child 不存在 
  18.     // 向上回溯节点 
  19.     let completedWork = workInProgress 
  20.     while (completedWork !== null) { 
  21.       // 收集副作用,主要是用于标记节点是否需要操作 DOM 
  22.       completeWork(completedWork) 
  23.  
  24.       // 获取 Fiber.sibling 
  25.       let siblingFiber = workInProgress.sibling 
  26.       if (siblingFiber) { 
  27.         // sibling 存在,则跳出 complete 流程,继续 beginWork 
  28.         workInProgress = siblingFiber 
  29.         return
  30.       } 
  31.  
  32.       completedWork = completedWork.return 
  33.       workInProgress = completedWork 
  34.     } 
  35.   } 
  36.  
  37. function beginWork(workInProgress) { 
  38.   // 调用 render 方法,创建子 Fiber,进行 diff 
  39.   // 操作完毕后,返回当前 Fiber 的 child 
  40.   return workInProgress.child 
  41. function completeWork(workInProgress) { 
  42.   // 收集节点副作用 

Fiber 的遍历本质上就是一个循环,全局有一个 workInProgress 变量,用来存储当前正在 diff 的节点,先通过 beginWork 方法对当前节点然后进行 diff 操作(diff 之前会调用 render,重新计算 state、prop),并返回当前节点的第一个子节点( fiber.child)作为新的工作节点,直到不存在子节点。然后,对当前节点调用 completedWork 方法,存储 beginWork 过程中产生的副作用,如果当前节点存在兄弟节点( fiber.sibling),则将工作节点修改为兄弟节点,重新进入 beginWork 流程。直到 completedWork 重新返回到根节点,执行 commitRoot将所有的副作用反应到真实 DOM 中。

Fiber work loop

 

在一次遍历过程中,每个节点都会经历 beginWork、completeWork ,直到返回到根节点,最后通过 commitRoot 将所有的更新提交,关于这部分的内容可以看:《React 技术揭秘》。

时间分片的秘密

前面说过,Fiber 结构的遍历是支持中断恢复,为了观察这个过程,我们将之前的 3 * 3 的 Table 组件改成 Concurrent 模式,线上地址:https://codesandbox.io/embed/react-async-demo-h1lbz。由于每次调用 Col 组件的 render 部分需要耗时 8ms,会超出了一个时间片,所以每个 td 部分都会暂停一次。

  1. class Col extends React.Component { 
  2.   render() { 
  3.     // 渲染之前暂停 8ms,给 render 制造一点点压力 
  4.     const start = performance.now(); 
  5.     while (performance.now() - start < 8); 
  6.     return <td>{this.props.children}</td> 
  7.   } 

在这个 3 * 3 组件里,一共有 9 个 Col 组件,所以会有 9 次耗时任务,分散在 9 个时间片进行,通过 Performance 的调用栈可以看到具体情况:

异步模式的调用栈

 

在非 Concurrent 模式下,Fiber 节点的遍历是一次性进行的,并不会切分多个时间片,差别就是在遍历的时候调用了 workLoopSync 方法,该方法并不会判断时间片是否用完。

  1. // 遍历 Fiber 节点 
  2. function workLoopSync() { 
  3.   while (workInProgress !== null) { 
  4.     performUnitOfWork(workInProgress) 
  5.   } 

同步模式的调用栈

 

通过上面的分析可以看出, shouldYield 方法决定了当前时间片是否已经用完,这也是决定 React 是同步渲染还是异步渲染的关键。如果去除任务优先级的概念,shouldYield 方法可以说很简单,就是判断了当前的时间,是否已经超过了预设的 deadline。

  1. function getCurrentTime() { 
  2.   return performance.now() 
  3. function shouldYield() { 
  4.   // 获取当前时间 
  5.   var currentTime = getCurrentTime() 
  6.   return currentTime >= deadline 

deadline 又是如何得的呢?可以回顾上一篇文章(《React 架构的演变 - 从同步到异步》)提到的 ChannelMessage,更新开始的时候会通过 requestHostCallback(即:port2.send)发送异步消息,在 performWorkUntilDeadline (即:port1.onmessage)中接收消息。performWorkUntilDeadline 每次接收到消息时,表示已经进入了下一个任务队列,这个时候就会更新 deadline。

异步调用栈

  1. var channel = new MessageChannel() 
  2. var port = channel.port2 
  3. channel.port1.onmessage = function performWorkUntilDeadline() { 
  4.   if (scheduledHostCallback !== null) { 
  5.     var currentTime = getCurrentTime() 
  6.     // 重置超时时间  
  7.     deadline = currentTime + yieldInterval 
  8.      
  9.     var hasTimeRemaining = true 
  10.     var hasMoreWork = scheduledHostCallback() 
  11.  
  12.     if (!hasMoreWork) { 
  13.       // 已经没有任务了,修改状态  
  14.       isMessageLoopRunning = false
  15.       scheduledHostCallback = null
  16.     } else { 
  17.       // 还有任务,放到下个任务队列执行,给浏览器喘息的机会  
  18.       port.postMessage (null); 
  19.     } 
  20.   } else { 
  21.     isMessageLoopRunning = false
  22.   } 
  23.  
  24. requestHostCallback = function (callback) { 
  25.   //callback 挂载到 scheduledHostCallback 
  26.   scheduledHostCallback = callback 
  27.   if (!isMessageLoopRunning) { 
  28.     isMessageLoopRunning = true 
  29.     // 推送消息,下个队列队列调用 callback 
  30.     port.postMessage (null
  31.   } 

超时时间的设置就是在当前时间的基础上加上了一个 yieldInterval, 这个 yieldInterval的值,默认是 5ms。

  1. deadline = currentTime + yieldInterval 

同时 React 也提供了修改 yieldInterval 的手段,通过手动指定 fps,来确定一帧的具体时间(单位:ms),fps 越高,一个时间分片的时间就越短,对设备的性能要求就越高。

  1. forceFrameRate = function (fps) { 
  2.   if (fps < 0 || fps > 125) { 
  3.     // 帧率仅支持 0~125 
  4.     return 
  5.   } 
  6.  
  7.   if (fps > 0) { 
  8.     // 一般 60 fps 的设备 
  9.     // 一个时间分片的时间为 Math.floor(1000/60) = 16 
  10.     yieldInterval = Math.floor(1000 / fps) 
  11.   } else { 
  12.     // reset the framerate 
  13.     yieldInterval = 5 
  14.   } 

总结

下面我们将异步逻辑、循环更新、时间分片串联起来。先回顾一下之前的文章讲过,Concurrent 模式下,setState 后的调用顺序:

  1. Component.setState() 
  2.   => enqueueSetState() 
  3.  => scheduleUpdate() 
  4.   => scheduleCallback(performConcurrentWorkOnRoot) 
  5.   => requestHostCallback() 
  6.   => postMessage() 
  7.   => performWorkUntilDeadline() 

scheduleCallback 方法会将传入的回调(performConcurrentWorkOnRoot)组装成一个任务放入 taskQueue 中,然后调用 requestHostCallback 发送一个消息,进入异步任务。performWorkUntilDeadline 接收到异步消息,从 taskQueue 取出任务开始执行,这里的任务就是之前传入的 performConcurrentWorkOnRoot 方法,这个方法最后会调用workLoopConcurrent(workLoopConcurrent 前面已经介绍过了,这个不再重复)。如果 workLoopConcurrent 是由于超时中断的,hasMoreWork 返回为 true,通过 postMessage 发送消息,将操作延迟到下一个任务队列。

 


 

流程图

 

 

到这里整个流程已经结束,希望大家看完文章能有所收获,下一篇文章会介绍 Fiber 架构下 Hook 的实现。

本文转载自微信公众号「更了不起的前端」,可以通过以下二维码关注。转载本文请联系更了不起的前端公众号。

 

责任编辑:武晓燕 来源: 更了不起的前端
相关推荐

2020-09-24 08:45:10

React架构源码

2020-10-28 09:12:48

React架构Hooks

2020-10-13 08:36:30

React 架构机制

2024-08-14 08:16:53

2023-05-29 13:56:00

JSReact

2019-04-18 14:24:52

技术互联网架构

2019-07-04 15:16:42

数据架构Flink数据仓库

2022-11-15 17:31:35

边缘计算架构人工智能

2023-08-09 08:00:00

数据仓库数据架构

2017-08-02 16:44:32

架构

2018-06-05 08:36:47

内部部署云存储

2009-08-26 18:20:42

三层架构

2013-05-29 10:33:16

2024-05-10 09:36:36

架构消息队列

2022-07-04 08:14:24

架构演变Tomcat容器架构

2021-04-20 14:57:20

架构运维技术

2023-03-21 08:31:13

ReconcilerFiber架构

2019-07-04 13:05:18

MySQL设计数据库

2021-05-12 23:07:16

服务器处理连接

2022-08-15 09:00:00

JavaScript前端架构
点赞
收藏

51CTO技术栈公众号