Jansiel Notes

18 版本之前

经典面试题:setState 是同步还是异步

在 react 18 版本之前,在面试中经常会出现这个问题,那么答案又是什么样的呢?

  • 在 React 合成事件中是异步的
  • 在 hooks 中是异步的
  • 其他情况皆是同步的,例如:原生事件、setTimeout、Promise 等
    看看下面这段代码的执行结果,就知道所言非虚了
 1class App extends React.Component {
 2
 3    state = {
 4        count: 0
 5    }
 6    
 7    componentDidMount() {
 8        this.setState({count: this.state.count + 1})
 9        console.log(this.state.count);
10        this.setState({count: this.state.count + 1})
11        console.log(this.state.count);
12
13        setTimeout(() => {
14            this.setState({count: this.state.count + 1})
15            console.log(this.state.count);
16            this.setState({count: this.state.count + 1})
17            console.log(this.state.count);
18        });
19        
20    }
21
22    render() {
23        return <h1>Count: {this.state.count}</h1>
24    }
25}

有经验的同学肯定都知道,最终的结果是: 0 0 2 3。

原因就是因为 componentDidMount 中的 setState 是批量更新,在整体逻辑没走完之前,不会进行更新。所以前两次打印结果都是 0,并且将两次更新合并成了一次。

而在 setTimeout 中,脱离了 React 的掌控,变成了同步更新,因为下方的 log 可以实时打印出即时的状态。

此时 React 的内部的处理逻辑我们可以写一段代码简单模拟一下:

  • 先声明三个变量,用来记录数据
    • isBatchUpdate: 判断是否批量更新的标志
    • count: 状态
    • queue: 存储状态的数组
  • 声明一个 handleClick 方法,来模拟 React 合成事件
  • 声明一个 setState 方法,来模拟 React 的 setState
 1// 判断是否批量更新的标志
 2let isBatchUpdate = false;
 3// 状态
 4let count = 0;
 5// 存储最新状态的数组
 6let queue = [];
 7const setState = (state) => {
 8    // 批量更新,则将状态暂存,否则直接更新
 9    if (isBatchUpdate) {
10        queue.push(state);
11    } else {
12        count = state;
13    }
14}
15
16const handleClick = () => {
17    // 进入事件,先将 isBatchUpdate 设置为 true
18    isBatchUpdate = true
19
20    setState(count + 1)
21    console.log(count);
22    setState(count + 1)
23    console.log(count);
24    setTimeout(() => {
25        setState(count + 1)
26        console.log(count);
27        setState(count + 1)
28        console.log(count);
29    })
30    
31    // 事件结束,将 isBatchUpdate 置为 false
32    isBatchUpdate = false;
33}
34
35handleClick();
36
37count = queue.pop();
38
39// 更新完成,重置状态数组 queue
40queue = [];
41

可以看到,上面这段代码的打印结果也是 0 0 2 3。

手动批量更新

上面提到,在原生事件以及 setTimeout 等情况下,setState 是同步的,那如果我们仍然希望这种情况下可以同步更新,该怎么办呢?

React 也提供了一种解决方案:从 react-dom 包中暴露了一个 API: unstable_batchedUpdates

那我们简单用一下看看效果:

 1class App extends React.Component {
 2
 3    state = {
 4        count: 0
 5    }
 6    
 7    componentDidMount() {
 8        this.setState({count: this.state.count + 1})
 9        console.log(this.state.count);
10        this.setState({count: this.state.count + 1})
11        console.log(this.state.count)
12
13        setTimeout(() => {
14            ReactDOM.unstable_batchedUpdates(() => {
15                this.setState({count: this.state.count + 1})
16                console.log(this.state.count)
17                this.setState({count: this.state.count + 1})
18                console.log(this.state.count)
19            }) 
20        })
21    }
22
23    render() {
24        return <h1>Count: {this.state.count}</h1>
25    }
26}

可以看到此时的打印结果为 0 0 1 1。

