剖析react核心设计原理--异步执行调度

1,816

JS的执行通常在单线程的环境中,遇到比较耗时的代码时,我们首先想到的是将任务分割,让它能够被中断,同时在其他任务到来的时候让出执行权,当其他任务执行后,再从之前中断的部分开始异步执行剩下的计算。所以关键是实现一套异步可中断的方案。那么我们将如何实现一种具备任务分割、异步执行、而且还能让出执行权的解决方案呢。React给出了相应的解决方案。

背景

React起源于 Facebook 的内部项目,用来架设 Instagram 的网站,并于 2013 年 5 月开源。该框架主要是一个用于构建用户界面的 JavaScript 库,主要用于构建 UI,对于当时双向数据绑定的前端世界来说,可谓是独树一帜。更独特的是,他在页面刷新中引入了局部刷新的机制。优点有很多,总结后react的主要特性如下:

1. 1 变换

框架认为 UI 只是把数据通过映射关系变换成另一种形式的数据。同样的输入必会有同样的输出。这恰好就是纯函数。

1.2 抽象

​实际场景中只需要用一个函数来实现复杂的 UI。重要的是,你需要把 UI 抽象成多个隐藏内部细节,还可以使用多个函数。通过在一个函数中调用另一个函数来实现复杂的用户界面,这就是抽象。

1.3 组合

为了达到可重用的特性,那么每一次组合,都只为他们创造一个新的容器是的。你还需要“其他抽象的容器再次进行组合。”就是将两个或者多个容器。不同的抽象合并为一个。

React 的核心价值会一直围绕着目标来做更新这件事,将更新和极致的用户体验结合起来,就是 React 团队一直在努力的事情。

变慢==>升级

随着应用越来越复杂,React15 架构中,dom diff 的时间超过 16.6ms,就可能会让页面卡顿。那么是哪些因素导致了react变慢,并且需要重构呢。

React15之前的版本中协调过程是同步的,也叫stack reconciler,又因为js的执行是单线程的,这就导致了在更新比较耗时的任务时,不能及时响应一些高优先级的任务,比如用户在处理耗时任务时输入页面会产生卡顿。页面卡顿的原因大概率由CPU占用过高产生,例如:渲染一个 React 组件时、发出网络请求时、执行函数时,都会占用 CPU,而CPU占用率过高就会产生阻塞的感觉。如何解决这个问题呢?

在我们在日常的开发中,JS的执行通常在单线程的环境中,遇到比较耗时的代码时,我们首先想到的是将任务分割,让它能够被中断,同时在其他任务到来的时候让出执行权,当其他任务执行后,再从之前中断的部分开始异步执行剩下的计算。所以关键是实现一套异步可中断的方案。

那么我们将如何实现一种具备任务分割、异步执行、而且还能让出执行权的解决方案呢。React给出了相应的解决方案。

2.1 任务划分

如何单线程的去执行分割后的任务,尤其是在react15中更新的过程是同步的,我们不能将其任意分割,所以react提供了一套数据结构让他既能够映射真实的dom也能作为分割的单元。这样就引出了我们的Fiber。

Fiber

Fiber是React的最小工作单元,在React中,一切皆为组件。HTML页面上,将多个DOM元素整合在一起可以称为一个组件,HTML标签可以是组件(HostComponent),普通的文本节点也可以是组件(HostText)。每一个组件就对应着一个fiber节点,许多fiber节点互相嵌套、关联,就组成了fiber树(为什么要使用链表结构:因为链表结构就是为了空间换时间,对于插入删除操作性能非常好),正如下面表示的Fiber树和DOM的关系一样:

FiberDOM树

   div#root div#root
      | |
    <App/> div
      | / \
     div p a
    / ↖
   / ↖
  p ----> <Child/>
             |
             a

​一个 DOM 节点一定要着一个光纤节点节点,但一个光纤节点却非常有匹配的 DOM 节点节点。fiber作为工作单元的结构如下:

