React源码系列(一):React设计理念&架构

1,861 阅读7分钟

前言

从本篇文章开始,我将和大家一起梳理React源码相关知识点,预计写10篇左右,通过本专栏的学习,相信大家可以快速掌握React源码的相关概念以及核心思想,向成为大佬的道路上更近一步;

本系列源码基于v18.2.0版本;

异步可中断

React的设计理念是快速响应包含以下两个点:

  • CPU的瓶颈:当遇到大计算量的操作或者设备性能不足使页面掉帧,导致卡顿。
  • IO瓶颈:发送网络请求后,由于需要等待数据返回才能进一步操作导致不能快速响应。

主流浏览器刷新频率为60Hz,即每(1000ms / 60Hz)16.6ms浏览器刷新一次。在这16.6ms内要做很多的事,例如js脚本执行,样式布局,样式绘制等; 也就是说JS的脚本执行时间和布局绘制是互斥的,不能同步进行。如果JS脚本执行时间过长就会造成页面掉帧,导致页面卡顿。

如何解决这个问题呢,在我们日常的开发中,遇到了大数据量的渲染会怎么办呢,我们想到的可能是将任务分割,让它能够被中断,在其他任务到来的时候让出执行权,当其他任务执行后,再从之前中断的部分开始异步执行剩下的计算。所以关键是实现一套异步可中断的方案。

由上述可知,React的实现包括以下三个重要概念

  • 任务分割
  • 异步执行
  • 让出执行权组成

1、Fiber:在React16之前采用的调用是递归调用,递归调用会出现一个问题,也就是一旦开始渲染,就不能停止了,直到渲染出完整的树结构。也就是说会造成主线程被持续占⽤,造成的后果就是主线程上的布局、动画等周期性任务就⽆法立即得到处理,造成视觉上的卡顿,影响⽤户体验;React16之后使用增量渲染(把渲染任务拆分成块,匀到多帧),将把工作分解成小单元,在完成每个单元之后,如果还有其他任务需要完成,我们将让浏览器中断渲染,也就是经常听到的Fiber。

2、Scheduler(调度):有了Fiber,我们就需要用浏览器的时间片异步执行这些Fiber的工作单元。使用时间切片,将同步更新变为可中断的异步更新。也就是在浏览器每一帧的时间中,预留一些时间给JS线程,React利用这部分时间更新组件,预留的初始时间是5ms。当预留的时间不够用时,React将线程控制权交还给浏览器使其有时间渲染UI,React则等待下一帧时间到来继续被中断的工作。

3、Lane(优先级):有了异步调度,我们还需管理各个任务的优先级,让高优先级的任务优先执行,各个Fiber工作单元还能比较优先级,相同优先级的任务可以一起更新。

伪代码简单实现:

function workLoop(deadline) {
  // 停止循环标识
  let shouldYield = false

  // 循环条件为存在下一个工作单元,且没有更高优先级的工作
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    // 当前帧空余时间要没了,停止工作循环
    shouldYield = deadline.timeRemaining() < 1
  }
  // 空闲时间执行任务
  requestIdleCallback(workLoop)
}

// 空闲时间执行任务
requestIdleCallback(workLoop)

从下面第一幅图可以看出在这种情况下,函数堆栈的调用就像下图一样,层级很深,很长时间不会返回;

下面第二幅图 Fiber 分片模式下,浏览器主线程能够定期被释放,保证了渲染的帧率,函数的堆栈调用如下(波谷表示执行分片任务,波峰表示执行其他高优先级任务); image.png

代数效应

除了cpu的瓶颈问题,还有一类问题是和副作用相关的问题,比如获取数据、文件操作等。不同设备性能和网络状况都不一样,React怎样去处理这些副作用,让我们在编码时最佳实践,运行应用时表现一致呢,这就需要React有分离副作用的能力,为什么要分离副作用呢,因为要解耦,这就是代数效应。

举例说明:异步方案我们首先想到 async 和 await,getTotal是一个异步获取数据的方法,我们可以用async+await的方式获取数据,但是这会导致调用getTotal的run方法也会变成异步函数,这就是async的传染性,所以没法分离副作用。

function getTotal(id) {
  return fetch(`xxx.com?id=${id}`).then(({total})=>{
    return total
  })
}

async function run(){
	await getTotal(1);  
}

虚构一个类似try...catch的语法 —— try...handle与两个操作符perform、resume。当代码执行到perform的时候会暂停当前函数的执行,并且被handle捕获,handle函数体内会拿到id参数获取数据之后返回total执行位置,返回到之前perform暂停的地方并且返回totle,这就完全把副作用分离之外。

