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

本文深入解析React的Fiber架构,从JSX和creatElement入手,逐步手写实现虚拟DOM、Fiber、reconciliation算法、函数组件以及useState Hook。通过理解这些核心概念,读者可以更好地掌握React的更新机制和性能优化。文章还介绍了如何通过Fiber解决同步更新导致的卡顿问题,以及requestIdleCallback在任务调度中的作用。同时,文章还提供了完整的代码实现,供读者实践和学习。
摘要由CSDN通过智能技术生成

熟悉React的朋友都知道,React支持jsx语法,我们可以直接将HTML代码写到JS中间,然后渲染到页面上,我们写的HTML如果有更新的话,React还有虚拟DOM的对比,只更新变化的部分,而不重新渲染整个页面,大大提高渲染效率。到了16.x,React更是使用了一个被称为Fiber的架构,提升了用户体验,同时还引入了hooks等特性。那隐藏在React背后的原理是怎样的呢,Fiberhooks又是怎么实现的呢?本文会从jsx入手,手写一个简易版的React,从而深入理解React的原理。

本文主要实现了这些功能:

简易版Fiber架构

简易版DIFF算法

简易版函数组件

简易版Hook: useState

娱乐版Class组件

本文代码地址:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/fiber-and-hooks

本文程序跑起来效果如下:

Jun-19-2020 17-01-28

JSX和creatElement

以前我们写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>
);

经过babel转换后就变成了这样:

image-20200608175937104

上面的截图可以看出我们写的HTML被转换成了React.createElement,我们将上面代码稍微格式化来看下:

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, 这是节点上的属性,比如idhref
  3. children, 从第三个参数开始就全部是children也就是子元素了,子元素可以有多个,类型可以是简单的文本,也可以还是React.createElement,如果是React.createElement,其实就是子节点了,子节点下面还可以有子节点。这样就用React.createElement的嵌套关系实现了HTML节点的树形结构。

让我们来完整看下这个简单的React页面代码:

image-20200608180112829

渲染在页面上是这样:

image-20200608180139663

这里面用到了React的地方其实就两个,一个是JSX,也就是React.createElement,另一个就是ReactDOM.render,所以我们手写的第一个目标就有了,就是createElementrender这两个方法。

手写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
    }
  }
}

上述代码是React的createElement简化版,对源码感兴趣的朋友可以看这里:https://github.com/facebook/react/blob/60016c448bb7d19fc989acd05dda5aca2e124381/packages/react/src/ReactElement.js#L348

手写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);
}

上述代码是简化版的render方法,对源码感兴趣的朋友可以看这里:https://github.com/facebook/react/blob/3e94bce765d355d74f6a60feb4addb6d196e3482/packages/react-dom/src/client/ReactDOMLegacy.js#L287

现在我们可以用自己写的createElementrender来替换原生的方法了:

image-20200608180301596

可以得到一样的渲染结果:

image-20200608180139663

为什么需要Fiber

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

1625d95bc100c7fe

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

1625d95bc2baf0e1

怎么来拆分

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

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

requestIdleCallback

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

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

// 结束调用
Window.cancelIdleCallback(handle) 

requestIdleCallback接收一个回调,这个回调会在浏览器空闲时调用,每次调用会传入一个IdleDeadline,可以拿到当前还空余多久,options可以传入参数最多等多久,等到了时间浏览器还不空就强制执行了。使用这个API可以解决任务调度的问题,让浏览器在空闲时才计算diff并渲染。更多关于requestIdleCallback的使用可以查看MDN的文档。但是这个API还在实验中,兼容性不好,所以React官方自己实现了一套。本文会继续使用requestIdleCallback来进行任务调度,我们进行任务调度的思想是将任务拆分成多个小任务,requestIdleCallback里面不断的把小任务拿出来执行,当所有任务都执行完或者超时了就结束本次执行,同时要注册下次执行,代码架子就是这样:

function workLoop(deadline) {
   </
  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值