React函数组件性能优化三部曲(一)

最近在看react应用更新时的一些逻辑处理,因为之前的fiber reconciler协调流程重点学习的是FiberTree的创建过程,对整个更新流程没有进行深入的了解。本次在学习应用更新时也对函数组件性能优化有了更加深刻的认识,所以也是趁着有时间整理了一下文档,记录一下关于函数组件性能优化的相关内容。

本次内容会分成以下三个章节来讲解函数组件性能优化的逻辑原理:

  • 普通函数组件更新。
  • memo组件更新。
  • 性能优化使用总结。

本章节将讲解普通函数组件的更新逻辑,因为我们只有先知道普通函数组件更新特点,才能更好地理解下一章节React.memo方法是如何来优化普通函数组件的。

1,案例准备

首先我们准备一个函数组件更新的测试案例:

js 复制代码
// index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
js 复制代码
// App.js
import React from 'react'
import MyFun from './views/MyFun';

export default function App() {
  console.log('App组件运行了')
  return (
    <div className="App">
      <div>react源码调试</div>
      <MyFun name='MyFun'></MyFun>
    </div>
  );
}
js 复制代码
// MyFun.js
import { useState } from 'react'
import Child from './Child'

export default function MyFun(props) {
  console.log('MyFun组件运行了')
  const [count, setCount] = useState(1)
  function handleClick() {
    setCount(count + 1)
  }
  return (
    <div className='MyFun'>
      <div>state: {count}</div>
      <Child name='Child'></Child>
      <button onClick={handleClick}>更新</button>
    </div>
  )
}
js 复制代码
// Child.js
export default function Child(props) {
  console.log('Child组件运行了')
  return (
    <div className='Child'>
      <div>Child子组件</div>
      <div>name: {props.name}</div>
    </div>
  )
}

案例加载完成的页面内容:

Fiber树结构

案例对应的Fiber树结构:

这里没有展开Child组件内部的Fiber结构,因为我们这里关注的是它组件节点的更新。

注意: 案例唯一的更新按钮在MyFun组件中:

  • 它的父组件App自身没有状态,也没有给MyFun传递动态props
  • 它的子组件Child自身没有状态,也没有给它传递动态props

下面我们来看MyFun组件更新,到底会对它的父组件和子组件造成什么样的影响。

2,触发更新

点击更新按钮,查看打印结果:

可以发现MyFun组件重新渲染了,它的子组件Child也重新渲染了,它的父组件App没有重新渲染。

下面我们就来分析这整个的更新流程。

触发状态变化

点击更新按钮,就会触发DOM事件回调,然后执行我们的状态修改操作:

js 复制代码
function handleClick() {
  setCount(count + 1)
}

此时就会来到useState对应的dispatchSetState方法中:

该方法会执行以下几个步骤:

  • 创建一个update更新对象。
  • update对象添加到queue队列中,形成一个单向环状链表。
  • 调用scheduleUpdateOnFiber方法,开启一个新的调度更新任务。

注意:useState hookdispatch方法触发的状态变化,在触发调度之前,会有一个eagerState优化策略校验,也就是快速计算出本次状态变化的结果,与原来的状态进行对比,判断是否发生了真的状态变化:

  • 如果没有发生变化,则会进入eagerState策略,不会发起调度更新任务。
  • 如果发生了变化,则开启一个新的调度更新任务。
js 复制代码
if (is(eagerState, currentState)) {
 /**
 * 如果相等,则满足优化策略,只是将update更新对象添加到queue队列,不会开启新的更新调度。
 * 【注意:】这里是针对单次修改state做的eagerState优化策略:本次修改无变化,就不发起调度更新【只需要将update对象添加到临时链 	* 表即可,等会真正变化的dispatchSetState来触发调度更新】
 * updateReducer里面的Bailout策略是针对一个hook对象的整个update链表计算完成后,即一个state多次修改最终计算之后,再判断   
 * state有没有变化:1,有变化进行正常的组件更新; 2,无变化则进入Bailout优化策略。
 *
 */
  enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
  return;
}

注意: eagerState策略校验针对的是单次修改状态的校验。

js 复制代码
function handleClick() {
    // 每次修改都会执行一次校验
    setCount(1)
    setCount(2)
}

