需求做完不知道干什么?那就写个React吧

项目地址:https://github.com/4401271/MyReact
Note: index.js 需要搭配 index.html 使用,因为存在多个版本,所以需要保证两个文件前缀名相同!

       鲁迅说过:需求就像奶牛的奶,只要愿挤,总会是有的。可是!我负责的项目,已经有一段时间没有产出过什么新需求了(看来,是时候加草投料了~)。

       于是!大臂一挥,pia!

       天天与这 React 挤眉弄眼、眉来眼去。不如~ 去其体肤,察其核核(hú)。

       尝试着自己实现一个缩减版的 React。通过这篇文章,捋一下自己的实现逻辑,以及代码执行流程。

文章若有错,大佬莫留情!肆意地挥舞起你的手指,我已经做好了接招的准备


第一步,当然还是要从创建一个组件开始:

一、JSX 编译

以 index1.js 与 index1.html 这两个文件为例:

       配置缘故,在 index1.js 中写 JSX,默认使用原生 React 的 createElement,尽管引入的是自己的 React,但并不会使用我们自己写的 createElement

       因此我们需要先借助 Babel 将 JSX 编译为 JS ,这样,就可以显式地调用我们自己写的 createElement ,这是一段 index1.js 中的代码:

let style = {border: '2px solid skyblue', margin: '5px', borderRadius: '7px'}

let element = (
  <div id='A1' style={style}>A1
    <div id='B1' style={style}>B1
      <div id='C1' style={style}>C1</div>
      <div id='C2' style={style}>C2</div>
    </div>
    <div id='B2' style={style}>B2</div>
  </div>
)

JSX 部分借助 Babel 编译后,变成了这样
avatar
整理一下,就变成了我们需要的 JS 代码:

let element = React.createElement("div", {id: "A1",style: style}, "A1", 
  React.createElement("div", {id: "B1",style: style}, "B1", 
    React.createElement("div", {id: "C1",style: style}, "C1"), 
    React.createElement("div", {id: "C2",style: style}, "C2")
  ), 
  React.createElement("div", {id: "B2",style: style}, "B2")
);

可以很清除地看到我们向 React.createElement 传递的各个参数,即标签的各种属性。
强迫症患者看到这段代码没有一种很爽的感觉?反正我看完感觉很舒服~
avatar

createElement

作用:根据参数,创建对应的 虚拟DOM

export const ELEMENT_TEXT = Symbol.for('ELEMENT_TEXT');

function createElement(type, config, ...children) {
  // 标签没有属性时,config为null
  if (config) {
    delete config._self;
    delete config._source;
  }
  return {
    type,
    props: {
      ...config,
      children: children.map(child => {
        return typeof child === 'object' ?
          child :  // 虚拟 DOM 对应着一个对象
          {        // TEXT 对应着一个字符串
            type: ELEMENT_TEXT,
            props: { text: child, children: [] }
          }
      })
    }
  }
}

借助 createElement 创建出来的虚拟 DOM
avatar
虚拟 DOM 各个参数的具体含义如下:

  1. type: ‘div’(标签名);文本节点 type 值对应 'Symbol(ELEMENT_TEXT)'
  2. props: 标签处倘若写了一个 {null},那么 props 就为 null
  3. style: {border: “2px solid skyblue”}(样式)
  4. onClick: ()=>{}(绑定的事件)
  5. id=“btn”:(标签属性)
  6. children: [ text="A1"(标签文本)、{子虚拟DOM1}{子虚拟DOM2}{子虚拟DOM3}、… ]

可以看到,调用 createElement 时,传入的第 2~n 个参数,最终都被整合到了 props 中。其中,标签文本也被作为 children 的一个元素保存了起来。


       到这里,我们学会了如何去创建一个虚拟 DOM,也了解了虚拟DOM的结构。接下来,就需要一步一步地探索,这虚拟 DOM 是干啥用的。
欲知后事如何,请看下文:
avatar


二、render

组件创建完成后,将组件和 DOM 容器传递到 render 函数中去,像这样:

ReactDOM.render(
  element,
  document.getElementById('root')
)

render 函数有什么作用?

function render(element, container) {

  let rootFiber = {
    tag: TAG_ROOT,
    stateNode: container,
    props: { children: [element] }
  }

  scheduleRoot(rootFiber);
}
  1. 创建一个根 fiber:rootFiber
  2. 将 id 为 root 的真实 DOM 通过 stateNode 属性绑定在 rootFiber
  3. 将 createElement 创建出来的虚拟 DOM 通过 props 属性绑定在 rootFiber
  4. rootFiber 做为参数调用函数 scheduleRoot

这里引入了一个概念:fiber。首先,我们需要先明确

1. 为什么需要引入 fiber?

       v16 之前,更新过程是同步的,从调用各个组件的生命周期函数、计算、比对虚拟 DOM,到最后更新 DOM 树,整个过程必须要一气呵成
       随着互联网的发展,页面开始日益复杂,有时更新完页面中的所有组件,甚至需要花上数百毫秒的时间。这期间,用户与页面的任何交互将不会有任何反馈,这样的体验显然是很不友好的。
avatar
avatar
       我们知道 JavaScript 是单线程语言,一个任务花费太长的时间,就为导致其他任务无响应。因此,解决这个问题就显得尤为突出!
       解决 JavaScript 中同步操作时间过长的方法——分片。“分片” 的设计就是基于 fiber 来实现的。

2. fiber 是什么?

