Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React - Fiber #40

Open
jtwang7 opened this issue Feb 2, 2022 · 1 comment
Open

React - Fiber #40

jtwang7 opened this issue Feb 2, 2022 · 1 comment

Comments

@jtwang7
Copy link
Owner

jtwang7 commented Feb 2, 2022

React - Fiber 概述

参考文章:走进React Fiber的世界

浏览器帧渲染

在浏览器中,页面是一帧一帧展示更新的,渲染的帧率与设备的刷新率保持一致。一般情况下,设备的屏幕刷新率为 1s 60次,也就是说,浏览器大约每 1 s 绘制 60 帧。当每秒内绘制的帧数(FPS)超过60时,页面渲染是流畅的;而当FPS小于60时,会出现一定程度的卡顿现象。

一次完整的帧渲染流程

完整的一帧中,按顺序从上到下做了如下事情:

  1. 输入事件:首先处理的是 input event 输入事件 (click, touch, wheel, keypress 等),目的是能够让用户得到最早的反馈
  2. 定时器:接下来处理定时器,检查定时器是否到时间,若符合条件则执行对应的回调
  3. 开始帧事件:接下来处理 Begin Frame(开始帧),即每一帧的事件,包括 window.resize、scroll、media query change 等
  4. 动画帧:接下来执行请求动画帧 requestAnimationFrame(rAF),在每次绘制之前,会执行 rAF 回调
  5. 布局:紧接着进行 Layout 操作,包括计算布局和更新布局,即这个元素的样式是怎样的,它应该在页面如何展示
  6. 绘制:接着进行 Paint 操作,得到树中每个节点的尺寸与位置等信息,浏览器针对每个元素进行内容填充
  7. 执行空闲回调:到这时以上的六个阶段都已经完成了,接下来处于空闲阶段(Idle Peroid),可以在这时执行 requestIdleCallback 里注册的任务(requestIdleCallback 是 React Fiber 实现的基础)
输入事件 -> 定时器 -> 开始帧 -> 动画帧 -> 布局 -> 绘制 -> 执行空闲回调

根据浏览器的渲染进程,我们可以得到帧渲染的另一种执行流程表达:
参考 JS - 进程与线程

输入事件(事件触发线程+JS引擎线程) -> 定时器(定时器触发线程 + JS引擎线程) -> 开始帧(JS引擎线程) -> 动画帧(JS引擎线程) -> 布局(GUI渲染线程) -> 绘制(GUI渲染线程) -> 执行空闲回调(JS引擎线程)

在浏览器渲染进程中,GUI 线程和JS 线程是互斥的,因此你可以发现,一帧页面的渲染,也是先完成 JS 线程中所有的操作后,再执行 GUI 线程的。如果在某个阶段执行任务特别长,例如在定时器阶段或Begin Frame阶段执行时间非常长,时间已经明显超过了16ms,那么就会阻塞页面的渲染,从而出现卡顿现象。

React 为什么要引进 Fiber ?

reconcilation (协调):在 react16 引入 Fiber 架构之前,react 会采用深度优先遍历的递归方法对比虚拟DOM树,找出需要变动的节点,然后同步更新它们。

存在的问题

由于遍历是递归调用,执行栈会越来越深,而且不能中断,中断后就不能恢复了。递归如果非常深,就会十分卡顿。传统的方法存在不能中断和执行栈太深的问题。在 reconcilation 期间,react 会一直占用浏览器资源直到所有变化节点更新完成,会导致用户触发的事件得不到响应。

Fiber 设计思想

React 为了解决协调期间长时间占用主线程的问题,引入了 Fiber 来改变这种不可控的现状,把渲染/更新过程拆分为一个个小块的任务,通过合理的调度机制来调控时间,指定任务执行的时机,从而降低页面卡顿的概率,提升页面交互体验。通过Fiber架构,让 reconcilation 协调过程变得可被中断,适时地让出CPU执行权,可以让浏览器及时地响应用户的交互。

Vue 为什么没有引入 Fiber ?

  1. Vue 是基于 template 和 watcher 的组件级更新,在构建页面时,就已经把每个更新任务分割得足够小,不需要使用到 Fiber 架构,将任务进行更细粒度的拆分。
  2. React 的重渲染都是从根节点开始更新的,更新任务很大,需要使用到 Fiber 将大任务分割为多个小任务,可以中断和恢复,不阻塞主进程执行高优先级的任务。