比如在DOM事件中一次性执行多次状态修改,则每次dispatch方法都会执行一次eagerState的策略校验。

当前我们的状态发生了真实的变化,所以就会发起一个新的调度更新任务,触发一次应用的更新流程。

由点击事件触发的更新任务为同步优先级,但是react会默认使用微任务来处理同步任务。

最终会来到workLoopSync方法中,开始创建Fiber树,也就是应用更新的核心流程。

前面省略了一部分调度更新的逻辑,因为这部分不是本章的重点。关于这部分的详细逻辑可以查看《React18.2x源码解析:函数组件的加载过程》,这里不会过多关注。

根节点的处理

在解析workLoopSync方法之前,我们还得先知道Fiber树根节点的处理。

注意: react应用的每次更新渲染都会构建一颗新的Fiber树,也就是说构建Fiber树的过程就是应用更新的核心流程。

在这个流程中每个Fiber节点都会执行两项工作:

  • beginWork工作
  • completeWork工作

组件节点的工作重点在beginWork中,普通DOM节点的工作重点在completeWork中。

HostFiber 节点是Fiber树的根节点【可以看前面的树结构】,每次构建Fiber树的工作都是从这个根节点开始的,而HostFiber 是在workLoopSync方法执行之前就已经创建完成的,也就是说在每次构建Fiber树之前它的根节点就已经确定了。所以workLoopSync第一个处理的就是HostFiber 节点,开始执行它的beginWork工作。

在开始它的beginWork工作之前,我们得先知道HostFiber 的创建,因为这个非常重要。

在执行workLoopSync方法之前,会调用一个prepareFreshStack方法来做本次更新的准备工作。

prepareFreshStack
js 复制代码
function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
  ...
  
  // 使用旧的hostFiber节点,来创建一个工作中的hostFiber,即WorkInProgress【新的】
  const rootWorkInProgress = createWorkInProgress(root.current, null);
  // 并且将全局变量:workInProgress赋值为 新的hostFiber节点
  workInProgress = rootWorkInProgress;
  ...
}

这个准备工作首先一点就是:确定了本次构建Fiber树的根节点,也就是hostFiber

即调用createWorkInProgress方法来创建一个新的Fiber节点,因为当前传递的参数是root.current,它的意思就是根据旧的hostFiber节点来创建新的hostFiber。并且将这个节点赋值给全局变量workInProgress,所以下面我们进入workLoopSync方法时,全局变量workInProgress存储的就是hostFiber根节点对象。

这里我们需要再关注一下createWorkInProgress方法:它是更新阶段一个通用的创建Fiber节点的方法 ,并不只是用于创建hostFiber节点,其他的Fiber节点更新时也会用到这个方法。

createWorkInProgress
js 复制代码
// packages\react-reconciler\src\ReactFiber.new.js
export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
  let workInProgress = current.alternate;
  if (workInProgress === null) {
    /**
     * 该Fiber节点第一次更新的情况:
     * 它的alternate属性为null,即workInProgress为null。需要调用FiberNode构造函数创建新的Fiber节点,
     * 然后复用current节点信息
     */
    workInProgress = createFiber(
      current.tag,
      pendingProps, // 复用props
      current.key,
      current.mode,
    );
    // 复用相关信息
    workInProgress.elementType = current.elementType;
    workInProgress.type = current.type;
    workInProgress.stateNode = current.stateNode;

    workInProgress.alternate = current;
    current.alternate = workInProgress;
  } else {
    
    /**
     * 表示该Fiber节点的第二次更新及以上:
     * 不需要再使用FiberNode构造函数来创建Fiber对象了,直接复用current节点相关的信息
     * 
     */
    workInProgress.pendingProps = pendingProps;
    workInProgress.type = current.type;
    workInProgress.flags = NoFlags;

    workInProgress.subtreeFlags = NoFlags;
    workInProgress.deletions = null;
  }

  # 复用通用的current节点信息
  workInProgress.flags = current.flags & StaticMask;
  workInProgress.childLanes = current.childLanes;
  workInProgress.lanes = current.lanes;

  workInProgress.child = current.child;
  workInProgress.memoizedProps = current.memoizedProps;
  workInProgress.memoizedState = current.memoizedState;
  workInProgress.updateQueue = current.updateQueue;

  const currentDependencies = current.dependencies;
  workInProgress.dependencies =
    currentDependencies === null
      ? null
      : {
          lanes: currentDependencies.lanes,
          firstContext: currentDependencies.firstContext,
        };

  // These will be overridden during the parent's reconciliation
  workInProgress.sibling = current.sibling;
  workInProgress.index = current.index;
  workInProgress.ref = current.ref;

  # 返回新的Fiber节点
  return workInProgress;
}