fiber 是一个用来描述节点的对象,相较于虚拟 DOM,它包含的节点信息更加丰富。一起来看一下初次渲染时创建的 fiber 对象:

newFiber = {
  tag,
  type: sonVDOM.type,
  props: sonVDOM.props,
  stateNode: null,
  return: fiber,
  updateQueue: new UpdateQueue(),
  effectTag: PLACEMENT,
  nextEffect: null
}
  • tag: 节点类型,值包括为:

    • TAG_TEXT:文本类型,标签之间的文本即为该类型
    • TAG_HOST:原生节点类型,例如:div 标签、span 标签等
    • TAG_CLASS:类式组件
    • TAG_FUNCTION:函数式组件
  • type:调用 createElement 时传入的第一个参数:‘div’、‘span’、‘h1’…

  • props:标签属性:{id=“A1” style={style} onClick=()=>{} …}

  • stateNodefiber 对应的真实 DOM

  • updateQueue: 更新队列。每一个 fiber 都有一个 updateQueue。该属性只在 “类式组件” 与 “类式组件” 中有实际意义(下文会详细介绍)

  • effectTag:副作用标识,标识 DOM 发生了什么样的变化,值包括:

    • PLACEMENT:新增
    • DELETE:删除
    • UPDATE:更新
  • nextEffecteffect list 是一个单链表,该链表上保存着所有的 “发生了变化” 的 DOM 对应的 fiber。我们知道,React 并不会在每遇到一个变化,就去更新一次页面。而是将所有变化的 DOM 对应的 fiber 收集起来,最终只做一次更新(暂不考虑 offsetLeft、clientTop 等需要实时获取最新数据的属性),来降低由于频繁重绘重排来带的巨大性能开销

  • alternate:指向上一次渲染时的 fiber树 中对之应的 fiber 节点

  • return:指向 fiber父fiber,与 child、sibling 属性一同用于构建 fiber树

  • child:指向 fiber第一个 子fiber

  • sibling:指向 fiber弟弟fiber

3. fiber 树是什么样的结构?

       所有借助 createElement 创建的虚拟 DOM,都会对应一个 fiber 节点;每个组件也会对应一个根 fiber。根据层级关系,借助 fiber 节点的 childsiblingreturn 属性,将所有的 fiber 连接起来,形成了最终的 fiber树。所以说 fiber树 是一个链表结构,但并非单链表。
avatar


三、scheduleRoot

scheduleRoot 只做一件事情:更新 workInProgressRootFibercurrentRenderRootFiber 存储的根节点,及其 alternate 属性的指向。

页面可能会被无限次重新渲染,但维护的 fiber 树,就只有两棵:

  • 一棵为此次渲染正在构建的 fiber 树,其根节点用全局变量 workInProgressRootFiber 来保存
  • 另一棵为页面上次渲染时构建的 fiber 树,根节点用全局变量 currentRenderRootFiber 来保存

       第二次渲染结束后,页面需要再次重新渲染时,直接复用上上次构建或更新的 fiber 树,这样就可以节省出大量的:由于创建 fiber 对象所消耗的时间与空间。

这就是 React 优化核心之一的:双缓冲机制

整体的流程大概是这样:

  • 第一、二次渲染,各构建一棵新的 fiber 树;
  • 第三次渲染,直接拿第一次构建的 fiber 树来用,同时让 fiber 节点的 alternate 属性,指向第二次构建的 fiber 树中的对应节点;
  • 第四次渲染时,拿来第二次构建的 fiber 树,修改 fiber 节点的 alternate 属性,让其指向第三次渲染时更新的 fiber 树的对应节点;
  • 第五次渲染,拿第三次渲染更新的 fiber 树来用 …
export function scheduleRoot(rootFiber) {
  // 第 3、4、5 ... 次渲染
  if (currentRenderRootFiber && currentRenderRootFiber.alternate) { 
    workInProgressRootFiber = currentRenderRootFiber.alternate;
    workInProgressRootFiber.alternate = currentRenderRootFiber;
    if (rootFiber) workInProgressRootFiber.props = rootFiber.props;
    
  // 第 2 次渲染
  } else if (currentRenderRootFiber) { 
    if (rootFiber) {
      rootFiber.alternate = currentRenderRootFiber;
      workInProgressRootFiber = rootFiber;
    } else {
      workInProgressRootFiber = {
        ...currentRenderRootFiber,
        alternate: currentRenderRootFiber
      }
    }
    
  // 第 1 次渲染
  } else { 
    workInProgressRootFiber = rootFiber;
  }

  workInProgressRootFiber.firstEffect = workInProgressRootFiber.lastEffect = workInProgressRootFiber.nextEffect = null;
  currentFiber = workInProgressRootFiber;
}

scheduleRoot 的任务:

  • 第 1 次渲染 标志 :currentRenderRootFiber 为空

       让 workInProgressRootFiber 指向传入的第一个根 fiber。
       渲染过后,把 workInProgressRootFiber 的值赋给 currentRenderRootFiber (操作位于 commitRoot 中),currentRenderRootFiber 也就指向了第一个根 fiber。

  • 第 2 次渲染 标志:currentRenderRootFiber 非空,但 currentRenderRootFiber 上并不存在 alternate 属性

       将 currentRenderRootFiber 指向的第一个根 fiber,赋给刚传进来的第二个根 fiber 的 alternate 属性;然后把第二个根 fiber 赋给 workInProgressRootFiber,此时,workInProgressRootFiber.alternate 指向第一个根 fiber。
       渲染过后,把 workInProgressRootFiber 的值赋给 currentRenderRootFiber (操作位于 commitRoot 中),这样 currentRenderRootFiber.alternate 也就指向上上一棵 fiber 树的根 fiber。

  • 第 3、4… 次渲染 标志:currentRenderRootFiber 非空,且 currentRenderRootFiber.alternate 属性也非空。

       将 currentRenderRootFiber.alternate 指向的上上一个根 fiber,赋给 workInProgressRootFiber,这样就完成了对第一个根 fiber 的复用;然后再把 currentRenderRootFiber 中保存的上一个根 fiber,赋给当前 fiber 树的 alternate 属性,就完成了与上一棵 fiber 树的关联。


