likes
comments
collection
share

从0到1实现 React Fiber

作者站长头像
站长
· 阅读数 49

背景

  1. React支持JSX语法,我们可以直接将HTML代码写到JS中间,然后渲染到页面上,我们写的HTML如果有更新的话,React还有虚拟DOM的对比,只更新变化的部分,而不重新渲染整个页面,大大提高渲染效率;
  2. 到了V16.x,React更是使用了一个被称为Fiber的架构,提升了用户体验,同时还引入了hooks等特性。接下来从JSX入手,手写简单的React,了解其中原理;

JSX和CreateElement

在实现React要支持JSX还需要一个库叫JSXTransformer.js,后来JSX的转换工作都集成到了babel里面了,babel还提供了在线预览的功能,可以看到转换后的效果:

const App =
  (
    <div>
      <h1 id="title">Title</h1>
      <a href="xxx">Jump</a>
      <section>
        <p>
          Article
        </p>
      </section>
    </div>
  );

转换后:

"use strict";

const App = /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("h1", {
  id: "title"
}, "Title"), /*#__PURE__*/React.createElement("a", {
  href: "xxx"
}, "Jump"), /*#__PURE__*/React.createElement("section", null, /*#__PURE__*/React.createElement("p", null, "Article")));

格式化后:

var App = React.createElement(
  'div',
  null,
  React.createElement(
    'h1',
    {
      id: 'title',
    },
    'Title',
  ),
  React.createElement(
    'a',
    {
      href: 'xxx',
    },
    'Jump',
  ),
  React.createElement(
    'section',
    null,
    React.createElement('p', null, 'Article'),
  ),

);

所以从转换后的代码,可以看到React.createElement支持一下以下几个参数:

  1. type,也就是节点类型;
  2. config, 这是节点上的属性,比如id和href;
  3. children, 从第三个参数开始就全部是children也就是子元素了,子元素可以有多个,类型可以是简单的文本,也可以还是React.createElement,如果是React.createElement,其实就是子节点了,子节点下面还可以有子节点。这样就用React.createElement的嵌套关系实现了HTML节点的树形结构;

一个正常的React应用,最基础的除了createElement外,还有ReactDom.render,像下面:

import React from 'react';
import ReactDOM from 'react-dom';

const App = (
    <div>
        <h1 id="title">Title</h1>
        <section>
            <p>Hello xianzao</p>
        </section>
    </div>
);

ReactDOM.render(App, document.getElementById('root'));

手写CreateElement

对于<h1 id="title">Title</h1>这样一个简单的节点,原生DOM也会附加一大堆属性和方法在上面,所以我们在createElement的时候最好能将它转换为一种比较简单的数据结构,只包含我们需要的元素:

{
  type: 'h1',
  props: {
    id: 'title',
    children: 'Title'
  }
}

有了这个数据结构后,我们对于DOM的操作其实可以转化为对这个数据结构的操作,新老DOM的对比其实也可以转化为这个数据结构的对比,这样我们就不需要每次操作都去渲染页面,而是等到需要渲染的时候才将这个数据结构渲染到页面上。这其实就是虚拟DOM!而我们createElement就是负责来构建这个虚拟DOM的方法:

function createElement(type, props, ...children) {
// 核心逻辑不复杂,将参数都塞到一个对象上返回就行
// children也要放到props里面去,这样我们在组件里面就能通过this.props.children拿到子元素
return {
 type,
 props: {
   ...props,
   children
     }
   }
}

手写Render

我们用createElement将JSX代码转换成了虚拟DOM,那真正将它渲染到页面的函数是render,所以我们还需要实现下这个方法,通过我们一般的用法ReactDOM.render( <App />,document.getElementById('root'))

  1. 根组件,其实是一个JSX组件,也就是一个createElement返回的虚拟DOM;
  2. 父节点,也就是我们要将这个虚拟DOM渲染的位置;

有了这两个参数,我们来实现下render方法:

function render(vDom, container) {
  let dom;
  // 检查当前节点是文本还是对象
  if(typeof vDom !== 'object') {
    dom = document.createTextNode(vDom)
  } else {
    dom = document.createElement(vDom.type);
  }

  // 将vDom上除了children外的属性都挂载到真正的DOM上去
  if(vDom.props) {
    Object.keys(vDom.props)
      .filter(key => key != 'children')
      .forEach(item => {
        dom[item] = vDom.props[item];
      })
  }
  
  // 如果还有子元素,递归调用
  if(vDom.props && vDom.props.children && vDom.props.children.length) {
    vDom.props.children.forEach(child => render(child, dom));
  }

  container.appendChild(dom);
}
import React from './myReact';
const ReactDOM = React;

const App = (
        <div>
                <h1 id="title">Title</h1>
                <section>
                        <p>Hello xianzao</p>
                </section>
        </div>
);

