编者按:本文作者奇舞团前端开发工程师苏畅。
代码参照React 16.13.1
假设React是你日常开发的框架,在日复一日的开发中,你萌生了学习React源码的念头,在网上一顿搜索后,你发现这些教程可以分为2类:
-《xx行代码带你实现迷你React》,《xx行代码实现React hook》这样短小精干的文章。如果你只是想花一点点时间了解下React的工作原理,我向你推荐 这篇文章1,非常精彩。
-《React Fiber原理》,《React expirationTime原理》这样摘录React源码讲解的文章。如果你想学习React源码,当你都不知道Fiber是什么,不知道expirationTime对于React的意义时,这样的文章会给人“你讲解的代码我看懂了,但这些代码的作用是什么”的感觉。
我要写的这个系列文章和对应仓库的存在就是为了解决这个问题。
简单来说,这个系列文章会讲解React为什么要这么做,以及大体怎么做,但不会有大段的代码告诉你怎么做。
当你看完文章知道我们要做什么后,再来看仓库2中具体的代码实现。
同时为了防止堆砌很多功能后,代码量太大影响你理解某个功能的实现,我为仓库每个功能的实现打了一个git tag。
这是这个系列第二篇文章。
在从0实现React ?1 架构设计与首屏渲染3,我们介绍了
相较于首屏渲染的更新,非首屏渲染的更新会有一些不同,在这篇文章中我们来聊聊具体有哪些不同,以及这些不同是如何实现的。
让我们开始吧 ? ? ?
在上一篇文章的这里4,我们介绍了,更新会经历schedule-render-commit三个阶段。我们分别从三个阶段来聊聊非首屏渲染的不同之处。
在首屏渲染中,更新是由reactDOM.render方法的调用产生,唯一的任务是渲染一整棵DOM树,没有其他任务与他竞争谁该优先进入render阶段。
这一点,在非首屏渲染时是不同的。
在非首屏渲染中,更新一般是通过用户触发了事件来产生。
React将事件分为三类:
名称 | 解释 | 举例 |
---|---|---|
DiscreteEvent | 离散事件,这些事件都是离散触发的 | blur、focus、 click、 submit、 touchStart |
UserBlockingEvent | 用户阻塞事件,这些事件会阻塞用户的交互 | touchMove、mouseMove、scroll、drag、dragOver |
ContinuousEvent | 连续事件,需要同步执行,不能被中断,优先级最高。 | load、error、loadStart、abort、animationEnd |
源码中有很长一段全局变量申明,我截取一段,你随意感受下
不同的事件被赋予了不同的优先级,不同的优先级对应了不同的延迟时间。
// 不同的优先级var NoPriority = 0;var ImmediatePriority = 1;var UserBlockingPriority = 2;var NormalPriority = 3;var LowPriority = 4;var IdlePriority = 5;
// 不同优先级对应的延迟时间var maxSigned31BitInt = 1073741823; // Times out immediatelyvar IMMEDIATE_PRIORITY_TIMEOUT = -1; // Eventually times outvar USER_BLOCKING_PRIORITY = 250;var NORMAL_PRIORITY_TIMEOUT = 5000;var LOW_PRIORITY_TIMEOUT = 10000; // Never times outvar IDLE_PRIORITY = maxSigned31BitInt; // Tasks are stored on a min heap
更新会被赋予一个任务过期时间,其计算公式类似
过期时间 = 当前时间 + 一个延迟时间
// 更新的计算公式fiber.expirationTime = currentTime + timeout;
举个例子 ?
假设我们有2个更新,更新1的优先级是ImmediatePriority,对应ImmediatePriority的延迟时间是IMMEDIATE_PRIORITY_TIMEOUT,也就是 -1。
更新2的优先级是NormalPriority,对应NormalPriority的延迟时间是NORMAL_PRIORITY_TIMEOUT,也就是 5000。
// 更新1的过期时间fiber.expirationTime = currentTime - 1;// 更新2的过期时间fiber.expirationTime = currentTime + 5000;
可以看到,更新1的过期时间小于当前时间,代表这个更新已经过期,需要立即执行。而更新2的过期时间在当前时间的基础上还要过5000个时间单位才会过期。
所以经过schedule阶段的调度,更新1会优先进入render以及后续的commit阶段。
对于如何调度优先级,我们已经有了答案:
ps:后续文章会详细介绍schedule流程,这部分代码已经在现 v6版本5中实现。
接下来在介绍render与commit流程时,我们使用如下例子:
ps:React hook的首屏/非首屏渲染已经在v46中实现。
import React from 'react';const {useState} = React;
function Counter() { const [count, updateCount] = useState(0);
return <div onClick={() => updateCount(count + 1)}>{count}</div>}
当我们经过schedule阶段的调度终于进入render阶段。在上一篇文章的这里7我们介绍了beginWork方法。
之前只是简单介绍了他做的工作,现在让我们稍稍看一眼他的代码8:
function beginWork(current, workInProgress) { switch (workInProgress.tag) { // 省略... case HostRoot: // 省略... case FunctionComponent: const Component = workInProgress.type; return updateFunctionComponent( current, workInProgress, Component, workInProgress.pendingProps ); case ClassComponent: return updateClassComponent(current, workInProgress, ...); case HostComponent: // 省略... case HostText: // 省略... }}
总的来说,就是一个大大的switch case,根据fiber的类型进入不同的更新函数。
针对我们的Counter例子,
function Counter() { const [count, updateCount] = useState(0);
return <div onClick={() => updateCount(count + 1)}>{count}</div>}
作为函数组件,会进入updateFunctionComponent方法。
function updateFunctionComponent(current, workInProgress, Component, nextProps) { let nextChildren = renderWithHooks(current, workInProgress, Component, nextProps); // 省略... reconcileChildren(current, workInProgress, nextChildren); return workInProgress.child;}
在renderWithHooks内部会调用Component,即调用Counter函数
renderWithHooks(current, workInProgress, Component, props) { // ...省略 const children = Component(props);
// ...省略 return children;}
所以对于首屏渲染
// nextChildren 值为JSX对象nextChildren = <div>0</div>;
对于第一次点击产生的更新
nextChildren = <div>1</div>;
拓展小课堂?️~~~
对于ClassComponent ,会进入updateClassComponent,也有类似函数组件的逻辑,区别是多了一些生命周期勾子的调用,具体步骤如下:
为什么在React16这几个我们熟知的生命周期勾子名称前面加上了UNSAFE_前缀呢?
我们在讲schedule阶段时讲到任务有优先级,低优先级的任务即使进入render阶段,当schedule遇到更高优先级的任务时会中断已经在render中的低优先级任务,优先处理高优任务。
所以低优任务可能多次调用updateClassComponent,相应的勾子可能被触发多次。
那为什么如componentDidUpdate这样的勾子没有UNSAFE_前缀呢?
因为这个勾子是在commit阶段触发的,commit是一个不可中断的同步过程。
拓展小课堂 结束~~~
哎呀,一聊就偏题了,偏题了,老师喝口浓茶,清清嗓子 ???
回到我们的updateFunctionComponent
function updateFunctionComponent(current, workInProgress, Component, nextProps) { let nextChildren = renderWithHooks(current, workInProgress, Component, nextProps); // 省略... reconcileChildren(current, workInProgress, nextChildren); return workInProgress.child;}
我们已经知道nextChildren指本次更新的JSX对象,现在我们关注reconcileChildren方法的另2个参数current和workInProgress。
我们知道,beginWork会创建并返回子fiber节点,这个子节点会被赋值给workInProgress,接着递归调用beginWork,最终创建一棵fiber树。
那么当commit阶段完成DOM渲染后这棵fiber树会怎么处理呢?这棵树的节点会从workInProgress变成current。
我们可以从字面意思上来看:
所以首屏渲染时current === null;事实上,我们也是通过 current === null ?来判断本次更新是否是首屏渲染。
在fiber内部,通过alternate参数链接workInProgress与current。
workInProgress.alternate === current;current.alternate === workInProgress;
这个比较并返回新的子fiber的过程,叫做reconcile9(代码见此处10),我们熟知的Diff算法11就是在这个过程中执行。
图上正如我们所说,通过判断current是否存在来区分是否是首屏渲染。
PS:我们会在后续文章中深入Diff算法看看React如何在O(n)复杂度内完成reconcile。
在上一篇文章12我们提到,首屏渲染时执行completeWork为每个Fiber生成对应的DOM节点。
让我们小小的瞟一眼具体的代码:
function completeWork(current, workInProgress) { // ...省略 switch (workInProgress.tag) { case HostRoot: // ...省略 return null; case HostComponent: const type = workInProgress.type; if (current && workInProgress.stateNode) { updateHostComponent(current, workInProgress, type, newProps); return null; } // ...省略 let instance = createInstance(type, newProps); appendAllChildren(instance, workInProgress); workInProgress.stateNode = instance; // ...省略 return null; case HostText: // ...省略 }}
果然,又是个大大的switch case。
这里我们关注下 case HostComponent,也就是原生DOM节点(div、span...)对应的fiber节点。在我们的Counter例子中,
function Counter() { const [count, updateCount] = useState(0);
return <div onClick={() => updateCount(count + 1)}>{count}</div>}
FunctionComponentfiber(即Counter对应的fiber)的child为HostComponentfiber(即div fiber)。
首屏渲染时div fiber进入completeWork由于current === null,所以会进入
// instance即组件实例,也就是div DOM节点let instance = createInstance(type, newProps);appendAllChildren(instance, workInProgress);workInProgress.stateNode = instance;
正如我们前一篇文章这里提到的13,在appendAllChildren方法中,我们遍历一下这个HostComponentfiber节点的所有子HostComponent节点,将子节点的DOM节点插入到instance(创建的DOM节点)下。代码见这里14
在completeWork中每次面对HostComponent都执行appendAllChildren,那么当我们向上遍历到根fiber时就有一棵构建好的离屏DOM树了。
对于非首屏渲染,由于current !== null,所以会走到
updateHostComponent(current, workInProgress, type, newProps);return null;
对于Counter例子的div fiber,执行updateHostComponent前他的数据结构如下:
{ type: 'div', stateNode: HTMLDivElement, effectTag: 0, updateQueue: null // 省略...}
执行完updateHostComponent后,数据结构如下:
{ type: 'div', stateNode: HTMLDivElement, effectTag: 4, updateQueue: ["children", "1"] // 省略...}
我们看到,effectTag由0变为4,对应二进制的Update。
// fiber的副作用标志export const NoEffect = /* */ 0b00000000000;export const Placement = /* */ 0b00000000010;export const Update = /* */ 0b00000000100;// 省略...
拓展小课堂 ?♂️~~~
老师又来拖堂啦。React为什么用二进制来表示副作用标记呢?因为可以利用位运算高效操作标记。
// 初始化,没有effectfiber.effectTag = NoEffect;// 标记Updatefiber.effectTag |= Update;// 标记Placement,该fiber同时有Update与Placement标记fiber.effectTag |= Placement;// 删除 Placementfiber.effectTag &= ~Placement;// 判断是否有 Placement标记(fiber.effectTag & Placement) === NoEffect ? false : true;
拓展小课堂 结束~~~
再看updateQueue,他用数组的形式保存变化的prop,在commit阶段,我们遍历这个数组,其中
updateQueue[i] === propKey;updateQueue[i + 1] === propValue;
从上面代码可知,对于非首屏渲染,completeWork会对比props是否变化,如果有变化赋值
workInProgress.effectTag |= Update;
终于,在经历了
我们终于来到最后的commit阶段。默默的拥抱自己?
此时我们会有一条effectList15,遍历他执行对应的副作用。非首屏渲染与首屏渲染不同的是,除了Placement,可能还有Deletion和Update操作。
同时,commit阶段还会执行如下生命周期勾子:
function commitLifeCycles(finishedRoot, current, ...) { // 省略... switch (finishedWork.tag) { // 省略... case ClassComponent: { const instance = finishedWork.stateNode; if (finishedWork.effectTag & Update) { if (current === null) { instance.componentDidMount(); } else { // 省略... instance.componentDidUpdate(prevProps, prevState, ...); } } } // 省略case ...}
值得注意的是,schedule阶段是异步的,render阶段可以是同步(任务过期)或异步的。而commit阶段因为涉及到DOM操作,为了防止由于异步更新DOM导致用户看到未变化完全的DOM,所以是同步的。
所以在commit阶段触发的生命周期勾子都是安全,并被保证只会执行一次的。
这么长的文章,看到了这里,先给自己鼓鼓掌吧,不容易不容易???
我们终于讲完了组件的更新。虽然在这过程中,我们没有具体讲ReactDOM.render,this.setState,useState这些改变state的操作是如何工作的。但相信你已经了解,他们是殊途同归的。
React从13年5月第一次commit到现在已经1.3w次commit,在这期间主要API能一直保持不变,不得不佩服其理念的超前。
衷心希望这个系列文章能帮到和我一样想更深入了解这个前端里程碑框架的你。???