我们首先查看这个方法的两个参数:

  • current:旧的Fiber节点。
  • pendingProps:新的props对象。

根据这两个参数我们就可以得知这个方法的作用:复用current旧的节点信息以及新的props对象来创建对应的新的Fiber节点。

虽然这个方法看着内容很多,但是它的核心就是一个if else结构,下面我将它的逻辑整理一下:

js 复制代码
export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
	let workInProgress = current.alternate;
	if (workInProgress === null) {
		// 表示该Fiber节点第一次更新
        workInProgress = createFiber(
      		current.tag,
      		pendingProps, // Fiber节点的beginWork工作时,就会判断新旧props
      		current.key,
      		current.mode,
    	);
		...
        
	} else {
	    // 表示该Fiber节点非第一次更新
        workInProgress.pendingProps = pendingProps;
	    ...
	}
	
	# 复用通用信息
    ...
    
    // 返回新的节点
    return workInProgress
}

整理之后我们就可以发现它的逻辑并不复杂,这里根据当前Fiber节点的alternate属性值判断当前节点是否为第一次更新:

  • null时,代表当前Fiber节点为第一次更新,需要使用FiberNdoe构造函数创建新的Fiber实例,然后再复用旧的节点信息。
  • 不为null时,代表当前Fiber节点不是第一次更新,直接复用旧节点的相关信息。

if else结构之后是复用旧节点的一些通用信息,最后返回对应的新的Fiber节点。

注意: 此方法的核心在于通过复用旧的节点信息来创建新的Fiber节点,至于此节点是多少次更新并不是重点。

虽然createWorkInProgress方法内部复用了旧节点的很多属性内容,但是我们这里的重点是关注它的第二个参数:pendingProps

也就是Fiber.pendingProps属性的设置,它代表该节点新的props数据,此属性会影响该节点更新时的beginWork工作中能否进入Bailout策略,后面就会讲解这部分逻辑。

比如当前创建新的hostFiber节点时,第二个属性默认为null

js 复制代码
const newFiber = createWorkInProgress(root.current, null);

也就是说:所以无论多少次更新,hostFiber节点的pendingProps属性都是null

以上就是createWorkInProgress方法的主要内容,下面我们就正式进入workLoopSync方法,开始Fiber树的构建。

3,更新流程

执行流程图

beginWork工作执行流程图:

下面我们会通过分析几个重要的Fiber节点来学习react的更新流程逻辑。

HostFiber节点

通过上面我们已经知道了第一个执行的是hostFiber根节点:

下面我们直接来到它的beginWork工作:

js 复制代码
beginWork(current, workInProgress, lanes)
js 复制代码
function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {

  if (current !== null) {
    # 更新流程
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;
    // 主要判断:props变化和context上下文变化
    if (oldProps !== newProps || hasLegacyContextChanged() ) {
      // props有变化或者上下文有变化时,Fiber节点自身需要更新就会进入这里
      didReceiveUpdate = true;
    } else {

      // props和上下文都没有变化的情况,检查当前Fiber节点自身是否有挂起的调度更新或者上下文修改
      // 在修改state触发的更新,这里检查的结果就会为true
      const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
        current,
        renderLanes,
      );
      if (!hasScheduledUpdateOrContext && (workInProgress.flags & DidCapture) === NoFlags) {
        // hasScheduledUpdateOrContext为false,即Fiber自身没有更新的情况下:进入Bailout策略
        // No pending updates or context. Bail out now.
        didReceiveUpdate = false;
        return attemptEarlyBailoutIfNoScheduledUpdate(
          current,
          workInProgress,
          renderLanes,
        );
      }
        
      // 当前Fiber存在更新的情况:需要进入正常的组件更新流程,重新调用组件函数,生成新的react元素对象
      didReceiveUpdate = false;
    }
  } else {
	# 加载流程
  }

  workInProgress.lanes = NoLanes;
  // 根据Fiber.tag值走不同的组件处理逻辑
  switch(workInProgress.tag) {
    case FunctionComponent: {}
    case ClassComponent: {}
    ...
  }
}