四、requestIdleCallback

       阅读代码可以发现,scheduleRoot 就像一座孤岛,我们调用了 scheduleRoot 函数,但 scheduleRoot 却没有调用其他任何函数,那么其他函数是怎么被使用的呢?
avatar
       梳理一下代码的逻辑,可以看到,其余函数被调用的起点在 workLoop,该函数只在 requestIdleCallback 中被调用了:

requestIdleCallback(workLoop, { timeout: 500 });

于是,问题就变成了: requestIdleCallback 在什么时候被调用?

答:浏览器每刷新一帧,就会被调用一次。在一帧存在空闲时间时,执行传递的回调函数。
区别于 requestAnimationFramerequestAnimationFrame 的回调会在每一帧确定执行,属于高优先级任务;而 requestIdleCallback 的回调则不一定,属于低优先级任务

总的来说,在当前的场景中,requestIdleCallback 就是让浏览器在执行完别的任务时,判断时间片是否还有剩余的时间,如果有,就执行传入的 workLoop 任务。假如连续 500ms 都没有执行 workLoop,就强制执行该任务。

       但是原生的 requestIdleCallback 每秒只有 20 帧。

(20帧 / 1000ms = 1帧 / 50ms、60帧 / 1000ms = 1帧 / 16.7ms)

       也就是说,每个时间片是 50ms,隔 50ms 才会刷新一次,其流畅程度远比我们感觉标准的 16.7ms 要低很多。所以,在 React 的源码中,需要实现了一个更加流畅的 requestIdleCallback。(该函数不是我们关注的重点,暂时就不实现了)


五、workLoop

function workLoop(deadline) {

  let shouldYield = false; // false表示不需要让出时间片/控制权

  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork); // performUnitOfWork: 执行一个任务,返回下一个任务
    shouldYield = deadline.timeRemaining() < 1; // 剩余时间小于1ms时,没有剩余时间,shouldYield置为true,表示需要让出控制权
  }
  if (!nextUnitOfWork && workInProgressRootFiber) { // 时间片到期后,还有任务还尚未完成,就需要请求浏览器再次调度
    console.log('render阶段结束');
    commitRoot();
  }

  requestIdleCallback(workLoop, { timeout: 500 }); // 不论是否有任务,都去请求调度。每一帧在浏览器完成自己的任务后,如果有剩余时间,就执行一次workLoop,确保在有任务时,能够被及时执行
}

我们一起来根据上面的代码,梳理一下 workLoop 的工作:

  1. 调用 performUnitOfWork 执行一个 “任务”,返回它的下一个 “任务”(可中断的 “任务”,即为该 “任务”)
  2. 判断是否存在下一个 “任务”,并且当前时间片还有剩余的时间
  3. 如果二者都满足,就循环地执行 “任务”,返回下一个 “任务”,执行 “任务”,返回下一个 “任务”
  4. 如果是 “任务” 执行完毕,没有了下一个 “任务”,就表示 rereconcileChildren阶段 结束,接下来就开始调用 commitRoot 去修改 DOM
  5. 如果是时间片的时间被使用完毕,就再次调用 requestIdleCallback,在下次获得执行权时,延续上次被搁置的 “任务”,继续循环执行,直到所有的 “任务” 都被执行完毕

avatar
“任务” 的具体内容,相对较为复杂,需要通过下面的函数来一点一点地分析。在 八、performUnitOfWork 中,会对 “任务” 进行一个概括。


六、reconcileChildren

function reconcileChildren(fiber, vDOMArrOfChildrenOfFiber) 

从使用可以看到,该函数接收两个参数:

  • 第一个参数为一个 fiber 节点,也就是下面说的父 fiber
  • 第二个参数为该 fiber 节点的子虚拟 DOM 组成的数组

reconcileChildren 的功能:

创建出所有的子虚拟 DOM 对应的 fiber 节点,父 fiber (reconcileChildren 的第一个参数)通过 child 属性与第一个子 fiber 相连接,每一个子 fiber 再通过 sibling 属性与下一个子 fiber 连接。

1. 为虚拟 DOM 创建对应的 fiber
我们一起来看一下具体过程:

       首先,借助 fiber.alternate,找到父 fiber 在上一棵 fiber 树中,与之对应的 fiber 节点: oldFiber。然后通过 child 属性获得 oldFiber 的第一个子 fiber:oldSonFiberoldSonFiber 与 vDOMArrOfChildrenOfFiber 的第一个子虚拟 DOM 相对应,并且,oldSonFiber 正是我们要复用的 fiber 对象。

const sameType = oldSonFiber && sonVDOM && oldSonFiber.type === sonVDOM.type;

