写点什么

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

  • 2022 年 2 月 18 日
  • 本文字数:5307 字

    阅读完需:约 17 分钟

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

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 的关系一样:


Fiber树 DOM树
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 firstFiberlet nextFiber = firstFiberlet shouldYield = false//firstFiber->firstChild->siblingfunction 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 的设计可以看下这篇:


https://github.com/facebook/react/pull/18796github.com/facebook/react/pull/18796

应用场景

有了上面所介绍的这样一套异步可中断分配机制,我们就可以实现 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~~

发布于: 刚刚阅读数: 2
用户头像

高效学习,从有道开始 2021.03.10 加入

分享有道人的技术思考与实践。

评论

发布
暂无评论
剖析react核心设计原理--异步执行调度