React系列:useState和setState的执行机制(对比,包含React18)

1,512 阅读6分钟

概念

补充:下面的结论针对的是React18之前。(最近看了React18版本,其更新机制发生了变化)

所谓的执行机制:就是看useState或者setState到底是同步函数的表现形式还是异步的表现形式。

先看结论: setState 和 useState 在react的合成事件钩子函数中的异步的表现形式。在原生事件setTimeoutPromise的then方法等是同步的表现形式。

这里的异步表现形式:并不是说它是由异步函数实现的,其实函数的本身是同步代码,但是呢,是因为合成事件 和 钩子函数的调用顺序在更新之前,导致在合成事件个钩子函数中不能立即拿到更新后的值,就表现出了异步的形式

批量更新: 也是建立在"异步表现形式"上体现的,如果每一次修改state,就触发一次render函数,如果多次修改,就会触发多次,就会造成性能的浪费。所以,为了性能的优化,就先收集,然后一起更新,触发一次render函数。

useState的更新测试

const App: React.FC = () => {
  const [name, setName] = useState<string>('james')
  const [age, setAge] = useState<number>(23)

  const btn1 = () => {
    setName('kobe')
    setAge(24)
  }

  const btn2 = () => {
    Promise.resolve().then(() => {
      setName('curry')
      setName('30')
    })
  }
  
  // 看打印了几次render
  console.log('------render------')
  
  return <div>
    <button onClick={ btn1 }>同步函数</button>
    <button onClick={ btn2 }>异步函数</button>
  </div>
}

同步函数: 1次render触发

异步函数: 2次render触发

同步函数中,就使用批量更新,两次修改state,只触发了一次render。

类组件中的setState也是这么一回事,可以自己动手试一试。

批量更新的处理方式

先看类组件


export default class TestClass extends Component {
  state = {
    count: 1
  }
  btn1 = () => {
    this.setState({count: this.state.count + 1})
    this.setState({count: this.state.count + 1})
  }
  btn2 = () => {
    this.setState(prev => ({count: prev.count + 1}))
    this.setState(prev => ({count: prev.count + 1}))
  }
  render() {
    return (
      <div>
        <h1>{ this.state.count }</h1>
        <button onClick={this.btn1}>对象形式</button>
        <button onClick={ this.btn2 }>函数形式</button>
      </div>
    )
  }
}

对象形式: count值为 2

函数形式: count值为 3

类组件的批量更新的策略

针对对象形式:对象合并(类似Object.assign),针对相同属性名,后面属性覆盖前面属性。

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

// 合并为
this.setState({count: this.state.count + 1})
// 所以count为2

针对函数形式: 函数收集,然后依次执行

this.setState(prev => ({count: prev.count + 1}))
this.setState(prev => ({count: prev.count + 1}))

// 执行两次 prev => ({count: prev.count + 1}),所以count为3 

学习完了类组件的批量更新,就来看看函数组件。

函数组件分为直接传递参数和 函数形式

const App: React.FC = () => {
 const [count, setCount] = useState(1)

  const btn1 = () => {
    setCount(1)
    setCount(2)
  }

  const btn2 = () => {
    setCount(prev => prev + 1)
    setCount(prev => prev + 2)
  }

  return <div>
    <h1>{ count }</h1>
    <button onClick={ btn1 }>直接传参</button>
    <button onClick={ btn2 }>函数形式</button>
  </div>
}

直接传参: count 为 2

函数形式: count 为 4

函数批量更新策略:

针对直接传参形式: 简单理解就是覆盖,后面覆盖前面的

setCount(1)
setCount(2)
// 后面覆盖前面,count为2

针对函数形式:函数收集,依次执行

setCount(prev => prev + 1)
setCount(prev => prev + 2)
// 收集函数,依次执行
// prev => prev + 1
// prev => prev + 2
// 所以count = 1 + 1 + 2 = 4

批量更新策略总结

类组件函数组件
对象形式:合并传参形式:覆盖
函数形式:收集函数形式:收集

强行批量更新

在setTimeout等中,useState或则setState是不存在批量更新的(批量更新只存在 '异步的表现形式'中)。但是有时候,在一些请求中,根据返回的数据,就是会多次修改state,那么就会多次出发render函数,造成性能浪费,那么这时候该怎么处理呢?

在react-dom库中,提供了一个函数,就专门用来进行批量更新的。 unstable_batchedUpdates

具体使用:

const btn2 = () => {
  setTimeout(() => {
    setCount(prev => prev + 1)
    setCount(prev => prev + 2)
  }, 0)
}
// 会触发两次render

const btn2 = () => {
  setTimeout(() => {
    unstable_batchedUpdates(() => {
      setCount(prev => prev + 1)
      setCount(prev => prev + 2)
    })
  }, 0)
}
// 只会触发一次render

所以,有的时候,我们可以使用这个函数,来进行一些性能优化,减少render的次数。

捕获值(Capture Value)

// 类组件的点击事件(count = 1)
btn1 = () => {
  const { count } = this.state
  setTimeout(() => {
    this.setState({ count: this.state.count + 1 })
    console.log('this.state.count', this.state.count)
    console.log('count', count)
  }, 3000)
}