export type Fiber = {
  // 识别 fiber 类型的标签。
  tag: TypeOfWork,

  // child 的唯一标识符。
  key: null | string,

  // 元素的值。类型,用于在协调 child 的过程中保存身份。
  elementType: any,

  // 与该 fiber 相关的已解决的 function / class。
  type: any,

  // 与该 fiber 相关的当前状态。
  stateNode: any,

  // fiber 剩余的字段

  // 处理完这个问题后要返回的 fiber。
  // 这实际上就是 parent。
  // 它在概念上与堆栈帧的返回地址相同。
  return: Fiber | null,

  // 单链表树结构。
  child: Fiber | null,
  sibling: Fiber | null,
  index: number,

  // 最后一次用到连接该节点的引用。
  ref:
    | null
    | (((handle: mixed) => void) & { _stringRef: ?string, ... })
    | RefObject,

  // 进入处理这个 fiber 的数据。Arguments、Props。
  pendingProps: any, // 一旦我们重载标签,这种类型将更加具体。
  memoizedProps: any, // 用来创建输出的道具。

  // 一个状态更新和回调的队列。
  updateQueue: mixed,

  // 用来创建输出的状态
  memoizedState: any,

  mode: TypeOfMode,

  // Effect
  effectTag: SideEffectTag,
  subtreeTag: SubtreeTag,
  deletions: Array<Fiber> | null,

  // 单链表的快速到下一个 fiber 的副作用。
  nextEffect: Fiber | null,

  // 在这个子树中,第一个和最后一个有副作用的 fiber。
  // 这使得我们在复用这个 fiber 内所做的工作时,可以复用链表的一个片断。
  firstEffect: Fiber | null,
  lastEffect: Fiber | null,

  // 这是一个 fiber 的集合版本。每个被更新的 fiber 最终都是成对的。
  // 有些情况下,如果需要的话,我们可以清理这些成对的 fiber 来节省内存。
  alternate: Fiber | null,
};

了解完光纤的结构,那么光纤与光纤之间是如何并创建的链表树链接的呢。这里我们引出双缓冲机制

​在页面中被刷新用来渲染用户界面的树,被称为 current,它用来渲染当前用户界面。每当有更新时,Fiber 会建立一个 workInProgress 树(占用内存),它是由 React 元素中已经更新数据创建的。React 在这个 workInProgress 树上执行工作,并在下次渲染时使用这个更新的树。一旦这个 workInProgress 树被渲染到用户界面上,它就成为 current 树。

在这里插入图片描述 2.2 异步执行

那么fiber是如何被时间片异步执行的呢,提供一种思路,示例如下

let firstFiber
let nextFiber = firstFiber
let shouldYield = false
//firstFiber->firstChild->sibling
function performUnitOfWork(nextFiber){
  //...
  return nextFiber.next
}

