【React源码笔记】setState原理解析

全栈前端精选

共 14441字,需浏览 29分钟

 ·

2021-03-22 13:15

点击上方蓝字,发现更多精彩

导语


大家都知道React是以数据为核心的,当状态发生改变时组件会进行更新并渲染。除了通过React Redux、React Hook进行状态管理外,还有像我这种小白通过setState进行状态修改。对于React的初学者来说,setState这个API是再亲切不过了,同时也很好奇setState的更新机制,因此写了一篇文章来进行巩固总结setState。



React把组件看成是一个State Machines状态机,首先定义数值的状态state,通过用户交互后状态发生改变,然后更新渲染UI。也就是说更新组件的state,然后根据新的state重新渲染更新用户的界面。而在编写类组件时,通常分配state的地方是construtor函数。

刚开始热情满满学习的时候,总是从React官方文档开始死磕,看到state那一块,官方文档抛出了“ 关于 setState()你应该了解的三件事 “几个醒目的大字:

(1)不要直接修改state (2)state的更新可能是异步的 (3)state的更新会被合并

啊…那setState方法从哪里来?为什么setState是有时候是异步会不会有同步的呢?为什么多次更新state的值会被合并只会触发一次render?为什么直接修改this.state无效???

带着这么多的疑问,因为刚来需求也不多,对setState这一块比较好奇,那我就默默clone了react源码。今天从这四个有趣的问题入手,用setState跟大家深入探讨state的更新机制,一睹setState的芳容。源码地址入口(本次探讨是基于React 16.7.0版本,React 16.8后加入了Hook)。





1. setState API从哪里来



  1. Component.prototype.setState = function(partialState, callback) {

  2.  ...

  3.  this.updater.enqueueSetState(this, partialState, callback, 'setState');

  4. };

setState是挂载在组件原型上面的方法,因此用class方法继承React.Component时,setState就会被自定义组件所继承。通过调用this就可以访问到挂载到组件实例对象上的setState方法,setState方法从这来。





2. setState异步更新 && 同步更新


在react state源码注释中有这样一句话:

  1. There is no guarantee that this.state will be immediately updated, so accessing this.state after calling this method may return the old value.

大概意思就是说setState不能确保实时更新state,但也没有明确setState就是异步的,只是告诉我们什么时候会触发同步操作,什么时候是异步操作。

首先要知道一点,setState本身的执行过程是同步的,只是因为在react的合成事件与钩子函数中执行顺序在更新之前,所以不能直接拿到更新后的值,形成了所谓的“ 异步 ”。异步可以避免react改变状态时,资源开销太大,要去等待同步代码执行完毕,使当前的JS代码被阻塞,这样带来不好的用户体验。

那setState什么时候会执行异步操作或者同步操作呢?

简单来说,由react引发的事件处理都是会异步更新state,如

  • 合成事件(React自己封装的一套事件机制,如onClick、onChange等)

  • React生命周期函数

而使用react不能控制的事件则可以实现同步更新,如

  • setTimeout等异步操作

  • 原生事件,如addEventListener等

  • setState回调式的callback

由上面第一部分的代码可知setState方法传入参数是partialState, callback,partialState是需要修改的setState对象,callback是修改之后回调函数,如 setState({},()=>{})。我们在调用setState时,也就调用了 this.updater.enqueueSetState,updater是通过依赖注入的方式,在组件实例化的时候注入进来的,而之后被赋值为classComponentUpdater。而enqueueSetState如其名,是一个队列操作,将要变更的state统一插入队列,待一一处理。enqueueSetState函数如下:

  1. const classComponentUpdater = {

  2.  isMounted,

  3.  // inst其实就是组件实例对象的this

  4.  enqueueSetState(inst, payload, callback) {

  5.  //  获取当前实例上的fiber

  6.    const fiber = getInstance(inst);

  7.    const currentTime = requestCurrentTime();

  8.    const expirationTime = computeExpirationForFiber(currentTime, fiber);

  9.    const update = createUpdate(expirationTime);

  10.    update.payload = payload;

  11.    if (callback !== undefined && callback !== null) {

  12.      if (__DEV__) {

  13.        warnOnInvalidCallback(callback, 'setState');

  14.      }

  15.      update.callback = callback;

  16.    }

  17.    flushPassiveEffects();

  18.    //    把更新放到队列中去

  19.    enqueueUpdate(fiber, update);

  20.    //     进入异步渲染的核心:React Scheduler

  21.    scheduleWork(fiber, expirationTime);

  22.  },

  23.  ...

  24. }