有一个setTimeout,点击后,3秒后执行,在这3秒中,快速点击5次

// 打印
this.state.count 2
count 1

this.state.count 3
count 1

this.state.count 4
count 1

this.state.count 5
count 1

this.state.count 6
count 1

在上面,我们发现,react组件中state.count是已经发生了变化,但是在setTimeout函数中,count的值,是没有发生变化的。为什么呢?

由于闭包的关系, 在setTimeout函数中,使用了外部环境的变量(count),形成了闭包。那么setTimeout函数中就只会捕获,函数执行那一刻的外部变量的值,那时候count的值为1,所以count的值一直是1。

如果理解了上面的 值捕获,那么下面的函数组件的实例就非常的好懂了。

const btn1 = () => {
 setTimeout(() => {
   setCount(prev => prev + 1)
   console.log('count', count)
 }, 3000)
}
// 或则是
const btn1 = () => {
   setCount(prev => prev + 1)
   console.log('count', count)
}

点击了,打印的count值始终是1, 因为无论 btn1 还是 setTimeout,都是一个函数,使用了外部的变量,就形成了闭包,捕获的是执行那一刻的值。

面试题 (执行顺序)

const App: React.FC = () => {
 const [value1, setValue1] = useState('a')
 const [value2, setValue2] = useState('b')
 const [value3, setValue3] = useState('c')

 useEffect(() => {
  setTimeout(() => {
    console.log('------1------');
    setValue1('new_james')
    console.log('------2------');
    setValue2('new_kobe')
    console.log('------3------');
    setValue3('new_curry')
  }, 200);
 }, [])

 useEffect(() => {
  console.log('------4------');
  setValue1('new_james')
 }, [value1])

 useEffect(() => {
  console.log('------5------');
  setValue2('new_kobe')
 }, [value2])

 console.log('------render------')
 return <div>测试</div>
}

给出你的打印结果。。。

补充React18

最近也了解了一些React18的新特性,其中之一:React18的更新策略发生了变化。

在 react18 中,都是采用的批量更新,无论是在同步的表现形式中还是异步的表现形式中。

在React17(上面的说到),在 合成事件钩子函数 中是批量更新,在 异步函数原生DOM事件 中,都不是采用的批量更新。

示例

类组件

class App extends Component<any, IState> {
  constructor(props: any) {
    super(props);
    this.state = {
      count: 1,
    };
  }
  // 同步的表现形式
  btn1 = () => {
    this.setState((prev) => ({ count: prev.count + 1 }));
    this.setState((prev) => ({ count: prev.count + 1 }));
  };
  // 异步的表现形式
  btn2 = () => {
    setTimeout(() => {
      this.setState((prev) => ({ count: prev.count + 1 }));
      this.setState((prev) => ({ count: prev.count + 1 }));
    }, 0);
  };
  render() {
    console.log("render渲染的次数");
    return (
      <div>
        <button onClick={this.btn1}>同步函数</button>
        <button onClick={this.btn2}>异步函数</button>
      </div>
    );
  }
}

函数组件

function App() {
  const [count, setCount] = React.useState<number>(1);
  const btn1 = () => {
    setCount((prev) => prev + 1);
    setCount((prev) => prev + 1);
  };
  const btn2 = () => {
    setTimeout(() => {
      setCount((prev) => prev + 1);
      setCount((prev) => prev + 1);
    }, 0);
  };
  console.log("render渲染的次数");
  return (
    <div className="App">
      <button onClick={ btn1 }>同步函数</button>
      <button onClick={ btn2 }>异步函数</button>
    </div>
  );
}

这里你就会发现,无论是函数组件还是类组件,在合成事件还是异步函数中,都是采用的批量更新。每次点击按钮的时候,只会打印一次render。

注意: 要关闭React.StrictMode 模式。React18中,当使用严格模式时,React 会对每个组件进行两次渲染,以便你观察一些意想不到的结果。

破坏批量更新

既然知道了React18都是采用的批量更新,那么怎么破坏react的批量更新呢?(当然这种操作,还是少操作为好)。

在react17中提供了 unstable_batchedUpdates 函数,用来合并批量操作。 在react18中提供了 flushSync 函数,用来取消批量操作。(当然 unstable_batchedUpdates也没被废弃掉,还是可以使用,可能在未来有可能被删除吧)

import { flushSync } from "react-dom";

const add = () => {
  flushSync(() => {
    setCount1(count1 + 1);
  });
  flushSync(() => {
    setCount2(count2 + 1);
  });
}
// 调用add(),flushSync包裹,render方法将会触发两次

总结

React17 和 React18 批量更新的策略变化。React17根据情况而采用不同的更新策略,React18就统一的采用更新策略,在学习中成本上减少负担,以及在开发上,可以不用考虑render渲染次数,带来的性能问题。(我总是在请求后台接口,返回数据时使用unstable_batchedUpdates函数来减少渲染次数,优化)。

在未来,全面拥抱react18吧!