React 組件狀態(tài)
React 把組件看成是一個狀態(tài)機(State Machines)。通過與用戶的交互,實現(xiàn)不同狀態(tài),然后渲染 UI,讓用戶界面和數(shù)據(jù)保持一致。
setState() 則是 React 組件狀態(tài)更新的入口,調(diào)用 setState() 會對一個組件的 state 對象安排一次更新。當 state 改變了,該組件就會重新渲染。
一次組件更新的過程其實很復雜,包括 React 生命周期鉤子的執(zhí)行、虛擬DOM 的創(chuàng)建、diff 對比、真實DOM 的創(chuàng)建 等等。

setState 批量/合并 更新
那么問題來了,是不是每次調(diào)用 setState() 都會觸發(fā)組件重新渲染呢?
如果不確定的話,我們就來做個試驗驗證一下。由于 React 組件每次渲染都會調(diào)用 componentDidUpdate 生命周期方法,我們可以在這個方法中打個日志:
export default class App extends Component {
constructor (p) {
super(p)
this.state = {
name: "peak",
age: 10
}
}
componentDidUpdate() {
// 組件更新時觸發(fā)
console.log("組件更新")
}
handleClick = () => {
this.setState({
name: "peak1"
})
this.setState({
age: 11
})
}
render () {
return (
<View>
<Text>姓名:{this.state.name},年齡:{this.state.age}</Text>
<TouchableOpacity onPress={this.handleClick}>
<Text>更新組件</Text>
</TouchableOpacity>
</View>
)
}
}
可以發(fā)現(xiàn)點擊按鈕觸發(fā) setState() 時,componentDidUpdate 只走了一次,也就是說,并不是每次調(diào)用 setState() 都會觸發(fā)組件重新渲染。
在這個案例中,多個 setState() 被合并成了一次更新,這就是 setState() 的批量更新,或者稱為 合并更新。
setState() 的合并更新還有另一種表達方式,就是我們常說的 異步,異步的 setState() 表現(xiàn)為:調(diào)用 setState() 之后無法立刻獲取到最新的 this.state 值。通過下面的日志可以直觀的發(fā)現(xiàn)這一點:
handleClick = () => {
this.setState({
name: "peak1"
})
console.log("name=",this.state.name) // 打?。簆eak
this.setState({
age: 11
})
console.log("age=",this.state.age) // 打?。?0
}
What?還有同步的 setState?
實際上合并更新是 React 的一種優(yōu)化策略,目的在于避免頻繁的觸發(fā)組件重新渲染,但是這個優(yōu)化是有條件的,并不是所有的 setState() 都能被合并。
下面是 setState 的偽代碼:
setState(newState) {
if (this. isBatchingUpdates) {
this.updateQueue.push(newState)
return
}
// 下面是真正的更新: 修改 this.state,dom-diff, lifeCycle...
...
}
setState 會通過一個變量來判斷當前狀態(tài)變更是否能夠被合并,如果可以合并,就會將本次更新緩存起來,等到后面來一次性更新;如果不可以合并,就會立即更新組件。
意思就是,當 isBatchingUpdates 為 false 時,setState() 會立即觸發(fā)組件渲染,同時 this.state 的值也會相應的變化,我們能夠立即拿到最新的 this.state 值。此時的 setState() 表現(xiàn)并非是 異步,而是 同步 的。
從這里可以看出,setState(x) 并不等于 this.state = x。修改 this.state 的時機被 React 封裝了一層,只有當真正去渲染組件的時候 this.state 的值才會變化。這就造成了我們看到的 同步 和 異步 的現(xiàn)象。
有的人可能會說,同步 很好啊,我能夠立即獲取到最新的 this.state 值,很直觀。有這種想法的人忽略了一個重要的問題,就是 在同步場景中,每次調(diào)用 setState() 變更狀態(tài)都會觸發(fā)組件重新渲染,導致性能下降。 正因為如此,所以 React 才引入合并更新來避免組件頻繁的重新渲染。
那么問題又來了,既然 同步 更新會導致性能下降,那為什么 React 不直接全都用 異步 呢,這樣就能合并更新了。為了找到答案,我們接著往下看。
setState 什么時候是同步,什么時候是異步?
React 的更新是基于 Transaction(事務)的,Transacation 就是給目標函數(shù)包裹一下,加上前置和后置的 hook,在開始執(zhí)行之前先執(zhí)行 initialize hook,結束之后再執(zhí)行 close hook,這樣搭配上 isBatchingUpdates 這樣的布爾標志位就可以實現(xiàn)目標函數(shù)調(diào)用棧內(nèi)的多次 setState() 全部入 pending 隊列,結束后統(tǒng)一 apply 了。