beginWork方法通过判断current是否有值来区分此节点是加载还是更新,当前我们是更新流程,current都是有值的,直接查看更新逻辑。不过在这里还是要提前说明一下:在react中,影响一个Fiber节点更新的只有这三种因素:

  • state
  • props
  • context

首先取出新旧Fiber节点的对应的props对象,即新旧props数据:

js 复制代码
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;

然后判断新旧props是否相等,前面已经说了hostFiber节点的props默认是null,所以这里是相等的。

js 复制代码
if (oldProps !== newProps || hasLegacyContextChanged() ) {
  // props变化的情况:
  didReceiveUpdate = true;
} else {
  // props无变化的情况:
  const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(current, renderLanes);
  // 判断有没有等待的更新
  if (!hasScheduledUpdateOrContext && (workInProgress.flags & DidCapture) === NoFlags) {
      didReceiveUpdate = false;
      // 无更新,进入Bailout策略
      return attemptEarlyBailoutIfNoScheduledUpdate(
          current,
          workInProgress,
          renderLanes,
      );
  }
  // 有更新,进入正常的更新流程
  didReceiveUpdate = false;
}

注意: 这里虽然有context上下文的变化检查,它针对的是旧版的LegacyContext,新版的context变化表现在lanes中,所以第一部分的判断我们主要关注props的变化即可。

propsLegacyContext都无变化的时候,进入else分支处理:

js 复制代码
// 检查是否存在调度更新或者context变化
const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(current, renderLanes);
js 复制代码
function checkScheduledUpdateOrContext(current: Fiber, renderLanes: Lanes,): boolean {
  // 检查是否存在更新
  const updateLanes = current.lanes;
  // 检查当前节点lanes,有没有renderLanes
  if (includesSomeLane(updateLanes, renderLanes)) {
    return true;
  }
  return false;
}

继续检查当前Fiber节点是否有state变化触发的更新或者context变化。

  • 这里检查的是current节点的lanes,因为组件在修改state触发更新时,会将renderLanes绑定到在current节点之上。
  • 同时这里也检查了context的变化,因为新版context在发生变化时也会为Consumer对应的Fiber节点的lanes属性绑定renderLanes,所以这里通过Fiber.lanes的值同时检查了Fiber节点可能会出现的两种更新。

然后定义一个变量hasScheduledUpdateOrContext接收校验的结果:

  • 如果结果为false,代表不需要更新,设置全局变量didReceiveUpdatefalse,然后进入Bailout策略逻辑,执行更进一步的优化程度判断。
  • 如果结果为true,表示有更新内容,但注意的是:有更新并不意味着state确实发生了变化,比如一个组件多次修改了state,它存在更新的任务,但有可能多次状态修改的最终结果是没有变化,所以只有在更新时计算state后明确发生了真正的状态变化,才会同步更新这个变量的值为true。所以这里还是设置的didReceiveUpdatefalse,进入下面的switch case结构,执行对应的组件更新逻辑,之后再根据计算后的状态值再次判断是否进行真正的更新。

同时在这里我们也可以发现,react内部存在默认的优化策略 :即在每个Fiber节点的beginWork工作中,通过一系列的判断来校验当前Fiber节点是否可以进入Bailout策略,来跳过该节点的更新工作。

当前我们的hostFiber节点满足Bailout策略,进入attemptEarlyBailoutIfNoScheduledUpdate方法。

js 复制代码
function attemptEarlyBailoutIfNoScheduledUpdate() {
	...
	return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}

直接查看最后一行代码,继续调用了一个Bailout策略方法:

js 复制代码
// Bailout策略核心方法
function bailoutOnAlreadyFinishedWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  if (current !== null) {
    workInProgress.dependencies = current.dependencies;
  }
  markSkippedUpdateLanes(workInProgress.lanes);

  /***
   * 
   * 检查子节点是否存在有更新的工作:这里应该是判断该组件的整个子树有没有更新工作?
   * 1,如果没有则可以直接跳过整个组件的更新流程
   * 2,如果有,则直跳过组件自身节点的更新流程
   * 
   **/
  if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
      return null;
  }

  // 来到这里:表示当前Fiber节点没有更新工作,但是它的子节点有,所以只能跳过该节点自身的更新流程
  // 在更新阶段,hostFiber,App根组件节点这些节点自身没有变化基本都会走这里
  cloneChildFibers(current, workInProgress);
  // 比如当前workInProgress为HostFiber, 它的child就是App根组件节点
  // 下一步就是开始新的App组件节点的beginWork工作
  return workInProgress.child;
}

这个方法是Bailout优化策略的核心方法,它的作用执行进一步的优化程度判断:

  • 高度优化:跳过整个子树的更新流程。
  • 一般优化:跳过自身节点的更新流程。

在这里我们还要了解Fiber节点上两个属性的区别:

  • lanes:如果存在renderLanes,则说明节点自身存在更新。
  • childLanes:如果存在renderLanes,则说明该节点的子树存在更新。

在进入Bailout策略之前,已经对Fiber.lanes进行了校验。而能够进入Bailout策略,已经说明节点自身是不存在更新需求的,所以bailoutOnAlreadyFinishedWork方法内只需要再对childLanes进行校验,就可以判断出最终的优化程度了。

这里检查当前节点的childLanes判断子树是否存在更新的工作:

  • 如果没有:则可以直接跳过该节点整个子树的更新流程。
  • 如果有:则只跳过该节点自身的更新流程。

在更新流程中:Fiber树的根节点一般都不会存在更新,但是它的子树一定是存在更新的,比如当前的MyFun组件节点。

所以这里不满足高级优化的条件,就会执行cloneChildFibers方法,通过克隆复用来生成它的下一层子节点。

js 复制代码
export function cloneChildFibers(
  current: Fiber | null,
  workInProgress: Fiber,
): void {
  if (current !== null && workInProgress.child !== current.child) {
    throw new Error('Resuming work not yet implemented.');
  }
  if (workInProgress.child === null) {
    return;
  }
  // 注意:current.child和workInProgress.child是指向的同一个child。
  // 这里的目的是通过现在的child内容,创建另外一个即alternate属性指向的新Fiber节点
  let currentChild = workInProgress.child;
  # 复用生成新的Fiber节点
  let newChild = createWorkInProgress(currentChild, currentChild.pendingProps);
  // 比如当前的workInProgress是hostFiber时,就会创建新的App组件节点,然后更新hostFiber.child
  workInProgress.child = newChild;

  newChild.return = workInProgress;

  # 如果旧child还有兄弟节点,则循环创建它们对应workInProgress
  // 比如<div>react源码调试</div>它有兄弟阶段MyFun函数组件,此时就会继续新建它的Fiber节点
  while (currentChild.sibling !== null) {
    currentChild = currentChild.sibling;
    newChild = newChild.sibling = createWorkInProgress(
      currentChild,
      currentChild.pendingProps,
    );
    newChild.return = workInProgress;
  }
  // 无兄弟节点,执行结束
  newChild.sibling = null;
}

cloneChildFibers方法的作用就是克隆下一层子节点,比如当前hostFiber的子节点就只有一个App组件,所以这里只需要克隆生成App组件对应的新的Fiber节点即可,同时这里我们也可以看到这个方法的内部也是调用的createWorkInProgress方法:

js 复制代码
let currentChild = workInProgress.child;
// 创建新的Fiber节点
let newChild = createWorkInProgress(currentChild, currentChild.pendingProps);
// 比如当前的workInProgress是hostFiber时,就会创建新的App组件节点,然后更新hostFiber.child
workInProgress.child = newChild;

前面已经讲过这个方法,它的作用就是:复用旧的节点信息生成新的Fiber节点,当前就是生成一个属于App组件的新的Fiber节点,然后更新hostFiber节点的child属性为新的App组件节点。