sameType 为 true,判断 oldSonFiber 上是否存在 alternate 属性:

  • 若存在,就表示该节点已经是第 3、4、5…次渲染,直接将上上次被渲染的节点拿过来使用(可复用)。复用 fiber 时,并没有为 fiber 的 stateNode 属性重新赋值,这就表示其对应的真实 DOM 节点也是可以复用的。
  • 若不存在,一定是第 2 次渲染,也需要重新创建一个 fiber(不可复用),注意:第二次渲染一个节点时,我们需要为该节点添加 alternate 属性,并让其指向 oldSonFiber

(原生的 React 对是否可复用的判定逻辑及处理,会更加复杂一些,比如还会添加对 key 的判断等等)

sameType 为 false:

  • 可能是由于不存在 oldSonFiber,也就是说:在此之前不存在与之对应的 fiber 节点,此时可以确定当前节点一定是第 1 次被渲染(不可复用)
  • 也有可能由于 oldSonFibersonVDOM 所指向的节点标签名不同,此时只能确定该节点至少已经渲染过一次,这两种情况都需要创建新的 fiber(不可复用)
  • sonVDOM 为 null 时 sameType 也为 false,这种情况不需要新建 fiber(不可复用)

到这里,我们就成功地为一个虚拟 DOM 创建出其对应的 fiber 节点reconcileChildren 的第一个任务完成。

2. 收集被删除的 fiber
       sameType 为 false 表示:当前 fiber 树上并不存在与 oldSonFiber 相对应的虚拟 DOM。因此还需要将 oldSonFibereffectTag 属性标记为删除,同时将其推入删除数组 deletions 中。在渲染前,会先遍历该数组,将上一棵 fiber 树中对应的节点删除。这样,下次复用该树时,就可以避免一些干扰了。

3. 将子 fiber 进行连接
       首先,需要确定该 fiber 是父 fiber 的第几个子 fiber,通过 vDOMArrOfChildrenOfFiber 的下标 arrIndex 就可以直接判断出来

  • arrIndex === 0 表示第一个子 fiber,为父 fiber 添加 child 属性,将其挂载到父 fiber 的 child 属性上
  • 否则表示不是第一个子 fiber,为前一个子 fiber 添加 sibling 属性,并将其挂载到前一个子 fiber 的 sibling 属性上

       成功处理完了一个虚拟 DOM,借助 `oldSonFiber = oldSonFiber.sibling; ` 取出 oldSonFiber 的兄弟节点;同时让 `arrIndex` +1,取出下一个虚拟 DOM,再次执行上述方法,继续为虚拟 DOM 创建或复用 fiber。直到成功处理完 vDOMArrOfChildrenOfFiber 中所有的虚拟 DOM。



七、beginWork

fiber 的 tag 标识着 fiber 的种类,beginWork 的任务:

根据 tag 的不同,为 fiber 创建出对应的真实 DOM,挂载到 fiber 的 stateNode 属性上。
取出 fiber 的子虚拟 DOM 数组,交给 reconcileChildren 来将所有虚拟 DOM 转化为 fiber 并通过 child、sibling 进行连接。


1. TAG_ROOT:根 fiber(rootFiber):

       这种情况下,fiber 对应的真实 DOM,其实就是 id 为 root 的 DOM 容器。我们已经在 render 中将其挂载到了 rootFiber 的 stateNode 属性上。这里,我们就只需要取出其子虚拟 DOM 数组,交给 reconcileChildren。

2. TAG_TEXT:文本节点

       文本节点不存在后代,也就不需要调用 reconcileChildren。只需要根据文本内容,借助document.createTextNode(fiber.props.text) 创建出对应的文本节点,然后绑定到 fiber 的 stateNode 属性上。

3. TAG_HOST:标签节点

       标签节点需要借助 document.createElement(fiber.type) 创建出对应的标签。标签可能包含一些如 id、style、onClick 等属性,他们都存储在 fiber 的 props 属性上。

function updateDOM(DOM, oldProps, newProps) {
  if (DOM && DOM.setAttribute){
    for (let key in oldProps) {
      if (key !== 'children') {
        if (newProps.hasOwnProperty(key)) { // 1. 原来有,现在也有 - 更新
          setProps(DOM, key, newProps[key]);
        } else {
          DOM.removeAttribute(key); // 2. 原来有,现在没 - 删除
        }
      }
    }
    for (let key in newProps) {
      if (key !== 'children') {
        if (!oldProps.hasOwnProperty(key)) { // 3. 原来没,现在有 - 增加
          setProps(DOM, key, newProps[key]);
        }
      }
    }
  }
}
function setProps(DOM, key, value) {
  if (/^on/.test(key)) { // 事件
    DOM[key.toLowerCase()] = value;
  } else if (key === 'style') { // 样式
    if (value) { // value: {border: '2px solid skyblue', margin: '5px', borderRadius: '7px'}
      for (let styleName in value) {
        DOM.style[styleName] = value[styleName]; // 为DOM的style属性添加名为border的样式,值为value对象中border对应的值
      }
    }
  } else { // 一般属性
    DOM.setAttribute(key, value);
  }
}

我们就需要判断一个属性在复用 fiber 前,是否已经存在了

  • 原来没有该属性
    • 现在有 - 添加属性
      • 属性是事件:借助 DOM[key.toLowerCase()] = value; 为 DOM 添加一个事件名同名的属性,值为事件对应的函数
      • 属性是样式:借助 DOM.style[styleName] = value[styleName]; 将属性添加到 DOM 的 style 属性中
      • 一般属性:借助 DOM.setAttribute(key, value); 将属性添加到 stateNode 指向的标签中
  • 原来有该属性
    • 现在也有,只需要再走一遍 “原来没有,现在有” 的逻辑,将属性值重新赋值一次,以实现更新属性的目的
    • 现在没有,借助 DOM.removeAttribute(key); 删除属性

