探案揭秘六种React‘灵异’现象背后的原因

发表于 2年以前  | 总阅读数:504 次

前言

今天我们来一期不同寻常的React进阶文章,本文我们通过一些不同寻常的现象,以探案的流程分析原因,找到结果,从而认识React,走进React的世界,揭开React的面纱,我深信,更深的理解,方可更好的使用。

我承认起这个名字可能有点标题党了,灵感来源于小时候央视有一个叫做《走进科学》的栏目,天天介绍各种超自然的灵异现象,搞的神乎其神,最后揭秘的时候原来是各种小儿科的问题,现在想想都觉得搞笑。但是我今天介绍的这些React '灵异'现象本质可不是小儿科,每一个现象后都透露出 React 运行机制设计原理。(我们讲的react版本是16.13.1

src=http___n.sinaimg.cn_sinacn_w640h360_20180113_9984-fyqrewh6822097.jpg&refer=http___n.sinaimg.jpg

好的,废话不多说,我的大侦探们,are you ready ? 让我们开启今天的揭秘之旅把。

案件一:组件莫名其妙重复挂载

接到报案

之前的一位同学遇到一个诡异情况,他希望在组件更新,componentDidUpdate执行后做一些想要做的事,组件更新源来源于父组件传递 props 的改变。但是父组件改变 props发现视图渲染,但是componentDidUpdate没有执行,更怪异的是componentDidMount执行。代码如下:

// TODO: 重复挂载
class Index extends React.Component{
   componentDidMount(){
     console.log('组件初始化挂载')
   }
   componentDidUpdate(){
     console.log('组件更新')
     /* 想要做一些事情 */
   }
   render(){
      return <div>《React进阶实践指南》   { this.props.number } +   </div>
   }
}

效果如下

didupdate.gif

componentDidUpdate没有执行,componentDidMount执行,说明组件根本没有走更新逻辑,而是走了重复挂载

逐一排查

子组件一头雾水,根本不找原因,我们只好从父组件入手。让我们看一下父组件如何写的。

const BoxStyle = ({ children })=><div className='card' >{ children }</div>

export default function Home(){
   const [ number , setNumber ] = useState(0)
   const NewIndex = () => <BoxStyle><Index number={number}  /></BoxStyle>
   return <div>
      <NewIndex  />
      <button onClick={ ()=>setNumber(number+1) } >点赞</button>
   </div>
}

从父组件中找到了一些端倪。在父组件中,首先通过BoxStyle做为一个容器组件,添加样式,渲染我们的子组件Index,但是每一次通过组合容器组件形成一个新的组件NewIndex,真正挂载的是NewIndex,真相大白。

注意事项

造成这种情况的本质,是每一次 render 过程中,都形成一个新组件,对于新组件,React 处理逻辑是直接卸载老组件,重新挂载新组件,所以我们开发的过程中,注意一个问题那就是:

  • 对于函数组件,不要在其函数执行上下文中声明新组件并渲染,这样每次函数更新会促使组件重复挂载。
  • 对于类组件,不要在 render 函数中,做如上同样的操作,否则也会使子组件重复挂载。

案件二:事件源e.target离奇失踪

突发案件

化名(小明)在一个月黑风高的夜晚,突发奇想写一个受控组件。写的什么内容具体如下:

export default class EventDemo extends React.Component{
  constructor(props){
    super(props)
    this.state={
        value:''
    }
  }
  handerChange(e){
    setTimeout(()=>{
       this.setState({
         value:e.target.value
       })
    },0)
  }
  render(){
    return <div>
      <input placeholder="请输入用户名?" onChange={ this.handerChange.bind(this) }  />
    </div>
  }
}

input的值受到 statevalue属性控制,小明想要通过handerChange改变value值,但是他期望在setTimeout中完成更新。可以当他想要改变input值时候,意想不到的事情发生了。

event.jpg

控制台报错如上所示。Cannot read property 'value' of null 也就是说明e.targetnull。事件源 target怎么说没就没呢?

线索追踪

接到这个案件之后,我们首先排查问题,那么我们先在handerChange直接打印e.target,如下:

event1.jpg看来首先排查不是 handerChange 的原因,然后我们接着在setTimeout中打印发现:

event2.jpg

果然是setTimeout的原因,为什么setTimeout中的事件源 e.target 就莫名的失踪了呢? 首先,事件源肯定不是莫名的失踪了,肯定 React 底层对事件源做了一些额外的处理,首先我们知道React采用的是事件合成机制,也就是绑定的 onChange不是真实绑定的 change事件,小明绑定的 handerChange也不是真正的事件处理函数。那么也就是说React底层帮我们处理了事件源。这一切可能只有我们从 React 源码中找到线索。经过对源码的排查,我发现有一处线索十分可疑。

react-dom/src/events/DOMLegacyEventPluginSystem.js

function dispatchEventForLegacyPluginEventSystem(topLevelType,eventSystemFlags,nativeEvent,targetInst){
    const bookKeeping = getTopLevelCallbackBookKeeping(topLevelType,nativeEvent,targetInst,eventSystemFlags);
    batchedEventUpdates(handleTopLevel, bookKeeping);
}

dispatchEventForLegacyPluginEventSystemlegacy模式下,所有事件都必定经过的主要函数,batchedEventUpdates是处理批量更新的逻辑,里面会执行我们真正的事件处理函数,我们在事件原理篇章讲过 nativeEvent 就是真正原生的事件对象 eventtargetInst 就是e.target对应的fiber对象。我们在handerChange里面获取的事件源是React合成的事件源,那么了解事件源是什么时候,怎么样被合成的? 这对于破案可能会有帮助。

事件原理篇我们将介绍React采用事件插件机制,比如我们的onClick事件对应的是 SimpleEventPlugin,那么小明写onChange也有专门 ChangeEventPlugin事件插件,这些插件有一个至关重要的作用就是用来合成我们事件源对象e,所以我们来看一下ChangeEventPlugin

react-dom/src/events/ChangeEventPlugin.js

const ChangeEventPlugin ={
   eventTypes: eventTypes,
   extractEvents:function(){
        const event = SyntheticEvent.getPooled(
            eventTypes.change,
            inst, // 组件实例
            nativeEvent, // 原生的事件源 e
            target,      // 原生的e.target
     );
     accumulateTwoPhaseListeners(event); // 这个函数按照冒泡捕获逻辑处理真正的事件函数,也就是  handerChange 事件
     return event; // 
   }   
}

我们看到合成事件的事件源handerChange中的 e,就是SyntheticEvent.getPooled创建出来的。那么这个是破案的关键所在。

legacy-events/SyntheticEvent.js

SyntheticEvent.getPooled = function(){
    const EventConstructor = this; //  SyntheticEvent
    if (EventConstructor.eventPool.length) {
    const instance = EventConstructor.eventPool.pop();
    EventConstructor.call(instance,dispatchConfig,targetInst,nativeEvent,nativeInst,);
    return instance;
  }
  return new EventConstructor(dispatchConfig,targetInst,nativeEvent,nativeInst,);
}

番外:在事件系统篇章,文章的事件池感念,讲的比较仓促,笼统,这篇这个部分将详细补充事件池感念。

getPooled引出了事件池的真正的概念,它主要做了两件事:

  • 判断事件池中有没有空余的事件源,如果有取出事件源复用。
  • 如果没有,通过 new SyntheticEvent 的方式创建一个新的事件源对象。那么 SyntheticEvent就是创建事件源对象的构造函数,我们一起研究一下。
const EventInterface = {
  type: null,
  target: null,
  currentTarget: function() {
    return null;
  },
  eventPhase: null,
  ...
};
function SyntheticEvent( dispatchConfig,targetInst,nativeEvent,nativeEventTarget){
  this.dispatchConfig = dispatchConfig; 
  this._targetInst = targetInst;    // 组件对应fiber。
  this.nativeEvent = nativeEvent;   // 原生事件源。
  this._dispatchListeners = null;   // 存放所有的事件监听器函数。
  for (const propName in Interface) {
      if (propName === 'target') {
        this.target = nativeEventTarget; // 我们真正打印的 target 是在这里
      } else {
        this[propName] = nativeEvent[propName];
      }
  }
}
SyntheticEvent.prototype.preventDefault = function (){ /* .... */ }     /* 组件浏览器默认行为 */
SyntheticEvent.prototype.stopPropagation = function () { /* .... */  }  /* 阻止事件冒泡 */

SyntheticEvent.prototype.destructor = function (){ /* 情况事件源对象*/
      for (const propName in Interface) {
           this[propName] = null
      }
    this.dispatchConfig = null;
    this._targetInst = null;
    this.nativeEvent = null;
}
const EVENT_POOL_SIZE = 10; /* 最大事件池数量 */
SyntheticEvent.eventPool = [] /* 绑定事件池 */
SyntheticEvent.release=function (){ /* 清空事件源对象,如果没有超过事件池上限,那么放回事件池 */
    const EventConstructor = this; 
    event.destructor();
    if (EventConstructor.eventPool.length < EVENT_POOL_SIZE) {
       EventConstructor.eventPool.push(event);
    }
}

我把这一段代码精炼之后,真相也就渐渐浮出水面了,我们先来看看 SyntheticEvent 做了什么:

  • 首先赋予一些初始化的变量nativeEvent等。然后按照 EventInterface 规则把原生的事件源上的属性,复制一份给React 事件源。然后一个重要的就是我们打印的e.target就是this.target,在事件源初始化的时候绑定了真正的e.target->nativeEventTarget
  • 然后React事件源,绑定了自己的阻止默认行为preventDefault,阻止冒泡stopPropagation等方法。但是这里有一个重点方法就destructor,这个函数置空了React自己的事件源对象。那么我们终于找到了答案,我们的事件源e.target消失大概率就是因为这个destructordestructorrelease中被触发,然后将事件源放进事件池,等待下一次复用。

现在所有的矛头都指向了release,那么release是什么时候触发的呢?

legacy-events/SyntheticEvent.js

function executeDispatchesAndRelease(){
    event.constructor.release(event);
}

当 React 事件系统执行完所有的 _dispatchListeners,就会触发这个方法 executeDispatchesAndRelease释放当前的事件源。

真相大白

回到小明遇到的这个问题,我们上面讲到,React最后会同步的置空事件源,然后放入事件池,因为setTimeout是异步执行,执行时候事件源对象已经被重置并释放会事件池,所以我们打印 e.target = null,到此为止,案件真相大白。

通过这个案件我们明白了 React 事件池的一些概念:

  • React 事件系统有独特合成事件,也有自己的事件源,而且还有对一些特殊情况的处理逻辑,比如冒泡逻辑等。
  • React 为了防止每次事件都创建事件源对象,浪费性能,所以引入了事件池概念,每一次用户事件都会从事件池中取出一个e,如果没有,就创建一个,然后赋值事件源,等到事件执行之后,重置事件源,放回事件池,借此做到复用。

用一幅流程图表示:

eventloop.jpg

案件三:真假React

案发现场

这个是发生在笔者身上的事儿,之前在开发 React 项目时候,为了逻辑复用,我把一些封装好的自定义 Hooks 上传到公司私有的 package 管理平台上,在开发另外一个 React 项目的时候,把公司的包下载下来,在组件内部用起来。代码如下:

function Index({classes, onSubmit, isUpgrade}) {
   /* useFormQueryChange 是笔者写好的自定义hooks,并上传到私有库,主要是用于对表单控件的统一管理  */
  const {setFormItem, reset, formData} = useFormQueryChange()
  React.useEffect(() => {
    if (isUpgrade)  reset()
  }, [ isUpgrade ])
  return <form
    className={classes.bootstrapRoot}
    autoComplete='off'
  >
    <div className='btnbox' >
       { /* 这里是业务逻辑,已经省略 */ }
    </div>
  </form>
}

useFormQueryChange 是笔者写好的自定义 hooks ,并上传到私有库,主要是用于对表单控件的统一管理,没想到引入就直接爆红了。错误内容如下:

hooks.jpg

逐一排查

我们按照 React 报错的内容,逐一排查问题所在:

  • 第一个可能报错原因 You might have mismatching versions of React and the renderer (such as React DOM),意思是 ReactReact Dom版本不一致,造成这种情况,但是我们项目中的 ReactReact Dom 都是 v16.13.1,所以排除这个的嫌疑。
  • 第二个可能报错原因 You might be breaking the Rules of Hooks 意思是你打破了Hooks 规则,这种情况也是不可能的,因为笔者代码里没有破坏hoos规则的行为。所以也排除嫌疑。
  • 第三个可能报错原因You might have more than one copy of React in the same app 意思是在同一个应用里面,可能有多个 React。目前来看所有的嫌疑都指向第三个,首先我们引用的自定义 hooks,会不会内部又存在一个React 呢?

按照上面的提示我排查到自定义hooks对应的node_modules中果然存在另外一个React,是这个假React(我们姑且称之为假React)搞的鬼。我们在Hooks原理 文章中讲过,React HooksReactCurrentDispatcher.current 在组件初始化,组件更新阶段赋予不同的hooks对象,更新完毕后赋予ContextOnlyDispatcher,如果调用这个对象下面的hooks,就会报如上错误,那么说明了这个错误是因为我们这个项目,执行上下文引入的React是项目本身的React,但是自定义Hooks引用的是假React Hooks中的ContextOnlyDispatcher

接下来我看到组件库中的package.json中,

"dependencies": {
  "react": "^16.13.1",
  "react-dom": "^16.13.1"
},

原来是React作为 dependencies所以在下载自定义Hooks的时候,把React又下载了一遍。那么如何解决这个问题呢。对于封装React组件库,hooks库,不能用 dependencies,因为它会以当前的dependencies为依赖下载到自定义hooks库下面的node_modules中。取而代之的应该用peerDependencies,使用peerDependencies,自定义hooks再找相关依赖就会去我们的项目的node_modules中找,就能根本上解决这个问题。 所以我们这么改

"peerDependencies": {
    "react": ">=16.8",
    "react-dom": ">=16.8",
},

就完美的解决了这个问题。

拨开迷雾

这个问题让我们明白了如下:

  • 对于一些hooks库,组件库,本身的依赖,已经在项目中存在了,所以用peerDependencies声明。
  • 在开发的过程中,很可能用到不同版本的同一依赖,比如说项目引入了 A 版本的依赖,组件库引入了 B 版本的依赖。那么这种情况如何处理呢。在 package.json 文档中提供了一个resolutions配置项可以解决这个问题,在 resolutions 中锁定同一的引入版本,这样就不会造成如上存在多个版本的项目依赖而引发的问题。

项目package.json这么写

{
  "resolutions": {
    "react": "16.13.1",
    "react-dom": "16.13.1"
  },
}

这样无论项目中的依赖,还是其他库中依赖,都会使用统一的版本,从根本上解决了多个版本的问题。

案件四:PureComponet/memo功能失效问题

案情描述

在 React 开发的时候,但我们想要用 PureComponent 做性能优化,调节组件渲染,但是写了一段代码之后,发现 PureComponent 功能竟然失效了,具体代码如下:

class Index extends React.PureComponent{
   render(){
     console.log('组件渲染')
     const { name , type } = this.props
     return <div>
       hello , my name is { name }
       let us learn { type }
     </div>
   }
}

export default function Home (){
   const [ number , setNumber  ] = React.useState(0)
   const [ type , setType ] = React.useState('react')
   const changeName = (name) => {
       setType(name)
   }
   return <div>
       <span>{ number }</span><br/>
       <button onClick={ ()=> setNumber(number + 1) } >change number</button>
       <Index type={type}  changeType={ changeName } name="alien"  />
   </div>
}

我们本来期望:

  • 对于 Index 组件,只有propsnametype改变,才促使组件渲染。但是实际情况却是这样:

点击按钮效果:

purecomponent.gif

水落石出

为什么会出现这种情况呢? 我们再排查一下Index组件,发现 Index 组件上有一个 changeType,那么是不是这个的原因呢? 我们来分析一下,首先状态更新是在父组件 Home上,Home组件更新每次会产生一个新的changeName,所以IndexPureComponent每次会浅比较,发现props中的changeName每次都不相等,所以就更新了,给我们直观的感觉是失效了。

那么如何解决这个问题,React hooks 中提供了 useCallback,可以对props传入的回调函数进行缓存,我们来改一下Home代码。

const changeName = React.useCallback((name) => {
    setType(name)
},[])

效果:

pureComponent1.gif

这样就根本解决了问题,用 useCallbackchangeName函数进行缓存,在每一次 Home 组件执行,只要useCallbackdeps没有变,changeName内存空间还指向原来的函数,这样PureComponent浅比较就会发现是相同changeName,从而不渲染组件,至此案件已破。

继续深入

大家用函数组件+类组件开发的时候,如果用到React.memo React.PureComponent等api,要注意给这些组件绑定事件的方式,如果是函数组件,那么想要持续保持纯组件的渲染控制的特性的话,那么请用 useCallback,useMemo等api处理,如果是类组件,请不要用箭头函数绑定事件,箭头函数同样会造成失效的情况。

上述中提到了一个浅比较shallowEqual,接下来我们重点分析一下 PureComponent是如何shallowEqual,接下来我们在深入研究一下shallowEqual的奥秘。那么就有从类租价的更新开始。

react-reconciler/src/ReactFiberClassComponent.js

function updateClassInstance(){
    const shouldUpdate =
    checkHasForceUpdateAfterProcessing() ||
    checkShouldComponentUpdate(
      workInProgress,
      ctor,
      oldProps,
      newProps,
      oldState,
      newState,
      nextContext,
    );
    return shouldUpdate
}

我这里简化updateClassInstance,只保留了涉及到PureComponent的部分。updateClassInstance这个函数主要是用来,执行生命周期,更新state,判断组件是否重新渲染,返回的 shouldUpdate用来决定当前类组件是否渲染。checkHasForceUpdateAfterProcessing检查更新来源是否来源与 forceUpdate , 如果是forceUpdate组件是一定会更新的,checkShouldComponentUpdate检查组件是否渲染。我们接下来看一下这个函数的逻辑。

function checkShouldComponentUpdate(){
    /* 这里会执行类组件的生命周期 shouldComponentUpdate */
    const shouldUpdate = instance.shouldComponentUpdate(
      newProps,
      newState,
      nextContext,
    );
    /* 这里判断组件是否是 PureComponent 纯组件,如果是纯组件那么会调用 shallowEqual 浅比较  */
    if (ctor.prototype && ctor.prototype.isPureReactComponent) {
        return (
        !shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState)
        );
    }
}