ReactDOM.render(App, document.getElementById('root'));

为什么需要Fiber

  1. 上述简单的实现了虚拟DOM渲染到页面上的代码,这部分工作被React官方称为renderer
  2. renderer是第三方可以自己实现的一个模块,还有个核心模块叫做reconcilerreconciler的一大功能就是大家熟知的diff,他会计算出应该更新哪些页面节点,然后将需要更新的节点虚拟DOM传递给rendererrenderer负责将这些节点渲染到页面上;
  3. 虽然React的diff算法是经过优化的,但是他却是同步的,renderer负责操作DOM的appendChild等API也是同步的,也就是说如果有大量节点需要更新,JS线程的运行时间可能会比较长,在这段时间浏览器是不会响应其他事件的,因为JS线程和GUI线程是互斥的,JS运行时页面就不会响应,这个时间太长了,用户就可能看到卡顿,特别是动画的卡顿会很明显。在React的官方演讲中有个例子,可以很明显的看到这种同步计算造成的卡顿;

而Fiber就是用来解决这个问题的,Fiber可以将长时间的同步任务拆分成多个小任务,从而让浏览器能够抽身去响应其他事件,等他空了再回来继续计算,这样整个计算流程就显得平滑很多。

现在的问题:

  1. 上面我们自己实现的render方法直接递归遍历了整个vDom树,如果我们在中途某一步停下来,下次再调用时其实并不知道上次在哪里停下来的,不知道从哪里开始,所以vDom的树形结构并不满足中途暂停,下次继续的需求,需要改造数据结构;
  2. 拆分下来的小任务什么时候执行?我们的目的是让用户有更流畅的体验,所以我们最好不要阻塞高优先级的任务,比如用户输入,动画之类,等他们执行完了我们再计算。那我怎么知道现在有没有高优先级任务,浏览器是不是空闲呢?

总结下来,Fiber要想达到目的,需要解决两个问题:

  1. 新的任务调度,有高优先级任务的时候将浏览器让出来,等浏览器空了再继续执行;
  2. 新的数据结构,可以随时中断,下次进来可以接着执行;

requestIdleCallback

requestIdleCallback是一个实验中的新API,这个API调用方式如下:

// 开启调用
var handle = window.requestIdleCallback(callback[, options])

// 结束调用
Window.cancelIdleCallback(handle) 
  1. requestIdleCallback接收一个回调,这个回调会在浏览器空闲时调用,每次调用会传入一个IdleDeadline,可以拿到当前还空余多久,options可以传入参数最多等多久,等到了时间浏览器还不空就强制执行了,使用这个API可以解决任务调度的问题,让浏览器在空闲时才计算diff并渲染;
  2. 但是这个API还在实验中,兼容性不好,所以React官方自己实现了一套。(基于时间原因,本文会继续使用requestIdleCallback来进行任务调度)我们进行任务调度的思想是将任务拆分成多个小任务,requestIdleCallback里面不断的把小任务拿出来执行,当所有任务都执行完或者超时了就结束本次执行,同时要注册下次执行,代码架子就是这样:
function workLoop(deadline) {
        while (nextUnitOfWork && deadline.timeRemaining() > 1) {
                // 这个while循环会在任务执行完或者时间到了的时候结束
                nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
        }

        // 如果任务还没完,但是时间到了,我们需要继续注册requestIdleCallback
        requestIdleCallback(workLoop);
}

// performUnitOfWork用来执行任务,参数是我们的当前fiber任务,返回值是下一个任务
function performUnitOfWork(fiber) {}

// 使用requestIdleCallback开启workLoop
requestIdleCallback(workLoop);

Fiber的可中断数据结构

接下来实现performUnitOfWork,从上面的结构可以看出来,他接收的参数是一个小任务,同时通过这个小任务还可以找到他的下一个小任务,Fiber构建的就是这样一个数据结构。

Fiber的数据结构是一棵树,包含3部分

  1. child: 父节点指向第一个子元素的指针;
  2. sibling:从第一个子元素往后,指向下一个兄弟元素;
  3. return:所有子元素都有的指向父元素的指针;

从0到1实现 React Fiber

有了这几个指针后,我们可以在任意一个元素中断遍历并恢复,比如在上图List处中断了,恢复的时候可以通过child找到他的子元素,也可以通过return找到他的父元素,如果他还有兄弟节点也可以用sibling找到。Fiber这个结构外形看着还是棵树,但是没有了指向所有子元素的指针,父节点只指向第一个子节点,然后子节点有指向其他子节点的指针,这其实是个链表。