更新完 DOM 的属性,将 DOM 挂载到 fiber 的 stateNode 上。

最后依旧是拿出 fiber.props.children 存储的虚拟 DOM 数组,然后交给 reconcileChildren。

4. TAG_CLASS:类式组件

// fiber.stateNode指向组件实例,组件实例的internalFiber指向fiber对象
fiber.stateNode = new fiber.type(fiber.props);
fiber.stateNode.internalFiber = fiber;

       在类式组件中,需要我们 new 一个 组件实例,同时,将组件参数作为实例化时的参数,传至 constructor 的 props 中,虚拟 DOM 就可以直接通过 this.props.xxx 获取到这些属性。

       然后将 组件实例 绑定到 fiber 的 stateNode 属性上,再将 fiber 绑定到 组件实例internalFiber 属性上。

setState(payload) { // payload可能是对象,也可能是函数
  let update = new Update(payload);  // 将payload挂载到update对象上
  this.internalFiber.updateQueue.addUpdate(update); // updateQueue放在类组件的fiber节点的internalFiber上
  scheduleRoot();
}
export class Update {
  constructor(payload) {
    this.payload = payload;
  }
}

       我们调用 setState 更新状态时,就是通过 组件实例.internalFiber 先拿到 fiber,然后就可以通过 fiber.updateQueue.addUpdate(update) 将封装好的 state 放入更新队列中。
avatar
这里就可以解释,为什么 setState 更新 state 是异步的

       其实,就是因为我们在调用 setState 时,并没有立即对 state 进行更新。而是先通过 new Update(payload); 将更新的 state 封装进一个对象中。然后将这个对象通过 addUpdate 添加至 fiber 的 updateQueue 也就是 更新队列 中。

       我们与页面进行一次交互,可能会触发多个 setState,所有 setState 的参数在封装过后,都会被添加至 更新队列 中。更新队列 是一个链表结构。最后在我们为类式组件构建 fiber 时,执行 fiber.stateNode.state = fiber.updateQueue.forceUpdate(fiber.stateNode.state);,遍历链表,就可以一次性的将所有的 setState 执行完毕,然后将最新的 state 赋值给 组件实例 的 state 对象。

总结:类式组件的执行逻辑
       在 index3.js 中,我们调用了 ReactDOM.render,参数分别为类式组件和容器 DOM。

       此时类式组件并没有被执行,紧接着就进入到了 react-dom.js 中的 render 方法,创建 rootFiber,stateNode 当然关联的依旧是容器 DOM,props.children 中存储着类式组件。

       注意!只有调用类组件实例的 render 方法,才会将虚拟 DOM 返回!因此我们在判定组件为类式组件时,虚要手动调用 fiber.stateNode.render(); 方法,为的就是拿到类式组件 调用 render 函数后,return 的虚拟 DOM,这样才能再去执行 reconcileChildren 函数。

       类式组件 return 的虚拟 DOM 是一个对象,而 reconcileChildren 处理的是虚拟 DOM 数组,所以我们还需要将其放至一个空数组中,然后再传入 reconcileChildren。

5. TAG_FUNCTION:函数式组件

       首先,我们需要指定两个全局变量:funComponentFiberhookIndex,作用我们后面再讨论。

funComponentFiber = fiber;
hookIndex = 0;
funComponentFiber.hooks = [];

       函数式组件不需要为其实例化对象,所以由 begin 进入对应处理函数 updateFunctionComponent 时,需要做的事情也就很少:
首先,将 “函数组件对应的 fiber” 赋给 funComponentFiber
其次,为每个函数组件添加一个 hooks 属性,用来存储组件中添加的一个个 hook;
紧接着,初始化 hookIndex,每次渲染时,都需要先对 hookIndex 初始化,才能依次拿到初次渲染时创建的 hook,依次进行操作。hooks 是一个数组,里面的每一个 hook 都和 hookIndex 相对应。

const vDOMArrOfChildrenOfFiber = [fiber.type(fiber.props)];

       我们知道,调用 函数式组件 内的函数,即可拿到被返回的虚拟 DOM。 调用的同时,将函数组件的参数传递到函数中,这样虚拟 DOM 就可以通过 props.xxx 拿到对应的参数值。

       拿到了被返回的虚拟 DOM,剩下的任务依旧是交给 reconcileChildren。

6. useReducer

       分析完了函数式组件,不如趁热打铁,顺带分析一下 hooks 中的 useReducer 是如何工作的。

useState 基于 useReducer,这里我们就重点分析一下 useReducer

我们一起来回忆一下 useReducer 的用法:

const ADD = 'ADD';
function reducer(state, action) {
  switch (action.type) {
    case ADD:
      return {count: state.count+1};
    default:
      return state;
  }
}

function FunctionCounter(props){
  const [countState, dispatch] = React.useReducer(reducer, {count: 0});
  return (
    <div>
	  <div>{countState.number}</div>
      <button onClick={dispatch({ type: ADD })}>戳一下 +1</button>
    </div>
  )
}
ReactDOM.render(
  <FunctionCounter name="计数器"/>,
  document.getElementById('root')
)

useReducer 接收两个参数:

  • 能够根据 “行为” 处理 state 的 reducer
  • 初始状态 initialValue

