随笔:关于Fiber架构的一点点理解

Posted 我是真的不会前端

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了随笔:关于Fiber架构的一点点理解相关的知识,希望对你有一定的参考价值。

什么是fiber:react16以后的一个虚拟dom思想

fiber:纤维 :很小很小絮状物

react 16版本以前的虚拟dom

其实react16以前对于虚拟dom和vue2.0是很相似的。

vue2.0通过snabdom生成虚拟dom ,diff运算 而vue3则做了优化

推荐一个开源作者租户做的一个react的网址(还有教程)

https://pomb.us/build-your-own-react/

一切从React.createElement开始

我们都知道其实jsx是语法糖。真正的react理论上只能读懂下列这种语法

const element =React.creatElement(
	'h1',
	titile:'foo',
	'hello'
)

创建了一个对象 包含这个dom的参数。接着给改改

const element =React.creatElement(
	type:'h1',
	props:
	title:'foo',
	children:'hello'	
,
)

这个type表示我们将要创建的dom结构。用document.createElement这个原生方法创建节点。我们也可以把他构造成函数
props你可以理解成就是一个对象。children可以是个字符串也可以是数组。可以放置更多的element
这里的element可以指react的元素,
这就是为什么说elelments同样也可以是一棵树。

ReactDom.render()他到底渲染了什么

  • 我们需要替换并调用这个方法。当dom改变时,可以触发并重新调用render方法更新。再通过diff运算 并最终触发commit提交。
  • 首先,我们用type字段创建了一个node在h1。
 const node = document.createElement(element.type)
node["title"] = element.props.title
const text = document.createTextNode("")
text["nodeValue"] = element.props.children

node.appendChild(text)
container.appendChild(node)
  • 如果还有孩子,再用text创建一个文本节点。并用append进去。再创建根节点containier。再把h1放进去。这样未用react即创建了一个节点树。

createElement函数

function createElement(type, props, ...children) 
  return 
    type,
    props: 
      ...props,
      children,
    ,
  

元素是一个带有type和的对象props(入参)。我们的函数唯一需要做的就是创建那个对象。
我们使用扩展运算符来结构该props,其他用restful参数语法交给children,这样的children(restful参数,一般放最后一个)道具将永远是一个数组。
言简意赅点说:

childern是个对象,用restful参数接收,放进函数中

这个children数组可以包含原始数据类型。还可以是jsx。如果这个chilren是个基础数据类型,并为他们创造一种特殊类型:TEXT_ELEMENT。

function createElement(type, props, ...children) 
  return 
    type,
    props: 
      ...props,
      children: children.map(child =>
        typeof child === "object"
          ? child
          : createTextElement(child)
      ),
    ,
  

function createTextElement(text) 
  return 
    type: "TEXT_ELEMENT",
    props: 
      nodeValue: text,
      children: [],
    ,
  

总结:
一个用于创建文本节点,一个用于创建真实的reac元素

render方法

我们知道render方法会触发虚拟dom进行diff运算。
现在我们可以试着写一个自己的render方法看看其原理。

  • 首先 我们现在添加一个方法到dom上。我们需要用到更新和删除相关方法。
  • 我们使用元素类型创建 DOM 节点,然后将新节点附加到容器中,也可以理解react中的app。
function render(element, container) 
  const dom = document.createElement(element.type)
​
  element.props.children.forEach(child =>
    render(child, dom)
  )
​
  container.appendChild(dom)

  • 创建一个函数,入参第一个是元素,第二个根容器。从根节点一层一层追加进去,有孩子就遍历。递归地为每个孩子做同样的事情。
 const dom =
    element.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type)
  • 我们还需要处理文本元素,如果元素类型是TEXT_ELEMENT我们创建一个文本节点而不是常规节点。
  • 我们需要再加上元素属性。
  • 我们对Key名遍历。我们把children放在props中。把children先过滤掉。对其他属性进行遍历,把所有属性都添加到dom属性上。
  • 现在做成了一个库可以支持jsx了
 const isProperty = key => key !== "children"
  Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => 
      dom[name] = element.props[name]
    )

并发模式

但是问题来了 如果render很复杂层级复杂
就会很卡
这个时候我们引入一个并发模式,进行重构
一旦开始渲染,递归是无法停止。直到吧所有的元素树都渲染上去。如果元素树很大,很有可能会阻塞浏览器的主线程。浏览器需要做一些高优先级任务的时候时,解决用户输入或者动画问题时。这将导致render不得不等待高优先级任务渲染完成。(说白了就会变得很卡)