js 复制代码
return workInProgress.child;

最后返回子节点即App组件节点,开始App组件节点的beginWork工作。

App组件节点

下面再次来到App组件节点的beginWork工作处理:

注意: App组件节点是通过克隆复用旧的节点信息生成的,它的props对象也是原props对象,即新旧props数据相等,所以这里App组件节点依然可以进入Bailout策略,会执行跟HostFiber节点一样的处理逻辑。

最后还是会来到cloneChildFibers方法中,生成App组件节点的子节点【div.App】。

div.App】节点也是一样的beginWork工作逻辑,就不再重复说明了。这里我们加快进度,直接来到cloneChildFibers方法中,再次来到该方法中时,因该节点有两个子节点,所以在第一个子节点生成之后,判断发现它的sibling属性有值,就会循环继续生成它的兄弟节点,即我们的MyFun函数组件节点。

后面的执行逻辑就是:

js 复制代码
// 1,执行:div 源码调试 节点的beginWork工作
// 2,执行:div 源码调试 节点的completeWork工作
// 3,执行:MyFun 节点的beginWork工作
MyFun组件节点

下面我们直接来MyFun组件节点的beginWork工作。

因为MyFun组件节点也是通过克隆生成的,所以它的新旧props也是相等的。

但是重点来了: 本次的更新流程是由该组件修改state触发的,所以该Fiber.lanes含有renderLanes即存在更新。

所以这里再调用checkScheduledUpdateOrContext校验时就会返回trueMyFun函数组件节点就无法进入Bailout策略。

js 复制代码
if (oldProps !== newProps || hasLegacyContextChanged() ) {
  // props变化的情况:
  didReceiveUpdate = true;
} else {
  // props无变化的情况:
  const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(current, renderLanes);
  // 判断有没有等待的更新
  if (!hasScheduledUpdateOrContext && (workInProgress.flags & DidCapture) === NoFlags) {
	// 无更新的情况:
  }
  # 有更新,进入正常的更新流程
  didReceiveUpdate = false; // 还是设置为false
}

...
switch (workInProgress.tag) {
    case FunctionComponent: {
        return updateFunctionComponent() // 执行正常的函数组件更新
    }
}

最后来到下面的switch case结构,执行函数组件正常的更新流程。

下面查看updateFunctionComponent方法:

js 复制代码
// 更新函数组件
function updateFunctionComponent(
  current,
  workInProgress,
  Component,
  nextProps: any,
  renderLanes,
) {

  // 调用组件函数
  const nextChildren = renderWithHooks(
      current,
      workInProgress,
      Component,
      nextProps,
      context,
      renderLanes,
  );

  /**
   * 函数组件默认的更新优化策略:【极少:可以通过两次修改state,加一减一来测试】
   * 一般情况下都无法进入此处的Bailout策略,除非是多次修改一个state情况,
   * 同时这个state必须为原始数据类型,比如number或者布尔值,并且多次修改的最终结果是【无变化】。
   * 这样的情况didReceiveUpdate才能保持false的值,然后进入此处的Bailout优化策略。
   */
  if (current !== null && !didReceiveUpdate) {
    // 多次修改state,虽然最终结果无变化。但是effect相关的hook,依赖也是变化了。
    // 这里需要清除hook的副作用标记,表示不需要更新effect
    bailoutHooks(current, workInProgress, renderLanes);
    /**
     * 如果能够进入Bailout策略:代表函数组件自身Fiber无需要更新
     * 1,如果子节点也没有更新,则跳过整个组件的更新流程
     * 2,如果子节点有更新,则跳过自身节点的更新,复用child子节点的内容;
     */
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  }

  // 创建子节点
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}

updateFunctionComponent方法就是专门处理函数组件的加载或者更新逻辑,这个方法的重点就是调用了一个renderWithHooks方法,

js 复制代码
function renderWithHooks() {
	const children = Component() // 调用MyFun函数
}

renderWithHooks方法里面的内容很多,但我们主要关注这一行代码,即调用我们定义的组件函数:

在调用我们定义的组件函数时,主要会执行以下几个内容:

  • 遇到useState hook时,循环update链表,计算最新的state数据。
  • 遇到effect副作用类的hook时,如果依赖变化,则打上HookHasEffect更新标记。
  • 最后根据return返回的jsx内容,生成MyFun函数组件内所有节点对应的新的react元素对象。