這里的 目標函數(shù) 指的是 React 控制的函數(shù),這樣的函數(shù)主要有兩類:React 合成事件 和 生命周期鉤子; 而 setTimeout 這樣的異步方法是脫離事務的,React 管控不到,所以就沒法對其中的 setState() 進行合并了。
我們結合下面的 Demo 來具體分析一下:
class App extends React.Component {
handleClick = () => {
this.setState({x: 1})
this.setState({x: 2})
this.setState({x: 3})
setTimeout(() => {
this.setState({x: 4})
this.setState({x: 5})
this.setState({x: 6})
}, 0)
}
render() {
return (<View>
<TouchableOpacity onPress={this.handleClick}>
<Text>更新組件</Text>
</TouchableOpacity>
</View>
}
}
1、handleClick 是 React 合成事件的回調(diào),React 有控制權,在開始執(zhí)行該函數(shù)的時候會將 isBatchingUpdates 設置為 true,所以 x 為 1、2、3 是合并的;
2、開始執(zhí)行 setTimeout,這里會將 setTimeout 的回調(diào)函數(shù)加入了事件循環(huán)的宏任務中,等待主線程完成所有任務后來進行調(diào)度;
3、handleClick 結束之后 isBatchingUpdates 被重新設置為 false;
4、此時主線程的函數(shù)已出棧,開始執(zhí)行 setTimeout 的回調(diào)函數(shù),由于 isBatchingUpdates 的值已經(jīng)變?yōu)榱?false,所以 x 為 4、5、6 沒有被合并更新,每一次的 setState() 都是同步執(zhí)行的;
5、總共觸發(fā)了 4 次組件渲染,其中有 2 次是冗余的。
總結為如下:
- 由 React 控制的事件處理程序、生命周期鉤子中的
setState()是異步的; - React 控制之外的事件中調(diào)用
setState()是同步的。比如網(wǎng)絡請求、setTimeout、setInterval、Promise 等; -
setState()的 “異步” 并不是說內(nèi)部由異步代碼實現(xiàn),其實本身執(zhí)行的過程和代碼都是同步的,只是合成事件和鉤子函數(shù)的調(diào)用順序在更新之前,導致在合成事件和鉤子函數(shù)中沒法立馬拿到更新后的值,形成了所謂的 “異步”。
由此可以看出 React 對于 setState() 的同步更新其實是迫于無奈,是 React 無法控制的。React 當然想目標函數(shù)中的 setState() 都是異步更新的,這樣性能也是最好的,能夠避免組件頻繁的更新渲染,但是條件不允許,React 辦不到。
那我們能不能在寫代碼的時候規(guī)避同步的 setState() 調(diào)用呢?這是不可能的,除非你的程序非常簡單且不需要跟后臺進行通信,只要你的程序要請求網(wǎng)絡接口,那么就會產(chǎn)生同步的 setState() 調(diào)用。那難道就沒有辦法對同步的 setState() 進行優(yōu)化,讓其合并更新嗎?
setState 手動合并(同步轉異步)
React 合成事件、生命周期鉤子 都在 React 的控制范圍內(nèi),所以它能夠?qū)⑺麄?strong>自動加入 React 事務中,讓其中的 setState() 合并更新。對于 React 無法控制的目標函數(shù),React 其實也有提供手動加入事務的 API,就是 unstable_batchedUpdates。
我們將上面 setTimeout 中的代碼做一下調(diào)整:
class App extends React.Component {
handleClick = () => {
this.setState({x: 1})
this.setState({x: 2})
this.setState({x: 3})
setTimeout(() => {
// 手動將目標函數(shù)加入 React 事務中,讓其合并更新
unstable_batchedUpdates(() => {
this.setState({x: 4})
this.setState({x: 5})
this.setState({x: 6})
})
}, 0)
}
render() {
return (<View>
<TouchableOpacity onPress={this.handleClick}>
<Text>更新組件</Text>
</TouchableOpacity>
</View>
}
}
x 為 1、2、3 在一個可控的目標函數(shù)中,是合并更新的;而 x 為 4、5、6 使用了 unstable_batchedUpdates 加入事務,也是合并更新的??偣灿?2 次更新,相較于之前的 4 次減少了 2 次。
unstable_batchedUpdates API 的原理如下:
function unstable_batchedUpdates(fn) {
this.isBatchingUpdates = true
fn()
this.isBatchingUpdates = false
const finalState = ... //通過this.updateQueue合并出finalState
this.setState(finaleState)
}
這個 API 在 React 和 React Native 中的引入方式有所不同:
-
react 中通過
react-dom進行引入
import { unstable_batchedUpdates } from "react-dom";
-
react-native 中則直接從
react-native庫中引入
import { unstable_batchedUpdates } from "react-native";
React 的這個 API 確實能夠?qū)⑼降?code>setState() 轉換為異步來進行合并更新,避免組件頻繁渲染。
但是根據(jù)其前綴 unstable 也可以看出來,這個 API 不是穩(wěn)定的。實際上這是 React 實驗性的 API 之一,并沒有全力推給到開發(fā)者去使用,所以如果不是特別影響性能,可以不用強制用這個 API 去合并 setState()。
setState 的隱藏 API
我們在使用 setState 時用的最多就是給它傳一個對象,像下面這樣:
this.setState({count: 1})
如果 setState 中的 count 需要依賴之前的值,你會怎么處理:
1、第一種方法:使用 setState 的第二個參數(shù)
this.setState({ count: this.state.count + 1 }, () => {
// 依賴當前 count 的值
this.setState({ count: this.state.count + 1 })
})
setState() 的第二個參數(shù)接收一個函數(shù),這個函數(shù)會在當前 setState() 更新完組件之后觸發(fā)。這種寫法有兩個缺陷:
- 破壞了 React 合并更新的優(yōu)化,會導致組件渲染兩次;
- 同時這種寫法會導致嵌套太深,很不美觀。
2、第二種方法:將 setState 轉為同步執(zhí)行
setTimeout(() => {
this.setState({ count: this.state. count + 1 })
this.setState({ count: this.state. count + 1 })
})
通過 setTimeout 能夠?qū)?setState() 轉為同步代碼,這樣就能夠立即獲取到最新的 this.state 值。這個方法不存在嵌套,但是和上面一樣,會導致組件渲染兩次。
3、終極方法:使用函數(shù)式的 setState
setState 其實有一個隱藏 API,第一個參數(shù)除了能夠接收對象之外,還能夠接收一個函數(shù)。這個函數(shù)接收先前的 state 作為參數(shù),同時返回本次需要變更的 state,如下:
this.setState((state) => {
return { count: state.count + 1 }
})
this.setState((state) => {
return { count: state.count + 1 }
})
函數(shù)式的 setState() 能夠保證第一個函數(shù)參數(shù)中的 state 是合并了之前所有狀態(tài)的,這樣后面的函數(shù)就能拿到前面函數(shù)執(zhí)行的結果。但是這個過程中并不會改變 this.state 的值,意思就是會等函數(shù)執(zhí)行完后才去進行渲染更新,所以組件只會渲染一次,沒有破壞 React 合并更新的優(yōu)化。
在同一個目標函數(shù)中不要混用函數(shù)式和對象式這兩種API
// 1
this.setState((state) => {
return { count: state.count + 1 }
})
// 2
this.setState({ count: this.state.count + 1 })
// 3
this.setState((state) => {
return { count: state.count + 1 }
})
1、假設一開始的 state.count 為 10
2、第一次執(zhí)行函數(shù)式 setState,count 為 11
3、第二次執(zhí)行對象式 setState,this.state 仍然是沒有更新的狀態(tài),所以 this.state.count 還是 10,加 1 以后又變回了 11
4、最后再執(zhí)行函數(shù)式 setState,回調(diào)函數(shù)中的 state.count 的值是第二步中的到的 11,這里再加 1,所以最終 count 的結果是 12。
可以發(fā)現(xiàn)第二個對象式 setState 將第一個函數(shù)式設置的 count 抹掉了,正確的做法是都調(diào)整為函數(shù)式的 setState,不然可能就會造成上面的問題。所以要避免函數(shù)式和對象式的 setState 混用,不然自己可能都會搞迷糊。
總結
在使用 React 作為開發(fā)框架的項目中,setState() 應該是我們接觸使用最多的 API,大家都習以為常的認為 setState() 是異步更新的,實際上有很多同步更新的場景被大家所忽略,從而忽視了對于 setState 也能進行性能優(yōu)化的場景。
文章提到的 setState 性能優(yōu)化主要包含兩方面:
- 適時地考慮使用
unstable_batchedUpdates來手動合并更新,解決 React 無法自動合并更新的場景。由于這個 API 不穩(wěn)定,所以未來可能會失效,但目前在 RN 0.64.2 及之前的版本中驗證還是可以使用的,暫時可以不用擔心; - 使用函數(shù)式的
setState()來更新那些依賴于當前的 state 的 state。
本文為原創(chuàng),轉載請注明出處