unit

尝试重构时。引入一个unit概念,把大的render任务切割成小任务。当我们执行每一个单元,我们让浏览器中断渲染。(
把递归改造成一个一个小任务,允许浏览器中断,可以让浏览器执行优先级更高的任务)

我们使用requestIdleCallback 去做任务的切割,实现一个循环,你可以认为这个方法就是一个settimeout延时器,但是实际上运行时,浏览器会调用他的callback方法。当我们用这个方法执行任务时。

var t =requestIdleCallbac(Fn)

当我们直接调用fn()时,执行同步执行
如果按照上面代码这么执行,等于等待ts时间再来执行这个方法。t可以不确定,根据浏览器线程来决定。当主线程空闲的时候 ,我就执行他。请求空闲时间执行Fn。

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

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

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

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

schedule

react源码中自带的包
现在的react已经不使用requestIdleCallback了。现在使用调度程序包。但是对于这个用例,它在概念上是相同的

requestIdleCallback还给了我们一个截止日期参数。我们可以使用它来检查在浏览器需要再次控制之前还有多少时间。

执行单元工作

while (nextUnitOfWork)     
  nextUnitOfWork = performUnitOfWork(   
    nextUnitOfWork  
  ) 

  • 下一个单元任务,把整个render过程变成unitwork,用这些api不断执行下一个单元工作,每次执行完返回一个新的单元工作。进行一个循环,而每次循环都要返回一个新工作。
  • 要开始使用循环,我们需要设置第一个工作单元,然后编写一个performUnitOfWork函数,该函数不仅执行工作而且返回下一个工作单元。
  • 总结一下
    封装一个执行任务,执行一个后返回一个下一个
    通过requestIdlerequest切割成一个一个小单元

fiber的到来

Fiber 可以理解为一个执行单元,每次执行完一个执行单元,react 就会检查现在还剩多少时间,如果没有时间则将控制权让出去。
首先 React 向浏览器请求调度,浏览器在一帧中如果还有空闲时间,会去判断是否存在待执行任务,不存在就直接将控制权交给浏览器,如果存在就会执行对应的任务,执行完成后会判断是否还有时间,有时间且有待执行任务则会继续执行下一个任务,否则就会将控制权交给浏览器。

组织一个单元我们需要一个数据结构,用传统的虚拟dom很难再切割。我们现在构造一个新的结构。我们称之为fiberTree
每一个react元素都可以被创建成一个fiber。并且每一个fiber都是一个unitwork
假设我们现在要渲染一个tree

React.render(
  <div>
    <h1>
      <p />
      <a />
    </h1>
    <h2 />
  </div>,
  app
)

React Fiber 就是采用链表实现的。每个 Virtual DOM 都可以表示为一个 fiber,如下图所示,每个节点都是一个 fiber。一个 fiber包括了 child(第一个子节点)、sibling(兄弟节点)、return(父节点)等属性,React Fiber 机制的实现,就是依赖于以下的数据结构

为什么选择链表,因为链表可以使每个数据指针有所指引。更方便找到下一个节点

Fiber 是 React 进行重构的核心算法,fiber 是指数据结构中的每一个节点

render我们将创建根fiber并将其设置为nextUnitOfWork. 其余的工作将发生在performUnitOfWork函数上,我们将为每个fiber做三件事:

  1. 将元素添加到 DOM
  2. 为元素的子元素创建fiber
  3. 选择下一个工作单元

在设计这个数据结构的目标是使得找到下一个数据单元更加容易。

这种数据结构的目标之一是使查找下一个工作单元变得容易。这就是为什么每个fiber都有一个链接到它的第一个子节点、下一个兄弟节点和它的父节点。

当我们完成对光纤的工作时,如果它有Fiber,则child该光纤将是下一个工作单元。

在我们的示例中,当我们完成div Fiber的工作时,下一个工作单元将是h1 Fiber

如果Fiber没有child,我们将使用sibling作为下一个工作单元。

此外,如果parent没有 a sibling,我们会继续向上遍历parents 直到找到一个带有 a sibling或直到我们到达根。如果我们已经到了根,就意味着我们已经完成了为此的所有工作render。

Fiber节点设计

Fiber 的拆分单位是 fiber(fiber tree上的一个节点),实际上就是按虚拟DOM节点拆,我们需要根据虚拟dom去生成 Fiber 树。下文中我们把每一个节点叫做 fiber 。