实现Fiber

  1. 将之前的vDom结构转换为Fiber的数据结构,同时需要能够通过其中任意一个节点返回下一个节点,其实就是遍历这个链表;
  2. 遍历的时候从根节点出发,先找子元素,如果子元素存在,直接返回,如果没有子元素了就找兄弟元素,找完所有的兄弟元素后再返回父元素,然后再找这个父元素的兄弟元素。整个遍历过程其实是个深度优先遍历(DFS),从上到下,然后最后一行开始从左到右遍历;
  3. 比如下图从div1开始遍历的话,遍历的顺序就应该是div1 -> div2 -> h1 -> a -> div2 -> p -> div1。可以看到这个序列中,当我们return父节点时,这些父节点会被第二次遍历,所以我们写代码时,return的父节点不会作为下一个任务返回,只有siblingchild才会作为下一个任务返回;

从0到1实现 React Fiber

// performUnitOfWork用来执行任务,参数是我们的当前fiber任务,返回值是下一个任务
function performUnitOfWork(fiber) {
  // 根节点的dom就是container,如果没有这个属性,说明当前fiber不是根节点
  if(!fiber.dom) {
    fiber.dom = createDom(fiber);   // 创建一个DOM挂载上去
  } 

  // 如果有父节点,将当前节点挂载到父节点上
  if(fiber.return) {
    fiber.return.dom.appendChild(fiber.dom);
  }

  // 将我们前面的vDom结构转换为fiber结构
  const elements = fiber.children;
  let prevSibling = null;
  if(elements && elements.length) {
    for(let i = 0; i < elements.length; i++) {
      const element = elements[i];
      const newFiber = {
        type: element.type,
        props: element.props,
        return: fiber,
        dom: null
      }

      // 父级的child指向第一个子元素
      if(i === 0) {
        fiber.child = newFiber;
      } else {
        // 每个子元素拥有指向下一个子元素的指针
        prevSibling.sibling = newFiber;
      }

      prevSibling = newFiber;
    }
  }

  // 这个函数的返回值是下一个任务,这其实是一个深度优先遍历
  // 先找子元素,没有子元素了就找兄弟元素
  // 兄弟元素也没有了就返回父元素
  // 然后再找这个父元素的兄弟元素
  // 最后到根节点结束
  // 这个遍历的顺序其实就是从上到下,从左到右
  if(fiber.child) {
    return fiber.child;
  }

  let nextFiber = fiber;
  while(nextFiber) {
    if(nextFiber.sibling) {
      return nextFiber.sibling;
    }

    nextFiber = nextFiber.return;
  }
}

统一commit DOM操作

performUnitOfWork一边构建Fiber结构一边操作DOMappendChild,这样如果某次更新好几个节点,操作了第一个节点之后就中断了,那我们可能只看到第一个节点渲染到了页面,后续几个节点等浏览器空了才陆续渲染。为了避免这种情况,我们应该将DOM操作都搜集起来,最后统一执行,这就是commit。为了能够记录位置,我们还需要一个全局变量workInProgressRoot来记录根节点,然后在workLoop检测如果任务执行完了,就commit

let workInProgressRoot = null; // 指向Fiber的根节点