function workLoop(deadline){
  while(nextFiber && !shouldYield){
          nextFiber = performUnitOfWork(nextFiber)
          shouldYield = deadline.timeReaming < 1
        }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

我们知道浏览器有一个api叫做requestIdleCallback,它可以在浏览器空闲的时候执行一些任务,我们用这个api执行react的更新,让高优先级的任务优先响应。对于requsetIdleCallback函数,下面是其原理。

const temp = window.requestIdleCallback(callback[, options]);

对于普通的用户交互,上一帧的渲染到下一帧的渲染时间是属于系统空闲时间,Input输入,最快的单字符输入时间平均是33ms(通过持续按同一个键来触发),相当于,上一帧到下一帧中间会存在大于16.4ms的空闲时间,就是说任何离散型交互,最小的系统空闲时间也有16.4ms,也就是说,离散型交互的最短帧长一般是33ms。

requestIdleCallback回调调用时机是在回调注册完成的上一帧渲染到下一帧渲染之间的空闲时间执行

callback 是要执行的回调函数,会传入 deadline 对象作为参数,deadline 包含:

timeRemaining:剩余时间,单位 ms,指的是该帧剩余时间。

didTimeout:布尔型,true 表示该帧里面没有执行回调,超时了。

options 里面有个重要参数 timeout,如果给定 timeout,那到了时间,不管有没有剩余时间,都会立刻执行回调 callback。

但事实是requestIdleCallback存在着浏览器的兼容性和触发不稳定的问题,所以我们需要用js实现一套时间片运行的机制,在react中这部分叫做scheduler。同时React团队也没有看到任何浏览器厂商在正向的推动requestIdleCallback的覆盖进程,所以React只能采用了偏hack的polyfill方案。

requestIdleCallback polyfill 方案( Scheduler )

上面说到requestIdleCallback存在的问题,在react中实现的时间片运行机制叫做scheduler,了解时间片的前提是了解通用场景下页面渲染的整个流程被称为一帧,浏览器渲染的一次完整流程大致为

执行JS--->计算Style--->构建布局模型(Layout)--->绘制图层样式(Paint)--->组合计算渲染呈现结果(Composite)

帧的特性

帧的渲染过程是在JS执行流程之后或者说一个事件循环之后

帧的渲染过程是在一个独立的UI线程中处理的,还有GPU线程,用于绘制3D视图

帧的渲染与帧的更新呈现是异步的过程,因为屏幕刷新频率是一个固定的刷新频率,通常是60次/秒,就是说,渲染一帧的时间要尽可能的低于16.6毫秒,否则在一些高频次交互动作中是会出现丢帧卡顿的情况,这就是因为渲染帧和刷新频率不同步造成的 用户通常的交互动作,不要求一帧的渲染时间低于16.6毫秒,但也是需要遵循谷歌的RAIL模型的

那么Polyfill方案是如何在固定帧数内控制任务执行的呢,究其根本是借助requestAnimationFrame让一批扁平的任务恰好控制在一块一块的33ms这样的时间片内执行。

Lane

以上是我们的异步调度策略,但是仅有异步调度,我们怎么确定应该调度什么任务呢,哪些任务应该被先调度,哪些应该被后调度,这就引出了类似于微任务宏任务的Lane

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

关于lane的设计可以看下这篇:

github.com/facebook/re…

应用场景

有了上面所介绍的这样一套异步可中断分配机制,我们就可以实现batchUpdates批量更新等一系列操作: 在这里插入图片描述 更新fiber前 在这里插入图片描述 更新fiber后

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

设计serve computer

我们都写过获取数据的代码,在获取数据前展示loading,数据获取之后取消loading,假设我们的设备性能和网络状况都很好,数据很快就获取到了,那我们还有必要在一开始的时候展示loading吗?如何才能有更好的用户体验呢?

看下下面这个例子

function getSomething(id) {
  return fetch(`${host}?id=${id}`).then((res)=>{
    return res.param
  })
}

async function getTotalSomething(id1, id2) {
  const p1 = await getSomething(id1);
  const p2 = await getSomething(id2);

  return p1 + p2;
}

async function bundle(){
  await getTotalSomething('001', '002');
}

我们通常可以用async+await的方式获取数据,但是这会导致调用方法变成异步函数,这就是async的特性,无法分离副作用。

分离副作用,参考下面的代码

function useSomething(id) {
  useEffect((id)=>{
      fetch(`${host}?id=${id}`).then((res)=>{
       return res.param
      })
  }, [])
}

function TotalSomething({id1, id2}) {
  const p1 = useSomething(id1);
  const p2 = useSomething(id2);

  return <TotalSomething props={...}>
}

这就是hook解耦副作用的能力。

解耦副作用在函数式编程的实践中非常常见,例如redux-saga,将副作用从saga中分离,自己不处理副作用,只负责发起请求。

function* fetchUser(action) {
   try {
      const user = yield call(Api.fetchUser, action.payload.userId);
      yield put({type: "USER_FETCH_SUCCEEDED", user: user});
   } catch (e) {
      yield put({type: "USER_FETCH_FAILED", message: e.message});
   }
}

严格意义上讲react是不支持Algebraic Effects的,但是借助fiber执行完更新之后交还执行权给浏览器,让浏览器决定后面怎么调度,Suspense也是这种概念的延伸。

const ProductResource = createResource(fetchProduct);

​const Proeuct = (props) => {
    const p = ProductResource.read( // 用同步的方式来编写异步代码!
          props.id
    );
  return <h3>{p.price}</h3>;
}

function App() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <Proeuct id={123} />
      </Suspense>
    </div>
  );
}

可以看到ProductResource.read是同步的写法,把获取数据的部分分离出了Product组件之外,原理是ProductResource.read在获取数据之前会throw一个特殊的Promise,由于scheduler的存在,scheduler可以捕获这个promise,暂停更新,等数据获取之后交还执行权。这里的ProductResource可以是localStorage甚至是redis、mysql等数据库等。这就是我理解的server componet的雏形。

本文作为react16.5+版本后的核心源码内容,浅析了异步调度分配的机制,了解了其中的原理使我们在系统设计以及模型构建的情况下会有较好的大局观。对于较为复杂的业务场景设计也有一定的辅助作用。这只是react源码系列的第一篇,后续会持续更新,希望可以帮到你。

happy hacking~~