简单点说逻辑

  • 每个Fiber都有三个指针指向他的第一个儿子,他的下一个兄弟,和他的父亲
  • 当unit工作完成,要完成下一个单元时,先完成第一个子Fiber。孩子优先级更高
  • 没孩子找直接兄弟
  • 没孩子没直接兄弟找叔叔
  • 没有叔叔继续往上找爸爸的叔叔
  • 一直找,找到root就结束

代码实现

首先,让我们从render函数中删除这段代码。

我们将创建 DOM 节点的部分保留在自己的函数中,稍后我们将使用它。

在render函数中我们设置nextUnitOfWork为FIBER Tree的根。

fiber.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(fiber.type)const isProperty = key => key !== "children"
  Object.keys(fiber.props)
    .filter(isProperty)
    .forEach(name => 
      dom[name] = fiber.props[name]
    )return dom
function render(element, container) 
  nextUnitOfWork = 
    dom: container,
    props: 
      children: [element],
    ,
  

然后,当浏览器准备好时,它会调用我们的workLoop,我们将开始在根节点上工作。

首先,我们创建一个新节点并将其附加到 DOM。

我们在fiber.dom属性中跟踪 DOM 节点。

然后我们为每个孩子创造一个新的纤维。

我们将它添加到纤维树中,将其设置为孩子或兄弟姐妹,具体取决于它是否是第一个孩子。

最后我们寻找下一个工作单元。我们首先尝试与孩子,然后是兄弟姐妹,然后是叔叔,依此类推。

这就是我们的performUnitOfWork.

function workLoop(deadline) 
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) 
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  
  requestIdleCallback(workLoop)
requestIdleCallback(workLoop)function performUnitOfWork(fiber) 
  if (!fiber.dom) 
    fiber.dom = createDom(fiber)
  if (fiber.parent) 
    fiber.parent.dom.appendChild(fiber.dom)
  const elements = fiber.props.children
  let index = 0
  let prevSibling = nullwhile (index < elements.length) 
    const element = elements[index]const newFiber = 
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    if (index === 0) 
      fiber.child = newFiber
     else 
      prevSibling.sibling = newFiber
    
​
    prevSibling = newFiber
    index++
  if (fiber.child) 
    return fiber.child
  
  let nextFiber = fiber
  while (nextFiber) 
    if (nextFiber.sibling) 
      return nextFiber.sibling
    
    nextFiber = nextFiber.parent
  

render和commit

在完成整个树的渲染时候,浏览器可能会中断我们的操作。因此我们需要remove那些修改代码的地方。在循环的时候不断在做dom操作,在render中不断生成fiber 不断生成dom。被中断之后把修改dom的代码删掉,减少dom操作。

dom操作在commit阶段

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

当创建好fiber Tree时 再一次性的提交所有的fiber到dom中。render可以说是异步的,render的方法不会霸占浏览器主线程。把一个个单元做完,返回一个大的fiberTree。
但commit不能中断,从根节点一层层追加。以此可以看到一个完整UI,commit会霸占完整的线程。

收集effect list

知道了遍历方法之后,接下来需要做的工作就是在遍历过程中,收集所有节点的变更产出effect list,注意其中只包含了需要变更的节点。通过每个节点更新结束时向上归并effect list来收集任务结果,最后根节点的effect list里就记录了包括了所有需要变更的结果。

收集effect list的具体步骤为:

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

到目前为止,我们只向DOM添加了东西,但是更新或删除节点呢?

这就是我们现在要做的,我们需要将在render函数上接收到的元素与我们提交给 DOM 的最后一个 Fiber 树进行比较。

因此,我们需要在完成提交后保存对“我们提交给 DOM 的最后一个FiberTree”的引用。我们称之为currentRoot。

将alternate属性添加到每个fiber中。该属性是旧fiber的链接,即我们在前一个提交阶段提交给 DOM 的纤程。

现在让我们从中提取performUnitOfWork创建新纤程的代码……

…到一个新的reconcileChildren功能。

协调旧fiber与新element(diff运算)

同时迭代旧纤程 ( wipFiber.alternate) 的子元素和需要协调的元素数组。
如果我们忽略同时迭代数组和链表所需的所有样板文件,那么我们只剩下 while:oldFiber和 中最重要的内容element。这element是我们想要渲染到 DOM 的oldFiber东西,也是我们上次渲染的东西。
我们需要比较它们以查看是否需要对 DOM 应用任何更改。