function workLoop(deadline) {
  while(nextUnitOfWork && deadline.timeRemaining() > 1) {
    // 这个while循环会在任务执行完或者时间到了的时候结束
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  // 任务做完后统一渲染
  if(!nextUnitOfWork && workInProgressRoot) {
    commitRoot();
  }

  // 如果任务还没完,但是时间到了,我们需要继续注册requestIdleCallback
  requestIdleCallback(workLoop);
}

因为我们是在Fiber树完全构建后再执行的commit,而且有一个变量workInProgressRoot指向了Fiber的根节点,所以我们可以直接把workInProgressRoot拿过来递归渲染就行了:

// 统一操作DOM
function commitRoot() {
  commitRootImpl(workInProgressRoot.child);    // 开启递归
  workInProgressRoot = null;     // 操作完后将workInProgressRoot重置
}

function commitRootImpl(fiber) {
  if(!fiber) {
    return;
  }

  const parentDom = fiber.return.dom;
  parentDom.appendChild(fiber.dom);

  // 递归操作子元素和兄弟元素
  commitRootImpl(fiber.child);
  commitRootImpl(fiber.sibling);
}

reconcile

reconcile其实就是虚拟DOM树的diff操作:

  • 删除不需要的节点;
  • 更新修改过的节点;
  • 添加新的节点;

为了在中断后能回到工作位置,我们还需要一个变量currentRoot,然后在fiber节点里面添加一个属性alternate,这个属性指向上一次运行的根节点,也就是currentRootcurrentRoot会在第一次render后的commit阶段赋值,也就是每次计算完后都会把当次状态记录在alternate上,后面更新了就可以把alternate拿出来跟新的状态做diff。然后performUnitOfWork里面需要添加调和子元素的代码,可以新增一个函数reconcileChildren。这个函数要将老节点跟新节点拿来对比,对比逻辑如下:

  1. 如果新老节点类型一样,复用老节点DOM,更新props;
  2. 如果类型不一样,而且新的节点存在,创建新节点替换老节点;
  3. 如果类型不一样,没有新节点,有老节点,删除老节点;

注意删除老节点的操作是直接将oldFiber加上一个删除标记就行,同时用一个全局变量deletions记录所有需要删除的节点:

// 对比oldFiber和当前element
const sameType = oldFiber && element && oldFiber.type === element.type;  //检测类型是不是一样
// 先比较元素类型
if(sameType) {
  // 如果类型一样,复用节点,更新props
  newFiber = {
    type: oldFiber.type,
    props: element.props,
    dom: oldFiber.dom,
    return: workInProgressFiber,
    alternate: oldFiber,          // 记录下上次状态
    effectTag: 'UPDATE'           // 添加一个操作标记
  }
} else if(!sameType && element) {
  // 如果类型不一样,有新的节点,创建新节点替换老节点
  newFiber = {
    type: element.type,
    props: element.props,
    dom: null,                    // 构建fiber时没有dom,下次perform这个节点是才创建dom
    return: workInProgressFiber,
    alternate: null,              // 新增的没有老状态
    effectTag: 'REPLACEMENT'      // 添加一个操作标记
  }
} else if(!sameType && oldFiber) {
  // 如果类型不一样,没有新节点,有老节点,删除老节点
  oldFiber.effectTag = 'DELETION';   // 添加删除标记
  deletions.push(oldFiber);          // 一个数组收集所有需要删除的节点
}

然后就是在commit阶段处理真正的DOM操作,具体的操作是根据我们的effectTag来判断的:

function commitRootImpl(fiber) {
  if(!fiber) {
    return;
  }

  const parentDom = fiber.return.dom;
  if(fiber.effectTag === 'REPLACEMENT' && fiber.dom) {
    parentDom.appendChild(fiber.dom);
  } else if(fiber.effectTag === 'DELETION') {
    parentDom.removeChild(fiber.dom);
  } else if(fiber.effectTag === 'UPDATE' && fiber.dom) {
    // 更新DOM属性
    updateDom(fiber.dom, fiber.alternate.props, fiber.props);
  }

  // 递归操作子元素和兄弟元素
  commitRootImpl(fiber.child);
  commitRootImpl(fiber.sibling);
}

替换和删除的DOM操作都比较简单,更新属性的会稍微麻烦点,我们用一个辅助函数updateDom来实现:

// 更新DOM的操作
function updateDom(dom, prevProps, nextProps) {
  // 1. 过滤children属性
  // 2. 老的存在,新的没了,取消
  // 3. 新的存在,老的没有,新增
  Object.keys(prevProps)
    .filter(name => name !== 'children')
    .filter(name => !(name in nextProps))
    .forEach(name => {
      if(name.indexOf('on') === 0) {
        dom.removeEventListener(name.substr(2).toLowerCase(), prevProps[name], false);
      } else {
        dom[name] = '';
      }
    });

  Object.keys(nextProps)
    .filter(name => name !== 'children')
    .forEach(name => {
      if(name.indexOf('on') === 0) {
        dom.addEventListener(name.substr(2).toLowerCase(), nextProps[name], false);
      } else {
        dom[name] = nextProps[name];
      }
    });
}

总结

React V15 在渲染时,会递归比对 VirtualDOM 树,找出需要变动的节点,然后同步更新它们,过程期间,React 会占据浏览器资源,这会导致用户触发的事件得不到响应,并且会导致掉帧.导致用户感觉到卡顿。

为了给用户制造一种应用很快的“假象”,不能让一个任务长期霸占着资源。可以将浏览器的渲染、布局、绘制、资源加载(例如 HTML 解析)、事件响应、脚本执行视作操作系统的“进程”,需要通过某些调度策略合理地分配 CPU 资源,从而提高浏览器的用户响应速率,同时兼顾任务执行效率。

所以 React 通过Fiber 架构,让这个执行过程变成可被中断。“适时”地让出 CPU 执行权,除了可以让浏览器及时地响应用户的交互,还有其他好处:

  1. 分批延时对DOM进行操作,避免一次性操作大量 DOM 节点,可以得到更好的用户体验;

  2. 给浏览器一点喘息的机会,它会对代码进行编译优化(JIT)及进行热代码优化,或者对 reflow 进行修正;

核心思想: Fiber 也称协程或者纤程,它和线程并不一样,协程本身是没有并发或者并行能力的(需要配合线程),它只是一种控制流程的让出机制。让出 CPU 的执行权,让 CPU 能在这段时间执行其他的操作。渲染的过程可以被中断,可以将控制权交回浏览器,让位给高优先级的任务,浏览器空闲后再恢复渲染。

转载自:https://juejin.cn/post/7254853744918216762
评论
请登录