在上个月初的时候,偶然在IThelp看到这篇讨论 setState後画面没有立即Render,决定趁自己有空的时候把相关的概念搞清楚。
以下内容是自己参考多份官方文件後的整理,如果有想法或是有错误都欢迎留言与我讨论。
本系列文章一共分上下两篇。上篇会先从React的机制来探讨如果setState是同步/非同步会发生什麽事,下篇会统整setState在什麽时候是同步/非同步,以及该如何正确的取得setState後的新state值。如果是刚入门,想先跳过底层原理解释的朋友可以直接看下篇。
请注意,在React 18以後,所有的setState都会是非同步的。
藉由上一篇,我们可以知道为了透过实作batching进行效能优化,透过React机制所呼叫的setState都是非同步的,也就是当呼叫setState的当下state并不会马上被改变。
这里的React机制指的是包含生命周期函数、SyntheticEvent handler等 (如: 以React.createElement或JSX呈现的html element上的onClick、onChange),详细SyntheticEvent列表请参考官方文件。
所以,在下方的程序码中,我们会发现在handleClick後的console.log印出的都是state修改前的值。(下方有function component版本)
export default class Apple extends Component {
constructor(props) {
super(props);
this.state = { price: 0 };
}
handleClick = (e) => {
// "e.target.value" is "this.state.price"
this.setState({ price: Number(e.target.value) + 10 });
console.log(`price is ${e.target.value}`);
};
render() {
return (
<div>
<p> Apple is ${this.state.price}</p>
<button
id="price-control"
value={this.state.price}
onClick={this.handleClick}
>
Add Apple's price
</button>
</div>
);
}
}
但是当我们不是使用React机制呼叫setState时,由於batching机制不存在,setState就会是同步的。例如: 原生addEvent listener的callback function、setTimoout的callback function.....等。在下方的范例中,我们会发现setState後马上印出的值会是新state值。
export default class Apple extends Component {
constructor(props) {
super(props);
this.state = { price: 0 };
}
handleClick = (e) => {
// "e.target.value" is "this.state.price"
this.setState({ price: Number(e.target.value) + 10 });
console.log(`price is ${e.target.value}`);
};
componentDidMount() {
document
.getElementById('price-control')
.addEventListener('click', this.handleClick);
}
componentWillUnmount() {
document
.getElementById('price-control')
.removeEventListener('click', this.handleClick);
}
render() {
return (
<div>
<p> Apple is ${this.state.price}</p>
<button
id="price-control"
value={this.state.price}
>
Add Apple's price
</button>
</div>
);
}
}
在function component中的React hook也是一样的,透过React机制所呼叫的setState都是非同步,也就是当呼叫setState的当下state并不会马上被改变。可以试着执行、比较下列程序码的执行结果
import { useState, useCallback } from 'react';
export default function Apple() {
const [price, setPrice] = useState(0);
// 透过JSX button的onClick触发
const handleClick = useCallback((e) => {
setPrice(Number(e.target.value) + 10);
console.log(e.target.value);
}, []);
return (
<div>
<p> Apple is ${price}</p>
<button
id="price-control"
value={price}
onClick={handleClick}
>
Add Apple's price
</button>
</div>
);
}
import { useState, useEffect, useCallback } from 'react';
export default function Apple() {
const [price, setPrice] = useState(0);
// 透过原生event listener触发
const handleClick = useCallback((e) => {
// "e.target.value" is "price"
setPrice(Number(e.target.value) + 10);
console.log(e.target.value);
}, []);
useEffect(() => {
document
.getElementById('price-control')
.addEventListener('click', handleClick);
return () => {
document
.getElementById('price-control')
.removeEventListener('click', handleClick);
};
}, [handleClick]);
return (
<div>
<p> Apple is ${price}</p>
<button id="price-control" value={price}>
Add Apple's price
</button>
</div>
);
}
在2021年中公布的React 18 alpha版中,释出了新的ReactDOM api ReactDOM.createRoot
。同时也公布了新的auto batching机制。在auto batching下,无论是透过SyntheticEvent、原生event还是setTimeout等,任何呼叫setState的方式都会实作batching机制。
React 18(含)以後: 所有的setState都会是非同步的
React 17(含)以前
粗略来说,我们可以根据「是谁呼叫了setState」分成这两种状况:
注: setState的非同步执行机制不同於event loop,event loop是透过WEB API执行callback,而React是将更新state的行为在React更新流程中延迟执行,但依然是在主线程(Thread)内。
参考资料:
https://reactjs.org/docs/state-and-lifecycle.html#state-updates-may-be-asynchronous
https://zhuanlan.zhihu.com/p/54919571
既然大多数的时候,setState都是非同步的,那麽该如何取得state被更新後的值呢? 以下我们会分别针对function component和class component讨论。
在function component中,如果我们想要拿到某个state被setState後的值,应该要为这个state多建立一个useEffect,并把该state被改变後要做的事情(副作用)放在这个新useEffect内。
下方是在建立元件後初始化state值,再检视新的state值的作法:
import { useState, useEffect } from 'react';
export default function Apple() {
const [price, setPrice] = useState(0);
// ---正确的作法---
useEffect(() => {
setPrice(10);
}, []);
useEffect(() => {
console.log(price);
}, [price]);
//----------------
/* ---错误的方法一---
useEffect(() => {
setPrice(10);
console.log(price);
}, []);
----------------*/
/* ---错误的方法二---
useEffect(() => {
setPrice(10);
console.log(price);
}, [price]);
----------------*/
return (
<div>
<p> Apple is ${price}</p>
</div>
);
}
另外,useState给予的setState function接收的参数原本其实也是函式,有的时候我们会想在设定某个state後,马上根据同个state更新後的值去做下一次同个state的更新,此时我们可以改用「函式回传值」的方式传入新的值。React会把更新後的state值传入此function参数中,所以我们能在函式中用更新後的state值去做下一次同个state的更新。这样的做法也能避免使用useEffect时需要思考是否会出现无限递回的情形。
在下方的范例中,即使都是在建立元件後连续加10加3次,以非函式参数作法,price会变成10,且为了只在建立元件後执行,没有把price放在useEffect的dependence参数中,严格模式下React会报错:
import { useState } from 'react';
export default function Apple() {
const [price, setPrice] = useState(0);
useEffect(() => {
setPrice(price + 10);
setPrice(price + 10);
setPrice(price + 10);
}, []);
return (
<div>
<p> Apple is ${price}</p>
</div>
);
}
而改传入函式时,price会在建立元件後变成30。也因为运算的是React传入函式的参数,而不是引入state本身,没有违反严格模式的问题:
import { useState } from 'react';
export default function Apple() {
const [price, setPrice] = useState(0);
useEffect(() => {
setPrice(prePrice => prePrice + 10);
setPrice(prePrice => prePrice + 10);
setPrice(prePrice => prePrice + 10);
}, []);
return (
<div>
<p> Apple is ${price}</p>
</div>
);
}
同时,使用useReducer,藉由reducer function封装处理state的逻辑也是可行的方法,也没有违反严格模式的问题:
import { useReducer } from 'react';
function priceRedcuer(prevState, action) {
switch (action.type) {
case 'ADD':
return prevState + 10;
default:
return prevState;
}
}
export default function Apple() {
const [price, priceDispatch] = useReducer(priceRedcuer, 0);
useEffect(() => {
priceDispatch({ type: 'ADD' });
priceDispatch({ type: 'ADD' });
priceDispatch({ type: 'ADD' });
}, []);
return (
<div>
<p> Apple is ${price}</p>
</div>
);
}
在class component中取得修改state後的值有两种作法。第一种是利用setState函式本身提供的第二个参数,这个参数接收一个function,React会在state被更新後呼叫这个callback function。我们就能在这个function参数中定义获得新state後要做的事情。
export default class Apple extends Component {
constructor(props) {
super(props);
this.state = { price: 0 };
}
componentDidMount() {
this.setState({ price: 10 }, () => {
console.log(this.state.price);
});
}
render() {
return (
<div>
<p> Apple is ${this.state.price}</p>
</div>
);
}
}
第二种方法则是利用生命周期函数中的componentDidUpdate。但需要特别注意的是,当该元件中任何state被setState设定时,componentDidUpdate都会被重新呼叫。所以必须特别注意目前的逻辑是否有出现无限递回的可能。
export default class Apple extends Component {
constructor(props) {
super(props);
this.state = { price: 0 };
}
componentDidMount() {
this.setState({ price: 10 });
}
componentDidUpdate(prevProps, prevState, snapshot) {
// 这个if是为了避免之後新增其他逻辑时出现非预期错误
if (prevState.price !== this.state.price) {
console.log(this.state.price);
}
}
render() {
return (
<div>
<p> Apple is ${this.state.price}</p>
</div>
);
}
}
另外,setState接收的第一个参数原本其实也是函式。如果想在某次设定state後,根据前次state更新後的值去做下一次的state更新,React会把更新後的state、props值传入此function参数中,所以我们能在此function用更新後的state值去做下一次的state更新。
在下方的范例中,即使都是连续加10加3次,错误的作法下,price会在建立元件後变成10;正确的作法下,price会在建立元件後变成30。
export default class Apple extends Component {
constructor(props) {
super(props);
this.state = { price: 0 };
}
componentDidMount() {
// 错误的作法
/*
this.setState({ price: this.state.price + 10 });
this.setState({ price: this.state.price + 10 });
this.setState({ price: this.state.price + 10 });
*/
// 正确的作法
for (let i = 0; i < 3; ++i) {
this.setState((state, props) => {
return { price: state.price + 10 };
});
}
}
render() {
return (
<div>
<p> Apple is ${this.state.price}</p>
</div>
);
}
}
参考资料: https://zh-hant.reactjs.org/docs/react-component.html#setstate
关於React中setState
的同步/非同步一直以来都是一个很容易遇到、也很容易犯错的问题。无论对刚入门或是对有一定的程度的开发者来说都是很值得研究。刚好趁自己有最近有时间去了解他的机制和原因,利用这两篇纪录一下,如果有想法或是有错误都欢迎留言与我讨论:)
最後偷偷广告一下,自己在11届和12届铁人赛的React.js系列文修订後和深智数位合作,最近在天珑开始预购了,想学React的朋友可以参考看看:
https://www.tenlong.com.tw/products/9789860776188?list_name=srh
<<: 理解React的setState到底是同步还是非同步(上)
上次我们已经学会要怎麽从资料库依照各个表取出我们想要的栏位,也可以透过条件筛选的方式过滤我们想要的资...
呼,想当初在铁人赛开赛前还在犹豫到底要不要开赛呢? 参赛後是要写什麽主题呢? 一探 React Na...
OK,说好本章结要来说一下页面,那就说一下页面能干嘛,首先要提到的是有页面功能又有文章功能,同时都可...
连续 30 天不中断每天上传一支教学影片,教你如何用 React 加上 Firebase 打造社群...
有些事 不可跨越 像飞的太靠近太阳 而被融化的翅膀 又或像 艾尔文献出自己的心脏 而领便...