感受react源码的进化

2022-12-06 10:53:06 浏览数 (1)

写在前面

网上有许多关于react源码解读的文章,其中有很多都只是单纯贴源码,罗列变量名。其实大家都知道这个英文怎么读,直译也大概知道意思,但是这个英文在react中起到什么作用,并没有说的很通俗明白。

对于刚刚接触源码或者想要了解react实现的人来说,没有起到引导作用,一堆函数变量反而劝退了很多人。

所以打算开启一个系列的文章,用简单的代码片段代替源码,拆解react的时间分片、优先级调度、diff等核心模块,让大家一眼就能明白其中的原理。

react15为什么需要进化

react15有两大原罪,渲染阻塞和无法合并异步函数里面的setState

原罪1:同步渲染阻塞主线程

react15从setState到DOM节点渲染到页面上,整个流程都是同步的,所以如果其中某个环节占用时间特别长,就会造成主线程阻塞。

由于JS的执行是单线程的,JS线程与浏览器的其他线程互斥,如果JS线程阻塞,浏览器的渲染线程、事件线程也会相应的挂起。此时用户触发的浏览器原生事件也会无响应,造成卡顿的现象。

疑问:react15什么情况下会造成阻塞?

react15采用的是树形结构的虚拟DOM树,使用了递归方式的进行节点遍历,递归意味着虚拟DOM树的构建是一个同步的过程,只要一开始就无法中断。而且DOM节点层级越深,节点数越多,diff流程霸占JS线程的时间就越长。

当然网上都是这么说,实际上是不是真的是树形结构,是不是真的用递归的方式进行节点遍历,还是需要经过实际源码考证,为此我翻看了react@15.5.3的源码

求证1:树形结构

代码语言:javascript复制
<div key={'最外层节点'}>
  {
    ['a', 'b', 'c', 'd'].map( (v,index) =>
      <div key={`第一层子节点 - ${v}`}>
        <span key={`${v}的子节点`}>parentNode:{v}</span>
      </div>
    )
  }
</div>

上面JSX代码在转换为DOM树结构时是通过树形的结构进行层层遍历

react15树形结构.pngreact15树形结构.png

求证2:递归遍历

这里采用伪代码的形式模拟react15的节点遍历,具体源码调用层级跨度大贴代码不好分析,有兴趣的同学可以翻看真正的源码查看具体细节

代码语言:javascript复制
 function 构建节点(节点) {
    if (有子节点) return 生成子节点(节点)
    return 节点
  }
  function 生成子节点(children) {
    const 子节点列表 = []
    children.map(child => {
      子节点列表.push(构建节点(child))
    })
    return 子节点列表
  }
  function 挂载节点(node) {
    container.insertBefore(node)
  }
  function Render(组件, container) {
    const 应用根节点 = 组件()
    const 节点树 = 构建节点(应用根节点)
    挂载节点(节点树, container)
  }

  function Count(params) {
    return <div>1<div>
  }
  Render(<Count/>, document.querySelector('#root'))

可以看到当遍历到一个节点发现下面有子节点的时候,他会递归调用构建节点的方法继续往下构建DOM树,整个DOM树构建的过程都是同步的。

原罪2:无法合并异步函数里面的setState

除了阻塞,react15下setState的合并更新机制是以函数为单位,将函数内同步执行的setState合并,注意,是同步执行的setState,这样会出现一个问题,异步函数中的setState无法被合并。

  • 问题1:异步函数中的setState更新会以同步的形式呈现
  • 问题2:异步函数内的每一个setState都会触发一次完整的视图更新,造成性能损耗 下面展示一下问题代码
代码语言:javascript复制
state = { count: 0 }
setCount() {
    this.setState({ count: 1 })
    console.log(this.state.count) // 输出0,这里是正常的,state不会马上更新
    setTimeout(() => {
        this.setState({ count: 2 })
        console.log(this.state.count) // 输出2,state同步更新,没有被合并
    })
}

上面的的代码为什么会输出这样的结果,react15 的合并更新是怎么实现的呀??

卖个关子,我会在后面的系列文章中为你解答,用30行代码告诉你 react15 合并更新原理

Fiber架构下的react得到哪些提升

为解决react15的痛点,在16 版本后,react重写整个架构,为的就是实现异步可中断更新。异步可中断更新这几个字说着简单,那具体需要怎么实现呢?

回顾react15的两大痛点,我们需要解决两件事情

  1. 解决阻塞问题。
  2. 让setState在异步函数里面也能被合并。 下面将一一解决这两个问题

解决阻塞问题

看完上面react15节点遍历的伪代码,不难发现阻塞的根源有两个

  1. 递归遍历节点树,无法中断遍历
  2. 遍历节点树会一直占用主线程,阻塞了浏览器的其他线程

解决手段1:改变树结构和节点遍历方式