checkShouldComponentUpdate有两个至关重要的作用:

  • 第一个就是如果类组件有生命周期shouldComponentUpdate,会执行生命周期shouldComponentUpdate,判断组件是否渲染。
  • 如果发现是纯组件PureComponent,会浅比较新老propsstate是否相等,如果相等,则不更新组件。isPureReactComponent就是我们使用PureComponent的标识,证明是纯组件。

接下来就是重点shallowEqual,以props为例子,我们看一下。

shared/shallowEqual

function shallowEqual(objA: mixed, objB: mixed): boolean {
  if (is(objA, objB)) { // is可以 理解成  objA === objB 那么返回相等
    return true;
  }

  if (
    typeof objA !== 'object' ||
    objA === null ||
    typeof objB !== 'object' ||
    objB === null
  ) {
    return false;  
  } // 如果新老props有一个不为对象,或者不存在,那么直接返回false

  const keysA = Object.keys(objA); // 老props / 老state key组成的数组
  const keysB = Object.keys(objB); // 新props / 新state key组成的数组

  if (keysA.length !== keysB.length) { // 说明props增加或者减少,那么直接返回不想等
    return false;
  }

  for (let i = 0; i < keysA.length; i++) { // 遍历老的props ,发现新的props没有,或者新老props不同等,那么返回不更新组件。
    if (
      !hasOwnProperty.call(objB, keysA[i]) ||
      !is(objA[keysA[i]], objB[keysA[i]])
    ) {
      return false;
    }
  }

  return true; //默认返回相等
}