注释中讲到scheduleWork是异步渲染的核心,正是它里面调用了reqeustWork函数。

  1. function requestWork(root: FiberRoot, expirationTime: ExpirationTime) {

  2.  //  根节点添加到调度任务中

  3.  addRootToSchedule(root, expirationTime);

  4.  if (isRendering) {

  5.    return;

  6.  }

  7.  //  isBatchingUpdates默认为flase,但是react事件触发后会对它重新赋值为true

  8.  if (isBatchingUpdates) {

  9.  //  isUnbatchingUpdates默认也为false

  10.    if (isUnbatchingUpdates) {

  11.      nextFlushedRoot = root;

  12.      nextFlushedExpirationTime = Sync;

  13.      performWorkOnRoot(root, Sync, false);

  14.    }

  15.    return;

  16.  }

  17.  if (expirationTime === Sync) {

  18.  //  若是isBatchingUpdates为false,则对setState进行diff渲染更新

  19.    performSyncWork();

  20.  } else {

  21.    scheduleCallbackWithExpirationTime(root, expirationTime);

  22.  }

  23. }

可以看到在这个函数中有isRendering(当React的组件正在渲染但还没有渲染完成的时候,isRendering是为true;在合成事件中为false)和isBatchingUpdates(默认为false)两个变量,而这两个变量在下文分析中起到非常重要的作用。

°

2.1 交互事件里面的setState

举个栗子:

  1. this.state = {

  2.  name:'rosie',

  3.  age:'21',

  4. };

  5. handleClick(){

  6.  this.setState({

  7.    age: '18'

  8.  })

  9.  console.log(this.state.age) // 输出21

  10. }

可以看到在react交互事件里age并没有同步更新。

先贴张小小的流程图:

react有一套自己的事件合成机制,在合成事件调用时会用到interactiveUpdates函数。

  1. function interactiveUpdates<A, B, R>(fn: (A, B) => R, a: A, b: B): R {

  2.  if (isBatchingInteractiveUpdates) {

  3.    return fn(a, b);

  4.  }

  5.  ...

  6.  const previousIsBatchingInteractiveUpdates = isBatchingInteractiveUpdates;

  7.   //  将previousIsBatchingUpdates赋值为false

  8.  const previousIsBatchingUpdates = isBatchingUpdates;

  9.  isBatchingInteractiveUpdates = true;

  10.  isBatchingUpdates = true;

  11.  //  关键代码块

  12.  try {

  13.    return fn(a, b);

  14.  } finally {

  15.    isBatchingInteractiveUpdates = previousIsBatchingInteractiveUpdates;

  16.    //  isBatchingUpdates变为false

  17.    isBatchingUpdates = previousIsBatchingUpdates;

  18.    if (!isBatchingUpdates && !isRendering) {

  19.      performSyncWork();

  20.    }

  21.  }

  22. }

可以看到这个函数中执行了 isBatchingUpdates=true,在执行try代码块中的fn函数(指的是从dispatchEvent 到 requestWork整个调用栈)时,在reqeustWork方法中isBatchingUpdates被修改成了true,而isUnbatchingUpdates默认为false,所以在这里直接被return了。这就表示requestWork中performSyncWork函数没有被执行到,当然其他更新函数像performWorkOnRoot也没有被执行,因此还没被更新。但是在开始的enqueueSetState函数通过 enqueueUpdate(fiber,update)语句已经把该次更新存入到了队列当中。

  1. if (isBatchingUpdates) {

  2.  //  isUnbatchingUpdates也为false

  3.  if (isUnbatchingUpdates) {

  4.    nextFlushedRoot = root;

  5.    nextFlushedExpirationTime = Sync;

  6.    performWorkOnRoot(root, Sync, false);

  7.  }

  8.  return;

  9. }

那么在reqeustWork中被return了,会return到哪里呢?从流程图看到很显然是回到了interactiveUpdates这个方法中。因此执行setState后直接console.log是属于try代码块中的执行,由于合成事件try代码块中执行完state后并没有更新(因为没有执行到performSyncWork),因此输出还是之前的值,造成了所谓的“异步”。