这里的关键流程是perform暂停函数的执行,handle获取函数执行权,resume交出函数执行权。

function getTotal(id) {
  const totle = perform id;
  return totle;
}

try {
  getTotal(1);
} handle (id) {
  fetch(`xxx.com?id=${id}`).then(({total})=>{
    resume with total
  })
}

总结一下:代数效应能够将副作用从函数逻辑中分离,使函数关注点保持纯粹。

代数效应在React中的实践:使用hooks分离副作用的能力。不用关心useState中state是如何变化的,只需要使用即可。

function useTotal(id) {
  const [total,setTotal] = useState()
  useEffect((id)=>{
      fetch(`xxx.com?id=${id}`).then((res)=>{
        setTotal(res.total)
  	})
  }, [])
  return {total}
}

function Total({id1, id2}) {
  // 此处直接可以获取total
  const total = useTotal(id1);

  return ...
}

React新老架构

React15架构

React15架构可以分为两层:

  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

Reconciler(协调器)

触发更新:

  • 调用函数组件、或class组件的render方法,将返回的JSX转化为虚拟DOM
  • 将虚拟DOM和上次更新时的虚拟DOM对比
  • 通过对比找出本次更新中变化的虚拟DOM
  • 通知Renderer将变化的虚拟DOM渲染到页面上

Renderer(渲染器)

在每次更新发生时,Renderer接到Reconciler通知,将变化的组件渲染在当前环境。

React15架构的缺点

同步更新一旦开始,中途就无法中断。当层级很深时,递归更新时间超过了16ms,用户交互就会卡顿。

React16之后架构

React16架构可以分为三层:

  • Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler
  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

Scheduler 调度

可以看到,相较于React15,React16中新增了Scheduler(调度)

在 React 15 的版本中,采用了循环加递归的方式进行了 virtualDOM 的比对,由于递归使用 JavaScript 自身的执行栈,一旦开始就无法停止,直到任务执行完成。如果 VirtualDOM 树的层级比较深,virtualDOM 的比对就会长期占用 JavaScript 主线程,由于 JavaScript 又是单线程的无法同时执行其他任务,所以在比对的过程中无法响应用户操作,无法即时执行元素动画,造成了页面卡顿的现象。

在 React 16 的版本中,放弃了 JavaScript 递归的方式进行 virtualDOM 的比对,而是采用循环模拟递归。而且比对的过程是利用浏览器的空闲时间完成的,不会长期占用主线程,这就解决了 virtualDOM 比对造成页面卡顿的问题。

在 window 对象中提供了 requestIdleCallback API,它可以利用浏览器的空闲时间执行任务,但是它自身也存在一些问题,比如说并不是所有的浏览器都支持它,而且它的触发频率也不是很稳定,所以 React 最终放弃了 requestIdleCallback 的使用。

在 React 中,官方实现了自己的任务调度库,这个库就叫做 Scheduler。它也可以实现在浏览器空闲时执行任务,而且还可以设置任务的优先级,高优先级任务先执行,低优先级任务后执行。

Reconciler 协调

在 React 15 的版本中,协调器和渲染器交替执行,即找到了差异就直接更新差异。在 React 16 的版本中,这种情况发生了变化,协调器和渲染器不再交替执行。协调器负责找出差异,在所有差异找出之后,统一交给渲染器进行 DOM 的更新。也就是说协调器的主要任务就是找出差异部分,并为差异打上标记。在下面章节协调&diff会讲到;

Renderer 渲染

渲染器根据协调器为 Fiber 节点打的标记,同步执行对应的DOM操作。 既然比对的过程从递归变成了可以中断的循环,那么 React 是如何解决中断更新时 DOM 渲染不完全的问题呢? 其实根本就不存在这个问题,因为在整个过程中,调度器和协调器的工作是在内存中完成的是可以被打断的,渲染器的工作被设定成不可以被打断,所以不存在DOM 渲染不完全的问题。

image.png

调用链路

我们从下图来分析一下,从图中你可以看到,整个调用链路中所包含的三个阶段:

  • 初始化阶段
  • render 阶段
  • commit 阶段

图中 scheduleUpdateOnFiber 方法的作用是调度更新,也就是render阶段入口;而 commitRoot 方法开启的则是真实 DOM 的渲染过程(commit 阶段)。因此以scheduleUpdateOnFiber 和 commitRoot 两个方法为界; image.png

小结

本节通过介绍 React 的设计理念和新老架构来整体介绍了一下React的设计思想,下一节我们将开启虚拟DOM的学习。

参考链接