从使用来看,可以知道 useReducer 返回一个数组,数组中一定包含这两个数据:

  • 状态
  • 能够改变状态的 dispatch 函数

拿到 dispatch 后,可以通过向 dispatch 传递指定的 “行为”,来改变 countState 的值。


我们来分析一下 useReducer 具体是如何实现的:

let hook = funComponentFiber.alternate && // 第一次渲染时 hook 值为 undefined
  funComponentFiber.alternate.hooks && 
  funComponentFiber.alternate.hooks[hookIndex];

if (hook) { // 第2、3...次渲染
  hook.state = hook.updateQueue.forceUpdate(hook.state); 
} else { // 第1次渲染
  hook = {
    state: initialValue,
    updateQueue: new UpdateQueue()
  }
}

       第一次渲染时,用 funComponentFiber 存储:为函数式组件创建的第一个 fiber,此时的 funComponentFiber 并没有 alternate 属性;
       第二次渲染时,funComponentFiber 存储着为函数式组件创建的第二个 fiber,此时 funComponentFiber.alternate 指向在第一次渲染时,为函数式组件创建的第一个 fiber。
       这样,通过 alternate 属性我们就可以知道当前是否为第一次渲染。


假设我们在组件中调用了两次 useReducer,为组件添加了两个 hook

const [countState1, dispatch] = React.useReducer(reducer, {count1: 0}); // 第一个 hook
const [countState2, dispatch] = React.useReducer(reducer, {count2: 0}); // 第二个 hook

页面初次渲染
       代码执行至组件第一次调用 React.useReducer,于是进入 useReducer 函数,通过 alternate 判定为组件第一次被渲染。首先新建一个 updateQueue 更新队列,与初始的 state 一并封装进一个名为 hook 的对象中。
       然后通过 funComponentFiber.hooks[hookIndex++] = hook; 将该 hook 对象添加至函数式组件对应 fiber 的 hooks 属性中,同时让 hookIndex +1。此时 hookIndex = 0 就与第一个 hook 绑定。
       最后返回一个数组,数组第一个元素即为我们需要的 state,此时它里面存储着初始化的 state:{count1: 0};第二个元素为函数 dispatch


       紧接着,由于我们又调用了一次 React.useReducer,进入 useReducer 函数后,通过 alternate 发现组件依旧是第一次被渲染,那么再创建一个 hook 对象。
       此时 hookIndex = 1,通过 funComponentFiber.hooks[hookIndex++] = hook; 将第二个 hook 对象添加至函数式组件的 hooks 属性中。hookIndex = 1 就与第二个 hook 绑定。

const dispatch = action => { // action: {type: ADD}
  // reducer:
  // function reducer(state, action) {
  //   switch (action.type) {
  //     case ADD:
  //       return {count: state.count+1};
  //     default:
  //       return state;
  //   }
  // }
  let payload = reducer ? reducer(hook.state, action) : action; // 传入reducer时,就根据reducer和对应的action计算出对应的state
  hook.updateQueue.addUpdate(
    new Update(payload)
  );
  scheduleRoot();
}

       点击第二个 hook 关联的按钮,触发点击事件。首先,向 dispatch 传入一个 “行为” { type: ADD } 并调用该函数,函数首先通过 reducer 根据 “行为” 计算出更改后的 state,保存在变量 payload 中。然后借助 addUpdate 将该 state 添加至 更新队列 中,同时让第二个 hookfirstUpdatelastUpdate 均指向该 state。最后借助 scheduleRoot(); 重新渲染一下页面。

进入第二次渲染

       调用 scheduleRoot 函数会更新 workInProgressRootFibernextUnitOfWork 的值。
       requestIdleCallback(workLoop, { timeout: 500 }); 调用 workLoop 时发现存在 nextUnitOfWork,于是开始通过 performUnitOfWork 调用 beginWork
       当前组件为函数式组件,于是通过 beginWork 进入 updateFunctionComponent 函数,在该函数中将 hookIndex 置为 0,执行到 const vDOMArrOfChildrenOfFiber = [fiber.type(fiber.props)]; 时,开始调用函数式组件

       注意!虽然我们只点击了绑定第二个 hook 的按钮,似乎与第一个 useReducer 无关,但由于触发第二次渲染时,执行的是 “调用函数式组件”,所以依旧会走两遍 React.useReducer

首次调用 React.useReducer

       在 useReducer 中,通过 alternate 发现当前并非第一次渲染,于是,先借助 funComponentFiber.alternate.hooks[0]; 拿到上一次为函数式组件创建的 fiber,其 hooks 属性中存储的第一个 hook。用 hook 变量存储。
       那么 hook.state 就表示上次渲染时的 state,将其传入 forceUpdate 函数。我们并未点击第一个 useReducer 对应的按钮,所以不会触发第一个 hookaddUpdate 方法,因此其 firstUpdate 属性为 null。那么在 forceUpdate 函数中就会直接借助 return state; 将老的 state 返回,并将其存储在 hook 的 state 属性中。
       然后借助 funComponentFiber.hooks[hookIndex++] = hook; 将上面处理好的 hook 放到:第二次渲染为函数式组件创建的 fiber 的 hooks 中,同时让 hookIndex + 1,然后 return。
       注意!此时仅仅改变了第一个 useReducer 中的 state。