react15使用了树形结构串联整棵树,这也间接导致react15采用递归 子节点for循环的方式对虚拟DOM树进行层层遍历,过程无法中断。

要实现可中断的遍历好办,不用递归,改用while遍历的话就能满足中断这个要求

但是树形结构不方便做while遍历啊,嵌套层级深,分支又多,那咋整?

把整棵树拍扁,用链表的形式描述树结构,这样我就能无需维护多余的变量记录维护遍历顺序,非常轻松的一个个遍历节点,通过while循环做遍历中断也会更加清晰

下面我用伪代码的形式简单模拟一下react16 的遍历

代码语言:javascript复制
let 需要被遍历的幸运儿节点 = null
function 构建节点() {
    /**     *  ...在这里进行节点构建工作     */
    需要被遍历的幸运儿节点 = 需要被遍历的幸运儿节点.next
}
function 节点遍历() {
    while (需要被遍历的幸运儿节点 != null) {
        构建节点()
    }
}
function 调度() {
    需要被遍历的幸运儿节点 = react应用根节点
    节点遍历()
}
调度()

注意,需要被遍历的幸运儿节点 = 需要被遍历的幸运儿节点.next,react并不是简简单单用next去描述节点关系,我会在后面系列文章中详细描述

解决手段2:时间分片

好了,终于实现了可中断的更新,我们算是完成了半个react16了,还差一个异步,怎么做呢?那就是时间分片

时间分片顾名思义,就是设定一个固定而连续且有间隔的时间区间(好像不那么顾名思义)

什么是固定?就是我每天固定摸鱼工作8小时

什么是连续?我每天都需要上班

什么是有间隔?周末休息

react时间分片对应的就是

  1. 时间分片固定的5毫秒左右(会根据优先级有所浮动,求生欲)
  2. 分片支配着react工作的中断和开启(其实只是作用于部分工作)
  3. 分片与分片之间是有间隔的,这段间隔就是让浏览器有空闲时间去处理其他线程的任务

下面简单实现一下时间分片

代码语言:txt复制
下一章再讲吧,一下子写太多怕消化不了(逃

时间分片在performance中的直观体现(基本都控制在5毫秒左右)

时间分片.png时间分片.png

让setState在异步函数里面也能被合并

react16 对于这一块的实现,是基于整个Fiber架构的设计实现的,需要对时间分片、异步调度、lane优先级机制、state计算方式、事件系统有一定前置知识,或者能更好去理解

这里我简述下实现的原理

  1. 每一次执行setState

a. 将此次更新的优先级关联到当前Fiber节点和根Fiber节点

b. 执行调度函数

  1. 调度函数会先进行一个逻辑判断,判断当前应用根节点的优先级和当前已被调度的优先级是否相等

a. 相等。是同一个函数下面的setState,可以合并更新,不重复发起协调任务

b. 不相等。发起协调任务

这里不相等分为两种情况,一种是第一次发起调度,一种是高优先级任务进来。

如果对源码有一定了解小伙伴可能会有点点明白我这里说的是什么意思,上面说的并不完全与源码一一对应,但大概逻辑是相通的,后面我会以更详细的篇幅给大家理清楚优先级调度

宏观角度了解react的新架构

系列第一篇主要是为大家理解react16 源码做一个前置知识的铺垫,让大家对react16 的构成有一个大概的了解,下面是一张react16 的模块功能分布图

相关参考视频讲解:进入学习

react.pngreact.png

Scheduler

Scheduler主要负责react的任务调度,其中包括分片调度和优先级调度

  • 分片调度的主要任务是负责reconcile (render)阶段能够间断执行节点遍历任务
  • 优先级调度主要是为了将react任务划分为多种优先级类型,能够实现高优先级任务快速响应

Reconciler

Reconciler主要负责Fiber节点的构建和创建相应的副作用

  • state计算在引入了优先级机制后,并不是简单的将state计算覆盖,其中关联到低优先级任务重启的逻辑
  • diff就是通过遍历新旧Fiber树,找出需要增删改的节点
  • 副作用创建将需要增删改的节点以位与运算的形式记录到Fiber节点的flags属性上,等待commit阶段清除这些副作用(副作用包含但不限于节点增删改,还有useEffect执行,ref更新等等的副作用)

Renderer

Renderer (commit)阶段做的事情就是清除副作用,然后开启下一轮的调度

以上就是react的基本构成和各个模块的职责。后续为了更方便进行解读,我会用render阶段代指Reconciler,用commit阶段代指Renderer

写在最后

本文主要简述了react的进化历程和新react架构的基本构成。下一篇我会讲讲react的时间分片,同时会结合react的任务去模拟一个时间分片的运行过程。

上文所述如果有说的不对的,望各位大佬可以包涵指正。如果有不懂的,可以把疑问点提出来,我会逐一解答。每一次交流的过程都是一次思想和学习的碰撞,大家可以尽情diss

0 人点赞