React 原始碼剖析系列 - 解密 setState

undefined_segmentfault發表於2015-11-09

this.setState() 方法應該是每一位使用 React 的同學最先熟悉的 API。然而,你真的瞭解 setState 麼?先看看下面這個小問題,你能否正確回答。

引子

 

問上述程式碼中 4 次 console.log 列印出來的 val 分別是多少?

不賣關子,先揭曉答案,4 次 log 的值分別是:0、0、2、3。

若結果和你心中的答案不完全相同,那下面的內容你可能會感興趣。

同樣的 setState 呼叫,為何表現和結果卻大相徑庭呢?讓我們先看看 setState 到底幹了什麼。

setState 幹了什麼

上面這個流程圖是一個簡化的 setState 呼叫棧,注意其中核心的狀態判斷,在原始碼(ReactUpdates.js)

 

若 isBatchingUpdates 為 true,則把當前元件(即呼叫了 setState 的元件)放入 dirtyComponents 陣列中;否則 batchUpdate 所有佇列中的更新。先不管這個 batchingStrategy,看到這裡大家應該已經大概猜出來了,文章一開始的例子中 4 次 setState 呼叫表現之所以不同,這裡邏輯判斷起了關鍵作用。

那麼 batchingStrategy 究竟是何方神聖呢?其實它只是一個簡單的物件,定義了一個 isBatchingUpdates 的布林值,和一個 batchedUpdates 方法。下面是一段簡化的定義程式碼:

 

注意 batchingStrategy 中的 batchedUpdates 方法中,有一個 transaction.perform 呼叫。這就引出了本文要介紹的核心概念 —— Transaction(事務)。

初識 Transaction

熟悉 MySQL 的同學看到 Transaction 是否會心一笑?然而在 React 中 Transaction 的原理和行為和 MySQL 中並不完全相同,讓我們從原始碼開始一步步開始瞭解。

在 Transaction 的原始碼中有一幅特別的 ASCII 圖,形象的解釋了 Transaction 的作用。

 

簡單地說,一個所謂的 Transaction 就是將需要執行的 method 使用 wrapper 封裝起來,再通過 Transaction 提供的 perform 方法執行。而在 perform 之前,先執行所有 wrapper 中的 initialize 方法;perform 完成之後(即 method 執行後)再執行所有的 close 方法。一組 initialize 及 close 方法稱為一個 wrapper,從上面的示例圖中可以看出 Transaction 支援多個 wrapper 疊加。

具體到實現上,React 中的 Transaction 提供了一個 Mixin 方便其它模組實現自己需要的事務。而要使用 Transaction 的模組,除了需要把 Transaction 的 Mixin 混入自己的事務實現中外,還需要額外實現一個抽象的 getTransactionWrappers 介面。這個介面是 Transaction 用來獲取所有需要封裝的前置方法(initialize)和收尾方法(close)的,因此它需要返回一個陣列的物件,每個物件分別有 key 為 initialize 和 close 的方法。

下面是一個簡單使用 Transaction 的例子

 

當然在實際程式碼中 React 還做了異常處理等工作,這裡不詳細展開。有興趣的同學可以參考原始碼中 Transaction 實現。

說了這麼多 Transaction,它到底是怎麼導致上文所述 setState 的各種不同表現的呢?

解密 setState

那麼 Transaction 跟 setState 的不同表現有什麼關係呢?首先我們把 4 次 setState 簡單歸類,前兩次屬於一類,因為他們在同一次呼叫棧中執行;setTimeout 中的兩次 setState 屬於另一類,原因同上。讓我們分別看看這兩類 setState 的呼叫棧:

componentDidMout 中 setState 的呼叫棧

setTimeout 中 setState 的呼叫棧

很明顯,在 componentDidMount 中直接呼叫的兩次 setState,其呼叫棧更加複雜;而 setTimeout 中呼叫的兩次 setState,呼叫棧則簡單很多。讓我們重點看看第一類 setState 的呼叫棧,有沒有發現什麼熟悉的身影?沒錯,就是batchedUpdates 方法,原來早在 setState 呼叫前,已經處於 batchedUpdates 執行的 transaction 中!

那這次 batchedUpdate 方法,又是誰呼叫的呢?讓我們往前再追溯一層,原來是 ReactMount.js 中的_renderNewRootComponent 方法。也就是說,整個將 React 元件渲染到 DOM 中的過程就處於一個大的 Transaction 中。

接下來的解釋就順理成章了,因為在 componentDidMount 中呼叫 setState 時,batchingStrategy 的 isBatchingUpdates 已經被設為 true,所以兩次 setState 的結果並沒有立即生效,而是被放進了 dirtyComponents 中。這也解釋了兩次列印this.state.val 都是 0 的原因,新的 state 還沒有被應用到元件中。

再反觀 setTimeout 中的兩次 setState,因為沒有前置的 batchedUpdate 呼叫,所以 batchingStrategy 的 isBatchingUpdates 標誌位是 false,也就導致了新的 state 馬上生效,沒有走到 dirtyComponents 分支。也就是,setTimeout 中第一次 setState 時,this.state.val 為 1,而 setState 完成後列印時 this.state.val 變成了 2。第二次 setState 同理。

擴充套件閱讀

在上文介紹 Transaction 時也提到了其在 React 原始碼中的多處應用,想必除錯過 React 原始碼的同學應該能經常見到它的身影,像 initialize、perform、close、closeAll、notifyAll 等方法出現在呼叫棧裡時,都說明當前處於一個 Transaction 中。

既然 Transaction 這麼有用,我們自己的程式碼中能使用 Transaction 嗎?很可惜,答案是不能。不過針對文章一開始例子中 setTimeout 裡的兩次 setState 導致兩次 render 的情況,React 偷偷給我們暴露了一個 batchedUpdates 方法,方便我們呼叫。

當然因為這個不是公開的 API,後續存在廢棄的風險,大家在業務系統裡慎用喲!

註釋

  1. test-react 文中測試程式碼已放在 Github 上,需要自己實驗探索的同學可以 clone 下來自己斷點除錯。
  2. 為了避免引入更多的概念,上文中所說到的 batchingStrategy 均指 ReactDefaultBatchingStrategy,該 strategy 在 React 初始化時由 ReactDefaultInjection 注入到 ReactUpdates 中作為預設的 strategy。在 server 渲染時,則會注入不同的 strategy,有興趣的同學請自行探索。

相關文章