注意:state计算完成后,会执行一个判断:

js 复制代码
function updateReducer(reducer, initialArg, init) {
	...
	// state循环计算完成后
	 if (!objectIs(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate();
    }
}

判断本次state修改后最终的结果有没有变化,如果发生了变化则会调用一个markWorkInProgressReceivedUpdate方法,更新全局变量didReceiveUpdatetrue,代表确实需要执行真正的更新。

js 复制代码
export function markWorkInProgressReceivedUpdate() {
  didReceiveUpdate = true;
}

这就是为啥之前变量didReceiveUpdate还是为false的原因,要确保真实的状态变化才需要执行真正的更新。

关于renderWithHooks方法具体的执行逻辑可以查看《React18.2x源码解析:函数组件的加载过程》

renderWithHooks方法执行完成后,返回值就是新的react元素对象。

注意:调用renderWithHooks会为该组件内所有子节点创建对应的新的react元素对象。

js 复制代码
// 加载函数组件
function updateFunctionComponent() {

  // 调用组件函数
  const nextChildren = renderWithHooks(
      current,
      workInProgress,
      Component,
      nextProps,
      context,
      renderLanes,
  );

  // Bailout策略校验
  if (current !== null && !didReceiveUpdate) {
    bailoutHooks(current, workInProgress, renderLanes);
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  }

  // 重新渲染:创建子节点
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}

react元素对象创建完成之后,就会根据此对象来创建函数组件内新的子节点。不过在执行之前,这里也有一个Bailout优化策略校验,这里判断条件的重点就是didReceiveUpdate变量,不过因为当前MyFun函数组件确实发生了真实的状态变化,已经将didReceiveUpdate更新为了true,所以这里就不满足Bailout策略,必须进入正常的更新流程。

到这里我们其实可以发现:函数组件类型的Fiber节点在执行beginWork工作时,前后总共会执行两次Bailout策略校验。这说明react内部是存在默认的优化策略的,类组件也有。

注意: 实际上想要满足函数组件内部默认的Bailout优化策略,条件是比较苛刻的。首先必须是针对同一个state进行多次修改,然后state必须是原始数据类型,比如常见的字符串,数字和布尔值,并且必须保证多次修改的最终结果是无变化,这样才能进入默认的Bailout策略。

js 复制代码
function handleClick() {
	setCount(state => state + 1)
	setCount(state => state - 1)
}

比如我们执行这样的修改操作,虽然修改了多次state,但是最终的计算结果缺没有变化,就可以进入函数组件内部的Bailout策略,同时在这里还会清除由于state依赖变化而被打上标记的副作用。

js 复制代码
// 清除副作用标记
bailoutHooks(current, workInProgress, renderLanes);

回到案例,当前MyFun组件发生了真实的状态变化,所以会调用reconcileChildren方法创建新的子节点。

js 复制代码
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;

注意: 满足Bailout策略时是通过cloneChildFibers来创建子节点;不满足此策略时,即正常的组件更新是通过reconcileChildren来创建子节点。虽然这两种方式最终都是调用的createWorkInProgress方法来创建子节点,但是reconcileChildrencloneChildFibers方法最大的不同就是第二个参数传递的值不同:

  • cloneChildFibers: 传递的是current旧节点的props对象。
  • reconcileChildren:传递的是由新生成的的react元素对象上的props
js 复制代码
// cloneChildFibers
const newChild = createWorkInProgress(currentChild, currentChild.pendingProps); // 复用旧props

// reconcileChildren
const newChild = createWorkInProgress(fiber, pendingProps); // react元素对象传递的新的props对象

注意:一个Fiber对应的新建的react元素对象,它的props绝对不会和原来props对象相等,即使是空对象。

yaml 复制代码
{} !== {}

学习到这里,我们就能知道MyFun函数组件重新渲染,为何它的所有子节点也都会重新渲染了,因为这些子节点的pendingProps属性都已经是新的props对象了,在子节点执行它的beginWork工作时:

js 复制代码
if (oldProps !== newProps || hasLegacyContextChanged() ) {
    // props发生变化,直接进入更新流程
    didReceiveUpdate = true; 
}

props校验结果不相等,就会直接进入它的更新流程了。

Child组件节点

最后直接来看Child组件的beginWork工作流程:

根据调试截图可以发现,虽然Child子组件新旧props数据内容并没有实际的变化,但是此时的props已经是一个新的对象了。

js 复制代码
const oldProps = {name: 'child'};
const newProps = {name: 'child'};
if (oldProps !== newProps) // true

所以下面的新旧props就不会相等了,判断结果为true,直接进入该组件正常的更新流程。

这就是为何MyFun组件更新,Child子组件在无实际props变化和自身状态变化的情况下也被动重新渲染了。

4,总结

1,在react中,一次更新流程的核心逻辑就是创建一颗新的Fiber树,每个Fiber节点的创建工作都分为beginWorkcompleteWork两个模块,在beginWork工作开始时,每个Fiber节点都会执行一次Bailout优化策略校验。

2,在react中,影响一个Fiber节点更新的有三种因素:

  • state
  • props
  • context

statecontext的变化会将renderLanes附加到该Fiber节点的lanes属性上。

3,Bailout策略会依次校验这三个因素变化:

  • props的变化:是直接判断新旧props对象是否全等。
  • statecontext的变化:是校验Fiber.lanes是否包含renderLanes

校验不通过,则该节点进入正常的更新流程。

校验通过,则会进入bailoutOnAlreadyFinishedWork方法,进一步判断满足的优化程度:

  • 高度优化:跳过整个子树的更新流程【beginWorkcompleteWork】。
  • 一般优化:通过节点自身的更新流程,也就是直接克隆生成下一层子节点。

4,一个Fiber节点在更新时,执行正常的更新逻辑和进入Bailout策略逻辑最大的不同在于创建子节点的方式:

  • Bailout策略:调用cloneChildFibers方法,克隆旧节点生成新的Fiber节点,重点是保留了原props
  • 正常更新逻辑:调用reconcileChildren方法,复用旧节点生成新的Fiber节点,重点是使用了新建的react元素对象的props

5,一个组件在执行正常的更新逻辑时,会重新生成组件内所有子节点的react元素对象,然后调用reconcileChildren方法,根据react元素对象创建对应的Fiber节点,即子组件节点的pendingProps属性存储的是新的props对象,所以子节点在执行它的beginWork时无法通过Bailout策略校验,只能被动的重新渲染了。

6,一个组件在执行正常的更新逻辑时,在计算出最新的state之后,在调用reconcileChildren方法之前,其实中间还有一次Bailout策略校验,也就是说组件类型的Fiber前后会执行两次Bailout策略校验,但是后面这一次也就是组件内部的这次Bailout策略校验条件比较苛刻,实际项目中难以满足。

7,下章我们会讲解React.memo方法,它的源码是包裹我们传入的普通函数组件,在react内部生成一个新的memo组件节点,它的优化原理就是在beginWork之后,组件更新之前,再执行一次Bailout策略校验。

结束语

以上就是react普通组件更新流程的全部内容了,觉得有用的可以点赞收藏!如果有问题或建议,欢迎留言讨论!

相关推荐
Boilermaker199234 分钟前
【Java EE】SpringIoC
前端·数据库·spring
中微子1 小时前
JavaScript 防抖与节流:从原理到实践的完整指南
前端·javascript
天天向上10241 小时前
Vue 配置打包后可编辑的变量
前端·javascript·vue.js
芬兰y1 小时前
VUE 带有搜索功能的穿梭框(简单demo)
前端·javascript·vue.js
好果不榨汁1 小时前
qiankun 路由选择不同模式如何书写不同的配置
前端·vue.js
小蜜蜂dry1 小时前
Fetch 笔记
前端·javascript
拾光拾趣录1 小时前
列表分页中的快速翻页竞态问题
前端·javascript
小old弟1 小时前
vue3,你看setup设计详解,也是个人才
前端
Lefan1 小时前
一文了解什么是Dart
前端·flutter·dart
Patrick_Wilson2 小时前
青苔漫染待客迟
前端·设计模式·架构