等到合成事件执行完后,就进入到了finally,此时isBatchingUpdates变为false,isRendering也为false,二者取反为true则进入到了performSyncWork函数,这个函数会去更新state并且渲染对应的UI。

°

2.2 生命周期里的setState


  1. this.state = {

  2.  name:'rosie',

  3.  age:'21',

  4. };

  5. componentDidMount() {

  6.  this.setState({

  7.    age: '18'

  8.  })

  9.  console.log(this.state.age) // 21

  10. }

  11. shouldComponentUpdate(){

  12.  console.log("shouldComponentUpdate",this.state.age); // 21

  13.  return true;

  14. }

  15. render(){

  16.  console.log("render",this.state.age); // 18

  17.  return{

  18.    <div></div>

  19.  }

  20. }

  21. getSnapshotBeforeUpdate(){

  22.  console.log("getSnapshotBeforeUpdate",this.state.age); // 18

  23.  return true;

  24. }

  25. componentDidUpdate(){

  26.  console.log("componentDidUpdate",this.state.age);// 18

  27. }

可以看到在componentDidMount输出结果仍然是以前的值。再贴个大大的流程图:

我们一般在componentDidMount中调用setState,当componentDidMount执行的时候,此时组件还没进入更新渲染阶段,isRendering为true,在reqeustWork函数中直接被return掉(输出旧值最重要原因),没有执行到下面的更新函数。等执行完componentDidMount后才去 commitUpdateQueue更新,导致在componentDidMount输出this.state的值还是旧值。

采用程墨大大的图,React V16.3后的生命周期如下:

那么它会经过组件更新的生命周期,会触发Component的以下4个生命周期方法,并依次执行:

  1. shouldComponentUpdate // 旧值

  2. render // 更新后的值

  3. getSnapshotBeforeUpdate // 更新后的值

  4. componentDidUpdate // 更新后的值

componentDidMount生命周期函数是在组件一挂载完之后就会执行,由新的生命周期图可以看到,当shouldComponentUpdate返回true时才会继续走下面的生命周期;如果返回了false,生命周期被中断,虽然不调用之后的函数了,但是state仍然会被更新。

正是在componentDidMount时直接return掉,经过了多个生命周期this.state才得到更新,也就造成了所谓的“异步”。

当然我们也不建议在componentDidMount中直接setState,在 componentDidMount 中执行 setState 会导致组件在初始化的时候就触发了更新,渲染了两遍,可以尽量避免。同时也禁止在shouldComponentUpdate中调用setState,因为调用setState会再次触发这个函数,然后这个函数又触发了 setState,然后再次触发这两个函数……这样会进入死循环,导致浏览器内存耗光然后崩溃。

°

2.3 setTimeOut中的setState



  1. this.state = {

  2.  name:'rosie',

  3.  age:'21',

  4. };

  5. componentDidMount() {

  6.  setTimeout(e => {

  7.    this.setState({

  8.      age: '18'

  9.    })

  10.    console.log(this.state.age) // 18

  11.  }, 0)

  12. }

我们都知道JS有event loop事件循环机制。当script代码被执行时,遇到操作、函数调用就会压入栈。主线程若遇到ajax、setTimeOut异步操作时,会交给浏览器的webAPI去执行,然后继续执行栈中代码直到为空。浏览器webAPI会在某个时间内比如1s后,将完成的任务返回,并排到队列中去,当栈中为空时,会去执行队列中的任务。

  1. function requestWork(root: FiberRoot, expirationTime: ExpirationTime) {

  2.  ...

  3.  if (isBatchingUpdates) {

  4.  ...

  5.    return;

  6.  }

  7.  if (expirationTime === Sync) {

  8.    performSyncWork();

  9.  } else {

  10.    scheduleCallbackWithExpirationTime(root, expirationTime);

  11.  }

  12. }

当你try代码块执行到setTimeout的时候,此时是把该异步操作丢到队列里,并没有立刻去执行,而是执行interactiveUpdates函数里的finally代码块,而previousIsBatchingUpdates在之前被赋值为false,之后又赋给了isBatchingUpdates,导致isBatchingUpdates变成false。导致最后在栈中执行setState时,也就是执行try代码块中的fn(a,b)时,进入reqeustWork函数中执行了performSyncWork,也就是可以同步更新state。

