React 中 setState() 為什麼是非同步的?

哆啦P股發表於2018-01-30

前言

不知道大家有沒有過這個疑問,React 中 setState() 為什麼是非同步的?我一度認為 setState() 是同步的,知道它是非同步的之後很是困惑,甚至期待 React 能出一個 setStateSync() 之類的 API。同樣有此疑問的還有 MobX 的作者 Michel Weststrate,他認為經常聽到的答案都很容易反駁,並認為這可能是一個歷史包袱,所以開了一個 issue 詢問真正的原因。最終這個 issue 得到了 React 核心成員 Dan Abramov 的回覆,Dan 的回覆表明這不是一個歷史包袱,而是一個經過深思熟慮的設計。

注意:這篇文章根據 Dan 的回覆寫成,但不是一篇翻譯。我忽略了很多不太重要的內容,Dan 的完整回覆請看這裡

正文

Dan 在回覆中表示為什麼 setState() 是非同步的,這並沒有一個明顯的答案(obvious answer),每種方案都有它的權衡。但是 React 的設計有以下幾點考量:

一、保證內部的一致性

首先,我想我們都同意推遲並批量處理重渲染是有益而且對效能優化很重要的,無論 setState() 是同步的還是非同步的。那麼就算讓 state 同步更新,props 也不行,因為當父元件重渲染(re-render )了你才知道 props

現在的設計保證了 React 提供的 objects(state,props,refs)的行為和表現都是一致的。為什麼這很重要?Dan 舉了個栗子:

假設 state 是同步更新的,那麼下面的程式碼是可以按預期工作的:

console.log(this.state.value) // 0
this.setState({ value: this.state.value + 1 });
console.log(this.state.value) // 1
this.setState({ value: this.state.value + 1 });
console.log(this.state.value) // 2
複製程式碼

然而,這時你需要將狀態提升到父元件,以供多個兄弟元件共享:

-this.setState({ value: this.state.value + 1 });
+this.props.onIncrement(); // 在父元件中做同樣的事
複製程式碼

需要指出的是,在 React 應用中這是一個很常見的重構,幾乎每天都會發生。

然而下面的程式碼卻不能按預期工作:

console.log(this.props.value) // 0
this.props.onIncrement();
console.log(this.props.value) // 0
this.props.onIncrement();
console.log(this.props.value) // 0
複製程式碼

這是因為同步模型中,雖然 this.state 會立即更新,但是 this.props 並不會。而且在沒有重渲染父元件的情況下,我們不能立即更新 this.props。如果要立即更新 this.props (也就是立即重渲染父元件),就必須放棄批處理(根據情況的不同,效能可能會有顯著的下降)。

所以為了解決這樣的問題,在 React 中 this.statethis.props 都是非同步更新的,在上面的例子中重構前跟重構後都會列印出 0。這會讓狀態提升更安全。

最後 Dan 總結說,React 模型更願意保證內部的一致性和狀態提升的安全性,而不總是追求程式碼的簡潔性。

二、效能優化

我們通常認為狀態更新會按照既定順序被應用,無論 state 是同步更新還是非同步更新。然而事實並不一定如此。

React 會依據不同的呼叫源,給不同的 setState() 呼叫分配不同的優先順序。呼叫源包括事件處理、網路請求、動畫等。

Dan 又舉了個栗子。假設你在一個聊天視窗,你正在輸入訊息,TextBox 元件中的 setState() 呼叫需要被立即應用。然而,在你輸入過程中又收到了一條新訊息。更好的處理方式或許是延遲渲染新的 MessageBubble 元件,從而讓你的輸入更加順暢,而不是立即渲染新的 MessageBubble 元件阻塞執行緒,導致你輸入抖動和延遲。

如果給某些更新分配低優先順序,那麼就可以把它們的渲染分拆為幾個毫秒的塊,使用者也不會注意到。

三、更多的可能性

Dan 最後說到,非同步更新並不只關於效能優化,而是 React 元件模型能做什麼的一個根本性轉變(fundamental shift)。

Dan 還是舉了個栗子。假設你從一個頁面導航到到另一個頁面,通常你需要展示一個載入動畫,等待新頁面的渲染。但是如果導航非常快,閃爍一下載入動畫又會降低使用者體驗。

如果這樣會不會好點,你只需要簡單的呼叫 setState() 去渲染一個新的頁面,React “在幕後”開始渲染這個新的頁面。想象一下,不需要你寫任何的協調程式碼,如果這個更新花了比較長的時間,你可以展示一個載入動畫,否則在新頁面準備好後,讓 React 執行一個無縫的切換。此外,在等待過程中,舊的頁面依然可以互動,但是如果花費的時間比較長,你必須展示一個載入動畫。

事實證明,在現在的 React 模型基礎上做一些生命週期調整,真的可以實現這種設想。@acdlite 已經為這個功能努力幾周了,並且很快會釋出一個 RFC(亦可賽艇!)。

需要注意的是,非同步更新 state 是有可能實現這種設想的前提。如果同步更新 state 就沒有辦法在幕後渲染新的頁面,還保持舊的頁面可以互動。它們之間獨立的狀態更新會衝突。

Dan 最後對 Michel 說到:我希望我們能在接下來幾個月說服你,並且你會欣賞到 React 模型的靈活性。據我理解,這種靈活性至少一部分要歸功於 state 的非同步更新。

相關文章