为了比较它们,我们使用以下类型:

  1. 如果旧的 Fiber 和新的元素具有相同的类型,我们可以保留 DOM 节点并使用新的 props 更新它
  2. 如果类型不同并且有新元素,则意味着我们需要创建一个新的 DOM 节点(不做dom操作了)
  3. 如果类型不同并且有旧Fiber,我们需要删除旧节点

这里 React 也使用了Key(如果有key,更好的diff),这可以更好地协调。例如,它检测子元素何时更改元素数组中的位置。
如果有多个孩子,位置会发生变化,有利于更好的diff运算,也可以说是更好的协调

当旧纤程和元素具有相同的类型时,我们创建一个新fiber,保留旧fiber中的 DOM 节点和元素中的 props。

我们还为纤程添加了一个新属性:effectTag. 我们稍后会在提交阶段使用这个属性。

 const sameType =
      oldFiber &&
      element &&
      element.type == oldFiber.type
​
    if (sameType) 
      newFiber = 
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE",
      
    

元素需要一个新的 DOM 节点的情况,我们用PLACEMENTeffect 标签来标记新的 Fiber。(新Fiber是由旧Fiber计算而来,并且给新Fiber添加标记)

对于我们需要删除节点的情况,我们没有新的Fiber,所以我们将效果标签添加到旧光纤。

但是当我们将纤程树提交到 DOM 时,我们是从正在进行的工作根中完成的,它没有旧fiber。

所以需要一个数组来跟踪我们想要删除的节点。

然后,当我们将更改提交到 DOM 时,我们也会使用该数组中的Fiber。

现在,让我们更改commitWork函数来处理新的effectTags.

如果纤程有PLACEMENT效果标签,我们和之前一样,将 DOM 节点附加到来自父纤程的节点。

如果是DELETION,我们做相反的事情,移除孩子。

如果是UPDATE,我们需要使用更改的 props 更新现有的 DOM 节点。

UpdateDom

这个updateDom函数中做到真实的dom操作。

我们将旧光纤中的道具与新光纤的道具进行比较,将消失的属性移除,并设置新的或更改的属性。

我们需要更新的一种特殊道具是事件侦听器,因此如果道具名称以“on”前缀开头,我们将以不同的方式处理它们。
先删旧的事件处理器,再添加新的事件处理器
如果事件处理程序发生变化,我们将其从节点中删除。
然后我们添加新的处理程序。

总结

新fiber一定从旧fiber来
生成新fiber一定要依据旧fiber计算而来
每一个新fiber都要跟旧fiber做一个指针链接
添加effcetTag
1.更新:dom节点不同删除只更新属性
2.删除:删掉这个节点
3.插入:插入新节点
这个最终交给commitWork来使用,根据标签来操作。进行diff/协调阶段

在函数式组件中

使用这个简单的函数组件,它返回一个h1元素。

function App(props) 
  return Didact.createElement(
    "h1",
    null,
    "Hi ",
    props.name
  )

const element = Didact.createElement(App, 
  name: "foo",
)

函数式组件在两个方面有所不同:

函数组件的 Fiber 没有 DOM 节点 并且子节点们来自调用该函数而不是直接从 props
我们检查Fiber类型是否是一个函数,并根据它转到不同的更新函数。

在updateHostComponent我们做的和以前一样。

在updateFunctionComponent我们运行函数来获取子节点。

对于我们的示例,这里fiber.type是App函数,当我们运行它时,它返回h1元素。

然后,一旦我们有了孩子,和解就以同样的方式进行,我们不需要在那里改变任何东西。

我们需要改变的是commitWork函数。

现在我们有了没有 DOM 节点的纤程,我们需要改变两件事。

首先,为了找到一个 DOM 节点的父节点,我们需要沿着纤程树向上移动,直到找到一个带有 DOM 节点的纤程。

当删除一个节点时,我们还需要继续下去,直到找到一个具有 DOM 节点的子节点。

以上是关于随笔:关于Fiber架构的一点点理解的主要内容,如果未能解决你的问题,请参考以下文章

随笔:关于Fiber架构的一点点理解

随笔:关于Fiber架构的一点点理解

前端大佬谈 React Fiber 架构

手写React的Fiber架构,深入理解其原理

Deep In React之浅谈 React Fiber 架构

推荐手写React的Fiber架构,深入理解其原理