°

2.4 原生事件中的setState

原生事件指的是非react合成事件,像 addEventListener()或者 document.querySelector().onclick()等这种绑定事件的形式。

  1. this.state = {

  2.  name:'rosie',

  3.  age:'21',

  4. };

  5. handleClick = () => {

  6.  this.setState({

  7.    age: '18'

  8.  })

  9.  console.log(this.state.age) // 18

  10. }

  11. componentDidMount() {

  12.  document.body.addEventListener('click', this.handleClick)

  13. }

因为原生事件没有走合成过程,因此在reqeustWork中isRendering为false,isBatchingUpdates为false,直接调用了performSyncWork去更新,所以能同步拿到更新后的state值。





3. setState中的批量更新


如果每次更新state都走一次四个生命周期函数,并且进行render,而render返回的结果会拿去做虚拟DOM的比较和更新,那性能可能会耗费比较大。像以下这种:

  1. this.state = {

  2.  count:0,

  3. };

  4. add = () => {

  5.  for ( let i = 0; i < 10000; i++ ) {

  6.    this.setState( { count: this.state.count + 1 } );

  7.  }

  8. }

如果每次都立马执行的,在短短的时间里,会有10000次的渲染,这显然对于React来说是较大的一个渲染性能问题。那如果我不是10000次,只有两次呢?

  1. add = ()=>{

  2.  this.setState({

  3.    count: this.state.count + 1

  4.  });

  5.  this.setState({

  6.    count: this.state.count + 1

  7.  });

  8. }

没有意外,以上代码还是只执行了一个render,就算不是10000次计算,是2次计算,react为了提升性能只会对最后一次的 setState 进行更新。

React针对 setState 做了一些特别的优化:React 会将多个setState的调用合并成一个来执行,将其更新任务放到一个任务队列中去,当同步任务栈中的所有函数都被执行完毕之后,就对state进行批量更新。

当然你也可以用回调函数拿到每次执行后的值,此时更新不是批量的:

  1. add = () => {

  2.  this.setState((preCount)=>({

  3.    count: preCount.count + 1

  4.  }));

  5.  this.setState((preCount)=>({

  6.    count: preCount.count + 1

  7.  }));

  8. }// 输出2

你也可以使用setTimeout更新多次:

  1. add = () => {

  2.  setTimeout( _=>{

  3.    this.setState({

  4.      count: this.state.count + 1

  5.    });

  6.  },0)

  7.  setTimeout( _=>{

  8.    this.setState({

  9.      count: this.state.count + 1

  10.    });

  11.  },0)

  12. }// 输出2

你上面说了setState会进行批量更新,那为啥使用回调函数或者setTimeout等异步操作能拿到2,也就是render了两次呢??

首先只render一次即批量更新的情况,由合成事件触发时,在reqeustWork函数中isBatchingUpdates将会变成true,isUnbatchingUpdates为false则直接被return掉了。但是之前提到它会在开始的enqueueSetState函数通过enqueueUpdate(fiber, update)已经把该次更新存入到了队列当中,在enqueueUpdate函数中传入了fiber跟update两个参数。

  1.  enqueueSetState(inst, payload, callback) {

  2.    //    获取当前实例上的fiber

  3.    const fiber = getInstance(inst);

  4.    //    计算当前时间

  5.    const currentTime = requestCurrentTime();

  6.    //    计算当前fiber的到期时间,为计算优先级作准备

  7.    const expirationTime = computeExpirationForFiber(currentTime, fiber);

  8.    //    创建更新一个update

  9.    const update = createUpdate(expirationTime);

  10.    //    payload是要更新的对象

  11.    update.payload = payload;

  12.    //    callback回调函数

  13.    if (callback !== undefined && callback !== null) {

  14.      if (__DEV__) {

  15.        warnOnInvalidCallback(callback, 'setState');

  16.      }

  17.      update.callback = callback;

  18.    }

  19.    flushPassiveEffects();

  20.    //    重点:把更新放到队列中去

  21.    enqueueUpdate(fiber, update);

  22.    //     进入异步渲染的核心:React Scheduler

  23.    scheduleWork(fiber, expirationTime);

  24.  },