Fiber

数据结构

Fiber 可以理解为是一种数据结构,在 React16 之后,每个 Virtual DOM 都有一个对应的 Fiber 对象表示。React Fiber 采用链表实现的,在虚拟 DOM 树结构中,每个节点都是一个 fiber。一个 fiber包括了 child(第一个子节点)、sibling(兄弟节点)、return(父节点)等属性。

从代码角度理解,fiber 就是对虚拟 DOM 的一个对象类型的描述,用于表达该节点在树结构上下文中的关系。

执行单元

fiber 除了描述一个虚拟 DOM 节点之外,还可以被理解为一个执行单元。在 fiber 引入以前,react 更新 DOM 树需要遍历更新整颗 DOM 树,而且该过程不支持中断,这也就意味着 DOM 树结构在 react16 以前,会被视为一个大块的执行单元,引入 fiber 后,执行单元被拆分到各个节点上,虽然各个节点(小任务)的渲染执行必须是一次完成的,不能出现暂停,但是一个小块任务执行完后可以移交控制权给浏览器去响应用户,从而不用像之前一样要等那个大任务(整颗 DOM 树完成更新)一直执行完成再去响应用户。

移交控制权的时机:react 引入 fiber 后,每次执行完一个执行单元,react 就会检查现在还剩多少空闲时间,如果没有时间则将控制权让出去。

具体流程:
首先 React 向浏览器请求调度,若浏览器在一帧中如果还有空闲时间,则会响应该调度请求,会去判断 react 任务队列中是否存在待执行任务,不存在就直接将控制权交给浏览器,如果存在就会执行对应的任务,执行完成后会判断是否还有时间,有时间且有待执行任务则会继续执行下一个任务,否则就会将控制权交给浏览器。

requestIdleCallback - 实现 Fiber 的基础 api

requestIdleCallback 能使开发者在主事件循环上执行后台和低优先级的工作,而不影响延迟关键事件,如动画和输入响应。正常帧任务完成后没超过16ms,说明有多余的空闲时间,此时就会执行 requestIdleCallback 里注册的任务。
react 虽然自己实现了 requestIdleCallback 的封装,但核心思想没有改变,通过注册回调,在每次帧渲染时请求时间片,并期望在剩余的空闲时间内执行低优先级的回调任务。

具体流程:

用 requestIdleCallback 方法注册对应的任务,告诉浏览器这个任务优先级不高,当每一帧内存在空闲时间时,再执行注册的这个任务即可。浏览器执行完这个方法后,如果没有剩余时间了,或者已经没有下一个可执行的任务了,React应该归还控制权,并同样使用requestIdleCallback去申请下一个时间片。

requestIdleCallback 有一个 timeout 参数,它用于定义超时时间的,如果到了超时时间了,无论是否存在空闲时间,浏览器都必须立即执行注册的回调函数,例如 window.requestIdleCallback(callback, { timeout: 1000 })

window.requestIdleCallback(callback)的callback中会接收到默认参数 deadline 对象 ,其中包含了以下两个属性:

  • timeRamining 返回当前帧还剩多少时间供用户使用
  • didTimeout 返回 callback 任务是否超时

多帧执行

一帧页面渲染并非只能对应一个 requestIdleCallback 的注册回调,实际上 requestIdleCallback 的回调可在所有帧渲染时调用,前提是该帧所有操作完成后仍有剩余空闲时间 (或已经到达 timeout 规定时间需要强制执行)。假设剩余空间可以执行好几个注册回调,那么它们就会被依次取出来执行,若执行某次回调后没有剩余时间或者已经超出剩余时间了,那么就必须将控制权返还给浏览器,同时发起下一次的时间片请求,再下一段空闲时间继续执行剩余的回调函数。

如果子任务的时间超过了一帧的剩余时间,则会一直卡在这里执行,直到子任务执行完毕。如果代码存在死循环,则浏览器会卡死。

fiber 节点设计

