在上個月初的時候,偶然在IThelp看到這篇討論 setState後畫面沒有立即Render,決定趁自己有空的時候把相關的概念搞清楚。
以下內容是自己參考多份官方文件後的整理,如果有想法或是有錯誤都歡迎留言與我討論。
本系列文章一共分上下兩篇。上篇會先從React的機制來探討如果setState是同步/非同步會發生什麼事,下篇會統整setState在什麼時候是同步/非同步,以及該如何正確的取得setState後的新state值。如果是剛入門,想先跳過底層原理解釋的朋友可以直接看下篇。
首先,我們先假設setState絕對會是同步的(呼叫setState後state會馬上被改變)。
在React中,我們很常都會透過setState去更新state和props,藉此觸發React的更新機制(reconciliation),比較Virtual DOM後,再去渲染畫面。然而React開發者在處理setState和判斷元件更新的關係時,一些效能問題出現了。我們來看看這個例子:
下方程式碼中,Parent引入了Child。當Child被點擊時,由於event bubbling,其父層Parent中的div也會觸發onClick
事件。
import { Component } from 'react';
class Child extends Component {
constructor(props) {
super(props);
this.state = { count: 0 };
}
render() {
return (
<button
onClick={() => this.setState({ count: this.state.count + 1 })}
>
Child clicked {this.state.count} times
</button>
);
}
}
export default class Parent extends Component {
constructor(props) {
super(props);
this.state = { count: 0 };
}
render() {
return (
<div onClick={() => this.setState({ count: this.state.count + 1 })}>
Parent clicked {this.state.count} times
<Child />
</div>
);
}
}
直覺上來說,假設setState當下state就會被改變,我們預期的更新流程應該如下:
觀察上述流程,我們會發現階段4產生的re-render是不必要的,因為在最後階段時Child又再re-render了一次。
也就是上述範例在React中實際的更新流程如下
註: 什麼是 SyntheticEvent(合成事件)?
在React中,我們幾乎都會透過以JSX或是React.createElement呈現的html element上的onClick、onChange這些屬性進行事件處理、讓使用者的行為觸發呼叫setState函式、更新元件。而這些屬性和原生利用
addEventListener
、onclick
做事件處理不同的地方在,React針對自己的需求,在事件發生、呼叫handler到更新元件的過程中,多進行了某些加工。一個很明顯的例子是在使用ReactDOM.createPortal時,雖然實際渲染在DOM上的元素和原本JSX中的巢狀結構不再有父子關係,但React仍然會將event bubbling實作回JSX中引入其引入的父層元素中。這種在React中的事件處理稱為SyntheticEvent(合成事件)。
import { createPortal } from 'react-dom'; function Child() { return createPortal( <button>ChildReact</button>, document.getElementById('portal-react') ); } export default function Parent() { return ( <div onClick={() => console.log('Parent被點擊了')}> <Child /> </div> ); }
batching機制也同時避免了在同一次事件中大量呼叫setState所造成的資源浪費。例如,在下方的程式碼中,我們定義當Child中button被點擊時,在Parent中觸發三次setState。觀察實際執行結果,會發現React並不會在呼叫一次setState後就馬上去根據state新的值去更新DOM,而是根據所有setState依序執行後的state值去更新一次元件,所以「我被更新了」只會印出一次。
import { Component } from 'react';
class Child extends Component {
constructor(props) {
super(props);
}
render() {
return (
<div>
<p>parent count is {this.props.count}</p>
<button onClick={this.props.handleClick}>add Count</button>
</div>
);
}
}
export default class Parent extends Component {
constructor(props) {
super(props);
this.state = { count: 0 };
}
addCount = () => {
let currentCount = this.state.count;
for (let i = 0; i < 3; ++i) {
currentCount++;
this.setState({ count: currentCount });
}
};
render() {
console.log("我被更新了");
return (
<div>
<Child handleClick={this.addCount} />
</div>
);
}
}
參考資料: https://overreacted.io/react-as-a-ui-runtime/#batching
雖然batching可能是個讓setState需要具有非同步特性的原因,然而其實只要不讓元件re-render,立即更新state也可以做到batching。我們試著來把剛剛的流程改這樣:
看起來很完美,對吧?
然而這個時候,另一個問題又出現了: props。
由於React只有在父元件被re-render後,子元件才能知道其父元件賦予自己的props值,所以如果props也要隨著state的改變而同時被改變,那state改變後該元件就應該要馬上被re-render,卻也導致沒辦法進行batching了。這會帶來相當大的資源浪費,所以,無論如何,props的改變仍然是非同步的。
那麼這會帶來什麼問題呢? 來看看以下這個範例,Child負責顯示Parent的count數量,也能在Child連續兩次增加Parent中的count值:
import { Component } from 'react';
class Child extends Component {
constructor(props) {
super(props);
}
handleClick = () => {
this.props.handleClick();
console.log(this.props.count);
this.props.handleClick();
console.log(this.props.count);
};
render() {
return (
<div>
<p>parent count is {this.props.count}</p>
<button onClick={this.handleClick}>add Count 2 times</button>
</div>
);
}
}
export default class Parent extends Component {
constructor(props) {
super(props);
this.state = { count: 0 };
}
addCount = () => {
this.setState({ count: this.state.count +1 });
console.log(this.state.count);
};
render() {
console.log('更新了');
return (
<div>
<Child props={this.state.count} handleClick={this.addCount} />
</div>
);
}
}
依照我們剛剛制定state更新是同步、props更新是非同步的規則,當點擊Child中的button時,會產生的流程如下:
注意到了嗎? 明明Child的props.count綁定了Parent的state.count,但是在這個範例中,同一時間點得到的值會是不同的。由於這樣的不一致性容易造成開發者的困擾,所以,React團隊決定batching機制下,藉由讓setState具有非同步特性,得以使被綁定的state和props之間能保持一致。
到這邊,我們可以知道React setState之所以要是非同步的原因之一是batching和其延伸問題。另外還有一個原因是如果setState是非同步,React團隊會更方便實現在React 16後推出的React Fiber(非同步渲染機制)。和本文中提及一次處理所有元件的更新不同,雖然batching機制依然存在,但React Fiber將更新流程拆個多個片段,這樣「將原本一大包的更新拆成片段」的做法能夠讓瀏覽器在片段之間處理其他工作,解決了過去React在更新時偶而會發生的掉偵、卡頓問題。
而如果setState是非同步的,在呼叫setState後,React就能先透過一套演算法重新算出該更新任務的優先權,根據優先權再去決定該更新任務適合在哪個片段中執行。這部份細節自己暫時沒時間研究,就不多做介紹了。
在下一篇中,我們會整理前面提及setState是非同步的時機點,以及如何正確的取得setState後的新state值。
最後偷偷廣告一下,自己在11屆和12屆鐵人賽的React.js系列文修訂後和深智數位合作,最近在天瓏開始預購了,想學React的朋友可以參考看看:
https://www.tenlong.com.tw/products/9789860776188?list_name=srh
參考資料: https://github.com/facebook/react/issues/11527#issuecomment-360199710
最後 props 範例的 code,好像有寫錯:this.setState({ count: count +1 });
=> this.setState({ count: this.state.count +1 });
<Child props={this.count} handleClick={this.addCount} />
=> <Child count={this.state.count} handleClick={this.addCount} />
感謝你,在修改的時候沒注意到QQ 已更正