第二次调用 React.useReducer

       第二次调用 React.useReducer 时 hookIndex 已从 0 变为 1,因此获取的就是第二个 hook。执行 forceUpdate 时发现 hook.firstUpdate 并不为空,其次,我们知道 payload 是一个对象,其内部存储着点击按钮时,计算出的最新的 state,此时就需要将 state 赋给 nextState,然后借助 state = { ...state, ...nextState } 对点击按钮前后的 state 进行一个合并,return 出去后赋给 hook.state,最后将 hook 存放至:第二次渲染时,为函数式组件创建的 fiber,其 hooks 下标为 1 的位置。

总结:
函数式组件被初次渲染时,并不会执行 useReducer 函数中的 dispatch 函数。
触发点击事件,首先执行 dispatch 函数,该函数末尾的 scheduleRoot(); 导致函数式组件被再次渲染。
再次渲染时会和初次渲染一样,将 useReducer 整个函数除 dispatch 都走一遍,而非有些文章说的:再次渲染时不执行 useReducer 函数!


分析完了 useReduceruseState 的实现就显得十分简单了:

export function useState(initialValue) {
  return useReducer(null, initialValue); 
}

useState 在初次、再次渲染时的执行流程,就作为一道思考题留给大家了,相信屏幕前的你一定可以完美地分析出来~



八、performUnitOfWork

       上面我们讲,由 performUnitOfWork 来完成一个任务,然后返回下一个任务。到这里我们就明白了,一个任务,其实就是指:先为传入的 fiber 创建其对应的真实 DOM,然后将其所有的子虚拟 DOM 转化为 fiber 并连接起来,最后将第一个子 fiber 返回。
       严格来说,我们应该这么概述这一过程:传入一个 fiber 即分片,处理完该分片后返回下一个分片。

function workLoop(deadline) {
  let shouldYield = false; // false表示不需要让出时间片/控制权
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork); // performUnitOfWork: 执行一个任务,返回下一个任务
    shouldYield = deadline.timeRemaining() < 1; // 剩余时间小于1ms时,没有剩余时间,shouldYield置为true,表示需要让出控制权
  }
  ...
}

function performUnitOfWork(fiber) {
  beginWork(fiber); // beginWork每执行一次,就会将一个fiber的子虚拟DOM全部转化为fiber并借助child、sibling将所有的fiber连接起来
  if (fiber.child) {
    return fiber.child;
  }
  while (fiber) {
    completeUnitOfWork(fiber); // 将产生了变化的fiber用firstEffect、nextEffect、lastEffect连接起来
    if (fiber.sibling) {
      return fiber.sibling; // 然后找弟弟节点
    }
    fiber = fiber.return; // 没有弟弟,先回溯到父亲,就可以让父亲完成
  }
}

通过截取的这两段代码可以看的出来

1. performUnitOfWork 的第一个任务

       借助 beginWork 为传入的 fiber 创建其对应的真实 DOM,然后将其所有的子虚拟 DOM 转化为 fiber 并连接起来,然后将第一个子 fiber 返回。
       workLoop 的 while 循环会一直通过 performUnitOfWork 遍历获取 fiber 的第一个子 fiber,直到遇到某个 fiber 其不存在子元素,第一个任务结束!

2. performUnitOfWork 的第二个任务

       接下来就需要进入到 performUnitOfWork 的 while 循环中,交由 completeUnitOfWork 将 fiber 正确地添加进 Effect List 链表中,具体的添加方式在 九、completeUnitOfWork 会详细介绍。while 循环在 fiber 存在弟弟节点时,返回弟弟节点,被返回的弟弟节点会先进入到 performUnitOfWorkbeginWork 中,为其创建真实 DOM 及连接子 fiber;如果没有弟弟节点,就先回溯到父节点,将父节点通过 completeUnitOfWork 链入 Effect List,然后判断父节点是否存在弟弟节点,没有继续向上回溯…


九、completeUnitOfWork

任务:调整节点的 firstEffect、lastEffect、nextEffect 指向
(调用一次 completeUnitOfWork,可能同时涉及到对多个节点的调整)
firstEffect:指向以该节点为root,所在树的第一个child为null的节点
lastEffect:指向以该节为root,所在树下一层的最后一个节点
nextEffect:指向以深度优先遍历方式,遍历整棵树时,其遍历的下一个节点。注意,nextEffect 不会连接根节点

       举个例子,以 D 节点为 root,这棵树就包括 D、G、H,D.firstEffect 指向这棵树中第一个 child 为 null 的 G,D.lastEffect 指向下一层最后一个节点 H。以 A 节点为 root,这棵树包括 A、C、D、E、F、G、H,A.firstEffect 指向这棵树中第一个 child 为 null 的 E,A.lastEffect 指向下一层最后一个节点 D。

function completeUnitOfWork(fiber) {
  let returnFiber = fiber.return;
  // ①
  if (returnFiber) {
    // ②
    if (!returnFiber.firstEffect) {
      returnFiber.firstEffect = fiber.firstEffect;
    }
    // ③
    if (fiber.lastEffect) {
      // ④
      if (returnFiber.lastEffect) {
        returnFiber.lastEffect.nextEffect = fiber.firstEffect;
      }
      // ⑤
      returnFiber.lastEffect = fiber.lastEffect;
    }
    const effectTag = fiber.effectTag;
    // ⑥
    if (effectTag) {
      // ⑦
      if (returnFiber.lastEffect) {
        returnFiber.lastEffect.nextEffect = fiber;
      // ⑧
      } else {
        returnFiber.firstEffect = fiber;
      }
      // ⑨
      returnFiber.lastEffect = fiber;
    }
  }
}

       该函数绝对是我认为最漂亮的一个设计!