Fiber 的拆分单元是 fiber(fiber tree上的一个节点)。实际上一个 fiber 就对应一个虚拟 DOM 节点,虚拟 DOM 树会对应生成 Fiber 树。fiber 节点结构如下,源码详见 ReactInternalTypes.js

{
    type: any, // 对于类组件,它指向构造函数;对于DOM元素,它指定HTML tag
    key: null | string, // 唯一标识符
    stateNode: any, // 保存对组件的类实例,DOM节点或与fiber节点关联的其他React元素类型的引用
    child: Fiber | null, // 大儿子
    sibling: Fiber | null, // 下一个兄弟
    return: Fiber | null, // 父节点
    tag: WorkTag, // 定义fiber操作的类型, 详见https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactWorkTags.js
    nextEffect: Fiber | null, // 指向下一个节点的指针
    updateQueue: mixed, // 用于状态更新,回调函数,DOM更新的队列
    memoizedState: any, // 用于创建输出的fiber状态
    pendingProps: any, // 已从React元素中的新数据更新,并且需要应用于子组件或DOM元素的props
    memoizedProps: any, // 在前一次渲染期间用于创建输出的props
    // ……     
}

fiber 节点的几个主要属性:

  1. type & key
    type 描述了 fiber 所对应的组件,对于复合组件,type 是函数或类组件本身。对于原生标签(div,span等),type 是一个字符串。随着 type 的不同,在 reconciliation 期间使用 key 来确定 fiber 是否可以重新使用。
  2. stateNode
    stateNode 保存对组件的类实例,DOM节点或与 fiber 节点关联的其他 React 元素类型的引用。一般来说,可以认为这个属性用于保存与 fiber 相关的本地状态节点。
  3. child & sibling & return
    所有 fiber 节点都通过以下属性:child,sibling 和 return来构成一个 fiber node 的 linked list (链表结构)。
  • child 属性指向此节点的第一个子节点(大儿子)。
  • sibling 属性指向此节点的下一个兄弟节点(大儿子指向二儿子、二儿子指向三儿子)。
  • return 属性指向此节点的父节点,即当前节点处理完毕后,应该向谁提交自己的成果。如果 fiber 具有多个子 fiber,则每个子 fiber 的 return fiber 是 parent 。

Fiber 状态更新的链表结构设计

Fiber 结构是使用链表实现的,Fiber tree 实际上是个单链表树结构。同样,Fiber 树内的状态更新也是由链表结构实现的,每个 fiber 节点对应保存有自己的一个状态更新单元,通过更新队列的形式组织成一个链表结构,统一更新状态:

fiber 状态单元

一个 fiber 状态单元包含 payload(数据)和 nextUpdate(指向下一个更新单元的指针):

class Update {
  constructor(payload, nextUpdate) {
    this.payload = payload // payload 数据
    this.nextUpdate = nextUpdate // 指向下一个节点的指针
  }
}

更新队列

定义一个队列,把每个 fiber 状态单元串联起来,其中定义了两个指针:

  • 头指针 firstUpdate
  • 尾指针 lastUpdate

作用是指向第一个更新单元和最后一个更新单元,并加入了 baseState 属性存储 React 中整体的 state 状态。

class UpdateQueue {
  constructor() {
    this.baseState = null // state
    this.firstUpdate = null // 第一个更新单元
    this.lastUpdate = null // 最后一个更新单元
  }
}

接下来定义两个方法:

  • 插入节点单元 (enqueueUpdate)
  • 更新队列 (forceUpdate)

插入节点单元时需要考虑是否已经存在节点,如果不存在直接将 firstUpdate、lastUpdate 指向此节点即可。
更新队列是遍历这个链表,根据 payload 中的内容去更新 state 的值。

class UpdateQueue {
  //.....
  
  enqueueUpdate(update) {
    // 当前链表是空链表
    if (!this.firstUpdate) {
      this.firstUpdate = this.lastUpdate = update
    } else {
      // 当前链表不为空
      this.lastUpdate.nextUpdate = update
      this.lastUpdate = update
    }
  }
  