Ok,React 18 之前 setState 的更新方式就说到这里,那 React 18 里做了什么改动呢?

React 18 版本之后

上面提到了默认批量更新以及手动批量更新,那有些场景不满足了呀,觉得手动的还是不够智能,在很多情况下还得手动去调用
unstable_batchedUpdates 这个函数,用起来不爽。

别急,React 18 新版本就可以解决这些场景的痛点了!

直接上代码,看看 React 18 到底怎么用的:

 1class App extends React.Component {
 2
 3    state = {
 4        count: 0
 5    }
 6    
 7    componentDidMount() {
 8        this.setState({count: this.state.count + 1})
 9        console.log(this.state.count);
10        this.setState({count: this.state.count + 1})
11        console.log(this.state.count)
12
13        setTimeout(() => {
14            this.setState({count: this.state.count + 1})
15            console.log(this.state.count)
16            this.setState({count: this.state.count + 1})
17            console.log(this.state.count)
18        })
19    }
20
21    render() {
22        return <h1>Count: {this.state.count}</h1>
23    }
24}
25
26// 使用 react 18 新的并发模式写法进行 dom render
27ReactDOM.createRoot(document.getElementById('#root')!).render(<App />)

组件代码保持和第一版的一致,没有使用 unstable_batchedUpdates。

可以看到,此时的打印结果也是: 0 0 1 1
仅仅是使用了新的 API: ReactDOM.createRoot(root).render(jsx)。React 就能实现自动的批量更新了。感觉有点神奇。

我们依然写一段代码来模拟一下这个过程:

  • 此时不需要 isBatchUpdate 来判断是否批量更新了,而是通过更新的优先级来进行判断
  • 每次更新会进行优先级的判定,相同优先级的任务会被合并。
  • 事件执行完毕,进行任务的执行和更新
 1// 状态
 2let count = 0;
 3// 存储状态的数组
 4let queue = [];
 5const setState = (state) => {
 6    const newState = {payload: state, priority: 0 }
 7    // 判断当前优先级的任务集合是否存在,不存在则初始化,存在则存到对应由县级的任务集合中
 8    if (queue[newState.priority]) {
 9        queue[newState.priority].push(newState.payload)
10    } else {
11        queue[newState.priority] = [newState.payload]
12    }
13}
14
15const handleClick = () => {
16    setState(count + 1)
17    console.log(count);
18    setState(count + 1)
19    console.log(count);
20    setTimeout(() => {
21        setState(count + 1);
22        console.log(count);
23        setState(count + 1)
24        console.log(count);
25    })
26}
27
28handleClick();
29
30count = queue.pop().pop();
31
32setTimeout(() => {
33    count = queue.pop().pop();
34})

可以看到,上面这段代码的执行结果也是 0 0 1 1

上述模拟代码仅为了展示优先级批量更新,不代表任何 React 源码的逻辑和思想

自动批量更新的新特性就说到这里了。这里引入了三个问题

  1. Q: React 18 之后提供了 ReactDOM.createRoot(root).render(jsx) 的 API,那之前 ReactDOM.render 的 API 还支持吗?
    • 支持的,并且行为和之前版本是一致的。只有使用了 ReactDOM.createRoot 这种方式,才会启用新的并发模式。
  2. Q: React 全自动更新后,那如果我就是想拿到更新之后的数据怎么办呢?
    • 类组件中可以使用 setState(state, callback) 的方式,在 callback 中取到最新的值,函数组件可以使用 useEffect,将 state 作为依赖。即可以拿到最新的值。
  3. 文章中说到的优先级的概念是怎么回事呢?
  • 这个涉及到 React 最新的调度以及更新的机制,优先级的概念以及其他优先级的任务如何创建,我们之后会一一展开来说。
  • 目前的话,可以理解为 React 的更新机制进行了变化,不再依赖于批量更新的标志。而是根据任务优先级来进行更新:高优先级的任务先执行,低优先级的任务后执行。