avatar
       以下图的几个节点为例,当前状态是发现 fiber E 的 child 为 null,于是开始进入 performUnitOfWork 的 while 循环,E 首先被传入 completeUnitOfWork
avatar

  • ① E 存在父 fiber C
  • ② C 不存在 firstEffect,将 A 的 firstEffect undefined 赋给 C 的 firstEffect
  • ⑥ E 发生变化
  • ⑧ C 不存在 lastEffect,C 的 firstEffect 指向 E
  • ⑨ C 的 lastEffect 指向 E

completeUnitOfWork 执行完毕:
avatar
E 存在弟弟节点 F,F 被返回


F 作为 performUnitOfWork 的参数,因为不存在子元素,所以 beginWork 为其创建了一个真实 DOM 后,就完成了工作。F 的 child属性为 null,于是进入 while 循环,将 F 作为参数执行 completeUnitOfWork

  • ① F 存在父 fiber C
  • ⑥ E 发生变化
  • ⑦ C 的 lastEffect 指向 E,让 E 的 nextEffect 指向 F
  • ⑨ C 的 lastEffect 指向 F

completeUnitOfWork 执行完毕:
avatar
F 不存在弟弟节点,回溯到F的父节点C


将 C 传入completeUnitOfWork

  • ① C 存在父 fiber A
  • ② A 不存在 firstEffect 属性,让 A 的 firstEffect 指向 C 的 firstEffect 指向的 E
  • ③ C 存在 lastEffect 属性
  • ⑤ 让 A 的 lastEffect 指向 C 的 lastEffect 指向的 F
  • ⑥ C 发生更改
  • ⑦ A 的 lastEffect 指向 F,让 F 的 nextEffect 指向 C
  • ⑨ 让 A 的 lastEffect 指向 C

completeUnitOfWork 完成:
avatar
C 存在弟弟节点 D,于是将 D 返回。


将 D 作为参数传递到 performUnitOfWork 中,beginWork 将 D 的子虚拟 DOM G、H 创建出对应的 fiber,并借助 child、sibling 连接起来。

D 的 child 指向 G,将 G 返回,为 G 创建真实 DOM 后,发现其 child 为 null,再次进入 performUnitOfWork 的 while 循环,首先将 G 传入 completeUnitOfWork
① G 存在父 fiber D
② D 不存在 firstEffect 属性,让其 firstEffect 指向 G 的 firstEffect undefined
⑧ D 不存在 lastEffect 属性,让 D 的 firstEffect 指向 G
⑨ 让 D 的 lastEffect 指向 G
completeUnitOfWork(G) 完成,G存在弟弟节点H,返回H。


H 进入 beginWork,创建完真实 DOM 后,发现其 child 为 null,进入 performUnitOfWork 的 while 循环,首先将 G 传入completeUnitOfWork
① H 存在父节点 D,
⑦ D 的 lastEffect 指向 G,让 G 的 nextEffect 指向 H
⑨ 让 D 的 lastEffect 指向 H
completeUnitOfWork(H) 完成,H 不存在弟弟节点,回溯到父亲节点 D。


将 D 传入 completeUnitOfWork
① D 存在父 fiber A
③ D 的 lastEffect 指向 H
④ A 的 lastEffect 指向 C,让 C 的 nextEffect 指向 D 的 firstEffect G
⑤ 让 A 的 lastEffect 指向 D 的 lastEffect H
⑦ A 的 lastEffect 指向 H,让 H 的 nextEffect 指向 D
⑨ 让 A 的 lastEffect 指向 D
completeUnitOfWork(D) 完成,D 不存在弟弟节点,回溯到父亲节点 A。


将 A 传入 completeUnitOfWork
① A 存在父节点 R
② R 不存在 firstEffect,让 R 的 firstEffect 指向 A 的 firstEffect 指向的 E
③ A 存在 lastEffect
⑤ 让 R 的 lastEffect 指向 A.lastEffect 指向的 D
⑦ R 的 lastEffect 指向 D,让 D 的 nextEffect 指向A
⑨ 让 R 的 lastEffect 指向A
completeUnitOfWork(A) 完成,A 不存在弟弟节点,回溯到父亲节点 R。


将 R 传入 completeUnitOfWork
由于 R 不存在父节点,函数执行完毕。
得到了一个这样的链表:
avatar
删除一些不必要的线,就得到了最终的 Effect List
avatar

       fiber.return 为 undefined,退出 performUnitOfWork 的 while 循环,performUnitOfWork 执行完毕,此次执行完毕并没有返回任何 fiber,于是退出 workLoop 的 while 循环,打印 “render 阶段结束”,开始执行 commitRoot


十、commitRoot

deletions.forEach(commitWork); // deletions中 所有fiber的effectTag 均被标记为DELETION

       首先,遍历 deletions 数组,删除上一棵 fiber 树中所有 effectTag 被标记为 DELETION 的 fiber 节点。我们已经知道,上一棵 fiber 树会在下一次渲染被复用,清除了被删除的节点,在下一次复用时,就可以减少很多干扰。

       然后从根节点 firstEffect 指向的叶子节点开始,根据 fiber.type 即 DOM 操作方式,更新 DOM 树。

完成!
avatar
恭喜你!文章到这里就结束了~

自从画出 Effect ListReact 的执行流程及原理 就基本算是完美掌握了,不知屏幕前的你,是否还健在?
avatar

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

麦田里的POLO桔

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值