  // 获取state,然后遍历这个链表,进行更新
  forceUpdate() {
    let currentState = this.baseState || {}
    let currentUpdate = this.firstUpdate
    while (currentUpdate) {
      // 判断是函数还是对象,是函数则需要执行,是对象则直接返回
      let nextState = typeof currentUpdate.payload === 'function' ? currentUpdate.payload(currentState) : currentUpdate.payload
      currentState = { ...currentState, ...nextState }
      currentUpdate = currentUpdate.nextUpdate
    }
    // 更新完成后清空链表
    this.firstUpdate = this.lastUpdate = null
    this.baseState = currentState
    return currentState
  }
}

Demo 模拟 Fiber 的状态更新

// 实例化一个更新队列
let queue = new UpdateQueue()

// 添加 fiber 节点的更新单元
queue.enqueueUpdate(new Update({ name: 'www' }))
queue.enqueueUpdate(new Update({ age: 10 }))
queue.enqueueUpdate(new Update(state => ({ age: state.age + 1 })))
queue.enqueueUpdate(new Update(state => ({ age: state.age + 1 })))

// 依据 payload 更新 state
queue.forceUpdate()

console.log(queue.baseState);
// 打印结果如下:
{ name:'www',age:12 }

Fiber 在 React 生命周期中的执行流程

首先,我们知道 react 生命周期可被分为三个阶段:(render / pre-commit / commit)

此处我们不区分 pre-commit 和 commit 阶段,统一为 commit 阶段

Fiber 在 render 和 commit 阶段的表现是不同的:

  • render 阶段:这个阶段是可中断的,会找出所有节点的变更
  • commit 阶段:这个阶段是不可中断的,会执行所有的变更

render 阶段

此阶段会找出所有节点的变更,如节点新增、删除、属性变更等,这些变更 react 统称为副作用 (effect)。
此阶段会构建一棵虚拟 DOM 树以及对应的 Fiber tree,以虚拟dom节点为单位对任务进行拆分,即一个虚拟dom节点对应一个任务(fiber),最后产出的结果是effect list,从中可以知道哪些节点更新、哪些节点增加、哪些节点删除了。

深度优先遍历(后序遍历)

已知 render 阶段会基于虚拟 DOM 树映射出 Fiber tree,Fiber Tree 每个节点都有child、sibling、return属性,最终目的是得到一个 effect list,该列表中将包含 Fiber Tree 的所有更新任务。因此,我们最终期望在根结点获得 effect list,也就是后序遍历从下到上收集结果,采用深度优先后序遍历的方法:

  1. 从顶点开始递归到叶子节点
  2. 按照左子节点、右子节点、根节点的顺序,逐层向上收集更新任务
  3. 存储到 effect list 中
  4. 若没有父节点则标记遍历结束

收集effect list

在遍历过程中,收集所有节点的变更产出effect list,注意其中只包含了需要变更的节点。通过每个节点更新结束时向上归并effect list来收集任务结果,最后根节点的effect list里就记录了包括了所有需要变更的结果。
具体步骤:

  • 如果当前节点需要更新,则打tag更新当前节点状态(props, state, context等)
  • 为每个子节点创建fiber。如果没有产生child fiber,则结束该节点,把effect list归并到return(父节点),把此节点的sibling节点作为下一个遍历节点;否则把child节点作为下一个遍历节点 (深度优先后序遍历)
  • 如果有剩余时间,则开始下一个节点,否则等下一次主线程空闲再开始下一个节点
  • 如果没有下一个节点了,进入pendingCommit状态,此时effect list收集完毕,结束。

commit阶段

commit 阶段需要将上阶段计算出来的需要处理的副作用一次性执行,此阶段不能暂停,否则会出现UI更新不连续的现象。此阶段需要根据effect list,将所有更新都 commit 到DOM树上 (从根节点出发递归,根据effect list完成全部更新)。

@jtwang7
Copy link
Owner Author

jtwang7 commented Feb 3, 2022

Render 阶段 effect list 收集操作