shallowEqual流程是这样的,shallowEqual 返回 true 则证明相等,那么不更新组件;如果返回false 证明不想等,那么更新组件。is 我们暂且可以理解成 ===

  • 第一步,直接通过 === 判断是否相等,如果相等,那么返回true。正常情况只要调用 React.createElement 会重新创建propsprops都是不相等的。
  • 第二步,如果新老props有一个不为对象,或者不存在,那么直接返回false
  • 第三步,判断新老propskey组成的数组数量等不想等,说明props有增加或者减少,那么直接返回false
  • 第四步,遍历老的props ,发现新的props没有与之对应,或者新老props不同等,那么返回false
  • 默认返回true

这就是shallowEqual逻辑,代码还是非常简单的。感兴趣的同学可以看一看。

案件五: useState更新相同的State,函数组件执行2次

接到报案

这个问题实际很悬,大家可能平时没有注意到,引起我的注意的是掘金的一个掘友问我的一个问题,问题如下:

首先非常感谢这位细心的掘友的报案,我在 React-hooks 原理 中讲到过,对于更新组件的方法函数组件 useState 和类组件的setState有一定区别,useState源码中如果遇到两次相同的state,会默认阻止组件再更新,但是类组件中setState如果没有设置 PureComponent,两次相同的state 也会更新。