简单提一下,为了避免更新的过程中长时间阻塞主线程,在React 16之后加入了Fiber架构,它能将整个更新任务拆分为一个个小的任务,并且能控制这些任务的执行。而fiber是一个工作单元,是把控这个拆分的颗粒度的数据结构。

加入Fiber架构后,react在任务调度之前通过enqueueUpdate函数调度,里面修改了Fiber的updateQueue对象的任务,即维护了fiber.updateQueue,最后调度会调用一个getStateFromUpdate方法来获取最终的state状态,而这个方法里面的这段代码显得尤为关键:

  1. function getStateFromUpdate<State>(

  2.  workInProgress: Fiber,

  3.  queue: UpdateQueue<State>,

  4.  update: Update<State>,

  5.  prevState: State,

  6.  nextProps: any,

  7.  instance: any,

  8. ): any {

  9.  switch (update.tag) {

  10.    ...

  11.    case UpdateState: {

  12.      const payload = update.payload;

  13.      let partialState;

  14.      //  当payload为函数类型时

  15.      if (typeof payload === 'function') {

  16.          ...

  17.           partialState = payload.call(instance, prevState, nextProps);

  18.           ...

  19.      }

  20.      ...

  21.      // 重点:通过Object.assign生成一个全新的state,和未更新的部分state进行合并

  22.      return Object.assign({}, prevState, partialState);

  23.    }

  24.   ...

  25.  }

  26.  return prevState;

  27. }

看到Object.assign是不是很熟悉?preState是原先的状态,partialState是将要更新后的状态,Object.assign就是对象合并。那么 Object.assign({},{count:0},{count:1})最后返回的是{count:1}达到了state的更新。

我们刚才花了一大篇幅来证明在react合成事件和生命周期下state的更新是异步的,主要体现在interactiveUpdates函数的try finally模块,在try模块执行时不会立刻更新,因此导致三次setState的prevState值都是0,两次setState的partialState都是1。执行两次 Object.assign({},{count:0},{count:1})最后结果不还是返回1吗?

因此也可以得出state的批量更新是建立在异步之上的,那setTimeout同步更新state就导致state没有批量更新,最后返回2。

那callBack回调函数咋就能也返回2呢?我们知道payload的类型是function时,通过 partialState=payload.call(instance,prevState,nextProps)语句的执行,能获取执行回调函数后得到的state,将其赋值给每次partialState。每次回调函数都能拿到更新后的state值,那就是每次partialState都进行了更新。在进行Object.assign对象合并时,两次prevState的值都是0,而partialState第一次为1,第二次为2,像如下这样:

  1. Object.assign({}, {count:0}, {count:1});

  2. Object.assign({}, {count:0}, {count:2});

也就最后返回了2。所以如果你不想拿到setState批量更新后的值,直接用回调函数就好啦。





4. 直接修改this.state无效


  1. this.state.comment = 'Hello world';

直接以赋值形式修改state,不会触发组件的render。

通过上面的分析,可以得出setState本质是通过一个更新队列机制实现更新的,如果不通过setState而直接修改this.state,那么这个state不会放入状态更新队列中,也就不会render,因此修改state的值必须通过setState来进行。

  1. this.setState({

  2.  comment: 'Hello world'

  3. })





5. 小Tips && 小总结

更新对象:

  1. this.setState(preState=> ({

  2.  obj: Object.assign({}, preState.obj, {name: 'Tom'})

  3. }))

  4. this.setState(preState=> ({

  5.  obj: {...preState.obj,name:'Tom'}

  6. }))

更新数组:

  1. this.setState((perState)=>{

  2.  return {arr:perState.arr.concat(1)}

  3. })

  4. this.setState((perState)=>{

  5.  return {arr:[...perState.arr,1]}

  6. })

  7. this.setState((perState)=>{

  8.  return {arr:perState.arr.slice(1,4)}

  9. })

注意,不要使用push、pop、shift、unshift、splice等方法修改数组类型的状态,因为这些方法都是在原数组的基础上修改的,返回值不是新的数组,而是返回长度或者修改的数组部分等。而concat、slice、filter会生成一个新的数组。

总结:通过探讨React state的更新机制,更加理解了React深层更新的运作流程。感觉React还是非常的博大精深,希望以后继续探讨下去哈哈哈,欢迎大家批评指正!

浏览 56
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报