阅读 1919

深入源码剖析componentWillXXX为什么UNSAFE

v16.3.0开始如下三个生命周期钩子被标记为UNSAFE

  • componentWillMount

  • componentWillRecieveProps

  • componentWillUpdate

究其原因,有如下两点:

  • 这三个钩子经常被错误使用,并且现在出现了更好的替代方案(这里指新增的getDerivedStateFromPropsgetSnapshotBeforeUpdate)。

  • ReactLegacy模式迁移到Concurrent模式后,这些钩子的表现会和之前不一致。

本文会从React源码的角度剖析这两点。

同时,通过本文的学习你可以掌握React异步状态更新机制的原理。

被误用的钩子

我们先来探讨第一点,这里我们以componentWillRecieveProps举例。

我们经常在componentWillRecieveProps内处理props改变带来的影响。有些同学认为这个钩子会在每次props变化后触发。

真的是这样么?让我们看看源码。

这段代码出自updateClassInstance方法:

if (
  unresolvedOldProps !== unresolvedNewProps ||
  oldContext !== nextContext
) {
  callComponentWillReceiveProps(
    workInProgress,
    instance,
    newProps,
    nextContext,
  );
}
复制代码

你可以从这里看到这段源码

其中callComponentWillReceiveProps方法会调用componentWillRecieveProps

可以看到,是否调用的关键是比较unresolvedOldPropsunresolvedNewProps是否全等,以及context是否变化。

其中unresolvedOldProps为组件上次更新时的props,而unresolvedNewProps则来自ClassComponent调用this.render返回的JSX中的props参数。

可见他们的引用是不同的。所以他们全等比较false

基于此原因,每次父组件更新都会触发当前组件的componentWillRecieveProps

想想你是否也曾误用过?

模式迁移

让我们再看第二个原因:

ReactLegacy模式迁移到Concurrent模式后,这些钩子的表现会和之前不一致。

我们先了解下什么是模式?不同模式有什么区别?

从Legacy到Concurrent

React15升级为React16后,源码改动如此之大,说React被重构可能更贴切些。

正是由于变动如此之大,使得一些特性在新旧版本React中表现不一致,这里就包括上文谈到的三个生命周期钩子。

为了让开发者能平稳从旧版本迁移到新版本,React推出了三个模式:

  • legacy模式 -- 通过ReactDOM.render创建的应用会开启该模式。这是当前React使用的方式。这个模式可能不支持一些新功能。
  • blocking模式 -- 通过ReactDOM.createBlockingRoot创建的应用会开启该模式。开启部分concurrent模式特性,作为迁移到concurrent模式的第一步。
  • concurrent模式 -- 通过ReactDOM.createRoot创建的应用会开启该模式。面向未来的开发模式。

你可以从这里看到不同模式的特性支持情况

concurrent模式相较我们当前使用的legacy模式最主要的区别是将同步的更新机制重构为异步可中断的更新

接下来我们来探讨React如何实现异步更新,以及为什么异步更新情况下钩子的表现和同步更新不同。

同步更新

我们可以用代码版本控制类比更新机制

在没有代码版本控制前,我们在代码中逐步叠加功能。一切看起来井然有序,直到我们遇到了一个紧急线上bug(红色节点)。

为了修复这个bug,我们需要首先将之前的代码提交。

React中,所有通过ReactDOM.render创建的应用都是通过类似的方式更新状态。

即所有更新同步执行,没有优先级概念,新来的高优更新(红色节点)也需要排在其他更新后面执行。

异步更新

当有了代码版本控制,有紧急线上bug需要修复时,我们暂存当前分支的修改,在master分支修复bug并紧急上线。

bug修复上线后通过git rebase命令和开发分支连接上。开发分支基于修复bug的版本继续开发。

React中,通过ReactDOM.createBlockingRootReactDOM.createRoot创建的应用在任务未过期情况下会采用异步的方式更新状态。

高优更新(红色节点)中断正在进行中的低优更新(蓝色节点),先完成渲染流程。

高优更新完成后,低优更新基于高优更新部分或者完整结果重新更新。

深入源码

React源码中,每次发起更新都会创建一个Update对象,同一组件的多个Update(如上图所示的A -> B -> C)会以链表的形式保存在updateQueue中。

首先了解下他们的数据结构

Update有很多字段,当前我们关注如下三个字段:

const update: Update<*> = {
  // ...省略当前不需要关注的字段
  lane,
  payload: null,
  next: null
};
复制代码

UpdatecreateUpdate方法返回,你可以从这里看到createUpdate的源码

  • lane:代表优先级。即图中红色节点与蓝色节点的区别。
  • payload:更新挂载的数据。对于this.setState创建的更新payloadthis.setState的传参。
  • next:与其他Update连接形成链表。

updateQueue结构如下:

const queue: UpdateQueue<State> = {
    baseState: fiber.memoizedState,
    firstBaseUpdate: null,
    lastBaseUpdate: null,
    shared: {
      pending: null,
    },
    // 其他参数省略...
};
复制代码

UpdateQueueinitializeUpdateQueue方法返回,你可以从这里看到initializeUpdateQueue的源码

  • baseState更新基于哪个state开始。上图中版本控制的例子中,高优bug修复后提交master,其他commit基于master分支继续开发。这里的master分支就是baseState
  • firstBaseUpdatelastBaseUpdate更新基于哪个Update开始,由firstBaseUpdate开始到lastBaseUpdate结束形成链表。这些Update是在上次更新中由于优先级不够被留下的,如图中A B C
  • shared.pending:本次更新的单或多个Update形成的链表。

其中baseUpdate + shared.pending会作为本次更新需要执行的Update

例子

了解了数据结构,接下来我们模拟一次异步中断更新,来揭示本文探寻的秘密 —— componentWillXXX为什么UNSAFE

在某个组件updateQueue中存在四个Update,其中字母代表该Update要更新的字母,数字代表该Update的优先级,数字越小优先级越高。

baseState = '';

A1 - B2 - C1 - D2
复制代码

首次渲染时,优先级1。B D优先级不够被跳过。

为了保证更新的连贯性,第一个被跳过的UpdateB)及其后面所有Update会作为第二次渲染的baseUpdate,无论他们的优先级高低,这里为B C D

baseState: ''
Updates: [A1, C1]
Result state: 'AC'
复制代码

接着第二次渲染,优先级2。

由于B在第一次渲染时被跳过,所以在他之后的C造成的渲染结果不会体现在第二次渲染的baseState中。所以baseStateA而不是上次渲染的Result state AC。这也是为了保证更新的连贯性。

baseState: 'A'          
Updates: [B2, C1, D2]  
Result state: 'ABCD'
复制代码

我们发现,C同时出现在两次渲染的Updates中,他代表的状态会被更新两次。

如果有类似的代码:

componentWillReceiveProps(nextProps) {
    if (!this.props.includes('C') && nextProps.includes('C')) {
        // ...do something
    }
}
复制代码

则很有可能被调用两次,这与同步更新React表现不一致!

基于以上原因,componentWillXXX被标记为UNSAFE

总结

由于篇幅有限,本次我们只聚焦了React源码的冰山一角。

如果想深入学习React源码,在此向你推荐开源严谨易懂的React源码电子书 —— React技术揭秘