专栏介绍
本专栏将对 React(17.0.2)中核心的、开发者经常接触到的一些源码做解析讲解,包括 Fiber 架构、diff算法、Hook函数等内容,比较适合对 React 有一定了解,想要从源码的角度深入理解 React 底层原理的同学。
React架构
自 React 从15版更新到16版之后,其底层架构有了非常大的变化,丰富了 Reconcile(协调)、新增了 Schedule(调度)的概念。
Reconcile
对应源码中的 react-reconcile 包,主要作用是根据组件的最新状态,生成一个 element 对象,和已缓存的虚拟 DOM 进行对比,生成新的虚拟 DOM,这也是 React 的核心工作内容。React 在官方文档中也给出了相应的解释,对 Reconcile 的过程做了大概的讲解。
在某一时间节点调用 React 的
render()方法,会创建一棵由 React 元素组成的树。在下一次 state 或 props 更新时,相同的render()方法会返回一棵不同的树。React 需要基于这两棵树之间的差别来判断如何有效率的更新 UI 以保证当前 UI 与最新的树保持同步。
Reconcile 的步骤中,新旧虚拟 DOM 树对比的过程是最耗时的,尤其是当组件层级较深,应用较为复杂时。注意,这里的新虚拟 DOM 是在对比的过程中一步步根据组件 element 对象生成的。
生成将一棵树转换成另一棵树的最小操作数,即使在最前沿的算法中,该算法的复杂程度为 O(n3),其中 n 是树中元素的数量。
因此,我们不能按部就班的一一比对两棵树,我们需要做取舍,换一种思路,使用启发式算法,于是就有了我们常提到的 React 另一核心概念,diff 算法。diff 算法是建立在两个前提之上的:
- 每个组件的类型不会变,如果变了算法将不会继续对比其子组件,默认重新创建整个组件(包括其子组件)对应的虚拟 DOM 树,所以官方建议如果代码中出现两种不同类型的组件相互切换,但内容相似,尽量改成同种类型;
- 一个组件只会在其同级组件之间移动,不会移动到其他位置,此时可以使用 key 的直接对比省去多余的过程;因此列表当中的 key 应当是稳定且唯一的,不稳定的 key(比如通过
Math.random()生成的)会导致许多组件实例和 DOM 节点被不必要地重新创建,这可能导致性能下降和子组件中的状态丢失。
React 团队也验证了这两个前提条件,在绝大多数实际场景下都是成立的,这样 diff 算法就可以很好的满足React 渲染需要;但当组件足够多,层级够深时,React15 递归式不中断的 diff 偶尔也会阻塞页面渲染,毕竟每帧只有16ms。因此,为了不影响页面的正常渲染,React 不得不对 diff 过程进行拆分,调度资源。
时间切片
React 刻意将每一帧留给js执行的时间进行限制,所有的任务都将在这时间片段当中执行完成,如果没有,需要等待下一帧。
Schedule
Schedule 是一个单独的任务调度器,专门用于按照优先级调度执行任务,本质上可以和 React 分离,有自己单独的任务优先级定义,而 React 也有自己的 Lanes 优先级模型,因此在使用 Schedule 任务调度时,需要转换二者的任务优先级;
React 借助 Schedule,可以对 Reconcile 的任务进行暂停和恢复操作,让页面更加流畅。要想 diff 的过程能够被 Schedule 调度,实现异步可中断的更新,需要先实现:
- 为不同类型的任务分配优先级;
- 有更高优先级的任务时终止当前任务;
- 恢复执行之前停滞的任务;
- 没有任务时终止调度。
为了满足以上条件,React16 引入了 Concurrent 模式和 Fiber 架构,后面的章节中会更详细的讲到 Fiber,目前只需要了解它的结构与 DOM 树比较相似,每个 Fiber 都有其父节点、子节点和兄弟节点的引用,新旧 props,新旧 state,以及其不同阶段需要执行的副作用事件等内容,具体的结构在下一章节会讲到。我们先来看看 Schedule 都包含了什么。
task对象
Schedule 每次接受一个回调任务,就会为其创建一个 task 对象,根据优先级赋值过期时间,并将其放入到执行队列当中;
var newTask = {
id: taskIdCounter++, // 任务id,在 react 中是一个全局变量,每次新增 task 会自增+1
callback: callback, // 在调度过程中被执行的回调函数
priorityLevel: priorityLevel, // 通过 Scheduler 和 React Lanes 优先级融合过的任务优先级
startTime: startTime, // 任务开始时间
expirationTime: expirationTime, // 任务过期时间
sortIndex: -1 // 排序索引, 过期时间越小, 越紧急的任务排在最前面
};
timerQueue和taskQueue
- timerQueue:依据任务的过期时间(expirationTime)排序,过期时间越早,说明越紧急,过期时间小的排在前面。过期时间根据任务优先级计算得出,优先级越高,过期时间越早;
- taskQueue:依据任务的开始时间(startTime)排序,开始时间越早,说明会越早开始,开始时间小的排在前面。任务进来的时候,开始时间默认是当前时间,如果进入调度的时候传了延迟时间,开始时间则是当前时间与延迟时间的和。
在创建 task 时,优先执行的任务放入 taskQueue 中,不紧急的任务放入 timerQueue 中,在执行过程中会不断的通过 advanceTimers 方法将 timerQueue 中快过期的任务放入 taskQueue 中;
Schedule workLoop
Schedule 中有一个循环执行单元,每次循环都会取出 taskQueue 中优先级最高的任务,再判断当前时间片段是否到期,如果不是将结束循环过程,等待下一帧继续;如果是,执行任务的 callback 回调,移出 taskQueue,然后一直循环此步骤;
总结
了解了 Reconcile 和 Schedule,那么两个模块的协作也很简单,只需要将 Reconcile 的入口函数交给 Schedule,并在遍历到 Fiber 这棵树每个节点时,都判断当前时间片段是否过期,如果时间切片限制时间已到,把剩下的任务打包交给 Schedule,然后等待下一帧就可以了。