我们回顾一下 hooks 中是怎么样阻止组件更新的。

react-reconciler/src/ReactFiberHooks.js -> dispatchAction

if (is(eagerState, currentState)) { 
     return
}
scheduleUpdateOnFiber(fiber, expirationTime); // 调度更新

如果判断上一次的state -> currentState ,和这一次的state -> eagerState 相等,那么将直接 return阻止组件进行scheduleUpdate调度更新。所以我们想如果两次 useState触发同样的state,那么组件只能更新一次才对,但是事实真的是这样吗?。

立案调查

顺着这位掘友提供的线索,我们开始写 demo进行验证。

const Index = () => {
  const [ number , setNumber  ] = useState(0)
  console.log('组件渲染',number)
  return <div className="page" >
    <div className="content" >
       <span>{ number }</span><br/>
       <button onClick={ () => setNumber(1) } >将number设置成1</button><br/>
       <button onClick={ () => setNumber(2) } >将number设置成2</button><br/>
       <button onClick={ () => setNumber(3) } >将number设置成3</button>
    </div>
  </div>
}
export default class Home extends React.Component{
  render(){
    return <Index />
  }
}

如上demo,三个按钮,我们期望连续点击每一个按钮,组件都会仅此渲染一次,于是我们开始实验:

效果:

demo1.gif

果然,我们通过 setNumber 改变 number,每次连续点击按钮,组件都会更新2次,按照我们正常的理解,每次赋予 number 相同的值,只会渲染一次才对,但是为什么执行了2次呢?

可能刚开始会陷入困境,不知道怎么破案,但是我们在想 hooks原理中讲过,每一个函数组件用对应的函数组件的 fiber 对象去保存 hooks 信息。所以我们只能从 fiber找到线索。

顺藤摸瓜

那么如何找到函数组件对应的fiber对象呢,这就顺着函数组件的父级 Home 入手了,因为我们可以从类组件Home中找到对应的fiber对象,然后根据 child 指针找到函数组件 Index对应的 fiber。说干就干,我们将上述代码改造成如下的样子:

const Index = ({ consoleFiber }) => {
  const [ number , setNumber  ] = useState(0)
  useEffect(()=>{  
      console.log(number)
      consoleFiber() // 每次fiber更新后,打印 fiber 检测 fiber变化
  })
  return <div className="page" >
    <div className="content" >
       <span>{ number }</span><br/>
       <button onClick={ () => setNumber(1) } >将number设置成1</button><br/>
    </div>
  </div>
}
export default class Home extends React.Component{
  consoleChildrenFiber(){
     console.log(this._reactInternalFiber.child) /* 用来打印函数组件 Index 对应的fiber */
  }
  render(){
    return <Index consoleFiber={ this.consoleChildrenFiber.bind(this) }  />
  }
}

我们重点关心fiber上这几个属性,这对破案很有帮助

  • Index fiber上的 memoizedState 属性,react hooks 原理文章中讲过,函数组件用 memoizedState 保存所有的 hooks 信息。
  • Index fiber上的 alternate 属性
  • Index fiber上的 alternate 属性上的 memoizedState属性。是不是很绕,马上会揭晓是什么。
  • Index组件上的 useState中的number

首先我们讲一下 alternate 指针指的是什么?

说到alternate 就要从fiber架构设计说起,每个React元素节点,用两颗fiber树保存状态,一颗树保存当前状态,一个树保存上一次的状态,两棵 fiber 树用 alternate 相互指向。就是我们耳熟能详的双缓冲

初始化打印

效果图:

fiber1.jpg

初始化完成第一次render后,我们看一下fiber树上的这几个状态

第一次打印结果如下,

  • fiber上的 memoizedStatebaseState = 0 即是初始化 useState 的值。
  • fiber上的 alternatenull
  • Index组件上的 number 为 0。

初始化流程:首先对于组件第一次初始化,会调和渲染形成一个fiber树(我们简称为树A)。树A的alternate属性为 null

第一次点击 setNumber(1)

我们第一次点击发现组件渲染了,然后我们打印结果如下:

fiber2.jpg

  • 树A上的 memoizedState 中 **baseState = 0
  • 树A上的 alternate 指向 另外一个fiber(我们这里称之为树B)。
  • Index组件上的 number 为 1。

接下来我们打印树B上的 memoizedState

fiber3.jpg

结果我们发现树B上 memoizedState上的 baseState = 1

得出结论:更新的状态都在树B上,而树A上的 baseState还是之前的0。

我们大胆猜测一下更新流程:在第一次更新渲染的时候,由于树A中,不存在alternate,所以直接复制一份树A作为 workInProgress(我们这里称之为树B)所有的更新都在当前树B中进行,所以 baseState 会被更新成 1,然后用当前的树B进行渲染。结束后树A和树B通过alternate相互指向。树B作为下一次操作的current树。

第二次点击 setNumber(1)

第二次打印,组件同样渲染了,然后我们打印fiber对象,效果如下:

fiber4.jpg

  • fiber对象上的 memoizedStatebaseState更新成了 1。

然后我们打印一下 alternatebaseState也更新成了 1。

fiber5.jpg

第二次点击之后 ,树A和树B都更新到最新的 baseState = 1

首先我们分析一下流程:当我们第二次点击时候,是通过上一次树A中的 baseState = 0setNumber(1) 传入的 1做的比较。所以发现 eagerState !== currentState ,组件又更新了一次。接下来会以current树(树B)的 alternate指向的树A作为新的workInProgress进行更新,此时的树A上的 baseState 终于更新成了 1 ,这就解释了为什么上述两个 baseState 都等于 1。接下来组件渲染完成。树A作为了新的 current 树。

在我们第二次打印,打印出来的实际是交替后树B,树A和树B就这样交替着作为最新状态用于渲染的workInProgress树和缓存上一次状态用于下一次渲染的current树。

第三次点击(三者言其多也)

那么第三次点击组件没有渲染,就很好解释了,第三次点击上一次树B中的 baseState = 1setNumber(1)相等,也就直接走了return逻辑。

揭开谜底(我们学到了什么)

  • 双缓冲树:React 用 workInProgress树(内存中构建的树) 和 current(渲染树) 来实现更新逻辑。我们console.log打印的fiber都是在内存中即将 workInProgress的fiber树。双缓存一个在内存中构建,在下一次渲染的时候,直接用缓存树做为下一次渲染树,上一次的渲染树又作为缓存树,这样可以防止只用一颗树更新状态的丢失的情况,又加快了dom节点的替换与更新。
  • 更新机制:在一次更新中,首先会获取current树的 alternate作为当前的 workInProgress,渲染完毕后,workInProgress 树变为 current 树。我们用如上的树A和树B和已经保存的baseState模型,来更形象的解释了更新机制 。 hooks中的useState进行state对比,用的是缓存树上的state和当前最新的state。所有就解释了为什么更新相同的state,函数组件执行2次了。

我们用一幅流程图来描述整个流程。

FFB125E7-6A34-4F44-BB6E-A11D598D0A01.jpg

此案已破,通过这个容易忽略的案件,我们学习了双缓冲和更新机制。

案件六:useEffect修改DOM元素导致怪异闪现

鬼使神差

小明(化名)在动态挂载组件的时候,遇到了灵异的Dom闪现现象,让我们先来看一下现象。

闪现现象:

effect.gif

代码:

function Index({ offset }){
    const card  = React.useRef(null)
    React.useEffect(()=>{
       card.current.style.left = offset
    },[])
    return <div className='box' >
        <div className='card custom' ref={card}   >《 React进阶实践指南 》</div>
    </div>
}

export default function Home({ offset = '300px' }){
   const [ isRender , setRender ] = React.useState(false)
   return <div>
       { isRender && <Index offset={offset}  /> }
       <button onClick={ ()=>setRender(true) } > 挂载</button>
   </div>
}
  • 在父组件用 isRender 动态加载 Index,点击按钮控制 Index渲染。
  • Index的接受动态的偏移量offset。并通过操纵用useRef获取的原生dom直接改变偏移量,使得划块滑动。但是出现了如上图的闪现现象,很不友好,那么为什么会造成这个问题呢?

深入了解

初步判断产生这个闪现的问题应该是 useEffect造成的,为什么这么说呢,因为类组件生命周期 componentDidMount写同样的逻辑,然而并不会出现这种现象。那么为什么useEffect会造成这种情况,我们只能顺藤摸瓜找到 useEffectcallback执行时机说起。

useEffect``,useLayoutEffect , componentDidMount执行时机都是在 commit阶段执行。我们知道 React 有一个 effectList存放不同effect。因为 React 对不同的 effect 执行逻辑和时机不同。我们看一下useEffect被定义的时候,定义成了什么样类型的 effect

react-reconciler/src/ReactFiberHooks.js

function mountEffect(create, deps){
  return mountEffectImpl(
    UpdateEffect | PassiveEffect, // PassiveEffect 
    HookPassive,
    create,
    deps,
  );
}

这个函数的信息如下:

  • useEffect 被赋予 PassiveEffect类型的 effect
  • 小明改原生dom位置的函数,就是 create

那么 create函数什么时候执行的,React又是怎么处理PassiveEffect的呢,这是破案的关键。记下来我们看一 下React 怎么处理PassiveEffect

react-reconciler/src/ReactFiberCommitWork.js

function commitBeforeMutationEffects() {
  while (nextEffect !== null) {
    if ((effectTag & Passive) !== NoEffect) {
      if (!rootDoesHavePassiveEffects) {
        rootDoesHavePassiveEffects = true;
        /*  异步调度 - PassiveEffect */
        scheduleCallback(NormalPriority, () => {
          flushPassiveEffects();
          return null;
        });
      }
    }
    nextEffect = nextEffect.nextEffect;
  }
}

commitBeforeMutationEffects 函数中,会异步调度 flushPassiveEffects方法,flushPassiveEffects方法中,对于React hooks 会执行 commitPassiveHookEffects,然后会执行 commitHookEffectListMount

function commitHookEffectListMount(){
     if (lastEffect !== null) {
          effect.destroy = create(); /* 执行useEffect中饿 */
     }
}

commitHookEffectListMount中,create函数会被调用。我们给dom元素加的位置就会生效。

那么问题来了,异步调度做了些什么呢? React的异步调度,为了防止一些任务执行耽误了浏览器绘制,而造成卡帧现象,react 对于一些优先级不高的任务,采用异步调度来处理,也就是让浏览器才空闲的时间来执行这些异步任务,异步任务执行在不同平台,不同浏览器上实现方式不同,这里先姑且认为效果和setTimeout一样。

雨过天晴

通过上述我们发现 useEffect 的第一个参数 create,采用的异步调用的方式,那么闪现就很好理解了,在点击按钮组件第一次渲染过程中,首先执行函数组件render,然后commit替换真实dom节点,然后浏览器绘制完毕。此时浏览器已经绘制了一次,然后浏览器有空余时间执行异步任务,所以执行了create,修改了元素的位置信息,因为上一次元素已经绘制,此时又修改了一个位置,所以感到闪现的效果,此案已破。

那么我们怎么样解决闪现的现象呢,那就是 React.useLayoutEffectuseLayoutEffectcreate是同步执行的,所以浏览器绘制一次,直接更新了最新的位置。

  React.useLayoutEffect(()=>{
      card.current.style.left = offset
  },[])

总结

本节可我们学到了什么?

本文以破案的角度,从原理角度讲解了 React 一些意想不到的现象,透过这些现象,我们学习了一些 React 内在的东西,我对如上案例总结,

  • 案件一-对一些组件渲染和组件错误时机声明的理解
  • 案件二-实际事件池概念的补充。
  • 案件三-是对一些组件库引入多个版本 React 的思考和解决方案。
  • 案件四-要注意给 memo / PureComponent 绑定事件,以及如何处理 PureComponent 逻辑,shallowEqual的原理。
  • 案件五-实际是对fiber双缓存树的讲解。
  • 案件六-是对 useEffect create 执行时机的讲解。

本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/-bns51t3514KoHMhPc8t-g

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:7月以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:7月以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:7月以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:7月以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:7月以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:7月以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:7月以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:7月以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:7月以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:7月以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:7月以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:7月以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:7月以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:7月以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:7月以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:7月以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:7月以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:7月以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:7月以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:7月以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  236860次阅读
vscode超好用的代码书签插件Bookmarks 1年以前  |  6846次阅读
 目录