// 在完成的时候要收集有副作用的fiber,组成effect list
const completeUnitOfWork = (currentFiber) => {
  // 每个fiber有两个属性:
  // 1)firstEffect:指向第一个有副作用的子fiber
  // 2)lastEffect:指向最后一个有副作用的子fiber

  let returnFiber = currentFiber.return  // fiber 父节点
  if (returnFiber) {
    // 将当前 fiber 中有副作用的子 fiber 链接到父节点的后面
    if (!returnFiber.firstEffect) {
      returnFiber.firstEffect = currentFiber.firstEffect
    }
    if (currentFiber.lastEffect) {
      if (returnFiber.lastEffect) {
        returnFiber.lastEffect.nextEffect = currentFiber.firstEffect
      }
      returnFiber.lastEffect = currentFiber.lastEffect
    }

    // 若当前 fiber 自身存在副作用,则将其链接到父节点后面
    const effectTag = currentFiber.effectTag
    if (effectTag) { // 说明有副作用
      // 中间的使用nextEffect做成一个单链表
      if (returnFiber.lastEffect) {
        returnFiber.lastEffect.nextEffect = currentFiber
      } else {
        returnFiber.lastEffect = currentFiber
      }
      returnFiber.lastEffect = currentFiber
    }
  }
}

定义一个函数,从根节点出发,把全部的 fiber 节点遍历一遍,产出最终全部的effect list

const performUnitOfWork = (currentFiber) => {
  // 递归到叶子节点
  if (currentFiber.child) {
    return currentFiber.child
  }

  // 该处开始执行时,currentFiber 为叶子节点,向上收集 effect 并构建链表
  while (currentFiber) {
    completeUnitOfWork(currentFiber)  // 构建链表 
    // 遍历同级兄弟节点
    if (currentFiber.sibling) {
      return currentFiber.sibling
    }
    currentFiber = currentFiber.return // 同级遍历完成后,将结果收缩到父节点
  }
}

Commit 阶段的视图更新操作

commitWork

根据一个 fiber 的effect list列表去更新视图 (这里只列举了新增节点、删除节点、更新节点的三种操作)

// 作用:基于当前 fiber 状态更新单元,更新节点
const commitWork = currentFiber => {
  if (!currentFiber) return
  let returnFiber = currentFiber.return
  // 父节点元素
  let returnDOM = returnFiber.stateNode
  // fiber 内保存有更新操作的标识,基于该标识更新节点
  if (currentFiber.effectTag === INSERT) {  
    // 如果当前fiber的effectTag标识位INSERT,则代表其是需要插入的节点
    returnDOM.appendChild(currentFiber.stateNode)
  } else if (currentFiber.effectTag === DELETE) {  
    // 如果当前fiber的effectTag标识位DELETE,则代表其是需要删除的节点
    returnDOM.removeChild(currentFiber.stateNode)
  } else if (currentFiber.effectTag === UPDATE) {  
    // 如果当前fiber的effectTag标识位UPDATE,则代表其是需要更新的节点
    if (currentFiber.type === ELEMENT_TEXT) {
      if (currentFiber.alternate.props.text !== currentFiber.props.text) {
        currentFiber.stateNode.textContent = currentFiber.props.text
      }
    }
  }
  // 更新完成,清理标识符
  currentFiber.effectTag = null
}

commitRoot

根据全部 fiber 的 effect list 更新视图

const commitRoot = () => {
  let currentFiber = workInProgressRoot.firstEffect  // 找到更新的头节点
  while (currentFiber) {
    commitWork(currentFiber)  // 更新操作(将 effect 提交到真实 DOM) 
    currentFiber = currentFiber.nextEffect  // 移动指针
  }
  currentRoot = workInProgressRoot  // 把当前渲染成功的根fiber赋给currentRoot (保存)
  workInProgressRoot = null  // 清空,表明完成视图更新
}

视图更新(入口)

const workloop = (deadline) => {
  // render 阶段
  // 可通过移交控制权的方式,打断 render,同时会记录打断的位置,下次从打断处继续执行
  let shouldYield = false // 是否需要让出控制权
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)  // 构建 effect list
    shouldYield = deadline.timeRemaining() < 1 // 如果执行完任务后,剩余时间小于1ms,则需要让出控制权给浏览器
  }

  // commit 阶段
  // 不可被打断,将 effect list 内操作一次性完成更新
  if (!nextUnitOfWork && workInProgressRoot) {
    console.log('render阶段结束')
    commitRoot() // 没有下一个任务了,根据effect list结果批量更新视图
  }

  // 递归视图更新操作,在浏览器空闲时间段内执行
  requestIdleCallback(workloop, { timeout: 1000 })
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant