二葉姬稻荷神社・京都

深入 React Reconciliation Bailout 機制

CT Wu
The Practicalist
Published in
14 min readOct 18, 2016

--

在 React 內有一套 bailout reconciliation 機制,它的功用就跟 shouldComponentUpdate() 一樣,不同的是你不需要設定它,它就會自動判斷何時不需 update。shouldComponentUpdate() 已有許多文章介紹,我的前一篇文章也有稍微介紹過。這篇文章我會專注在介紹比較少被提到的自動 bailout 機制。

在開始之前

我建議先熟悉兩個觀念:

  1. React component、element 和 instance 的差異
  2. shouldComponentUpdate() 以及 immutable

另外,可以把 React 的 Implementation Note 放在手邊當作參考資料。

準備好了就讓我們開始吧!

React Internal Reconciliation Bailout

React 內部 bailout 的機制是寫在 ReactReconciler.js 這個檔案內,讓我們把相關的原始碼拿出來看

if (nextElement === prevElement &&
context === internalInstance._context
) {
// 不需 update subtree,bailout
return
}

由於有 context 的情況比較複雜,我們稍後再討論 context 的影響,讓我們先只看 element 的部分。

當 Element 相同時

首先,這篇文章裡提到的 element 都是指 React element,也就是 React 的 virtual DOM,不要與瀏覽器上的 DOM element 搞混了。

React 在 reconcile 前,會用 identity operator (===) 檢查這次準備要 update 的 element 和前一次 update 時的 element 是否相同,若相同則不 update subtree。

React 可以這樣 bailout 的前提是 element 是 immutable,也就是說用 mutate 的方式更新 element,可能會因為被 bailout 而沒更新到 UI:

const foo = <div>Hi</div>// 不要這麼做!
foo.children = 'Mutation!'

讓我來看看 reconciliaction bailout 的實際狀況。

🌰 1

React reconciliation bailout — Parent

最後一行的 count 是多少呢?

在 mount 階段,child 的 render() 被呼叫了 1 次(我用小寫字母代表 component 的 instance),這時 count 是 1。接這我們透過 parent.setState() 觸發 parent 被 update,然後在 Parent 的 render() 內我們沿用了 this.props.children,沒有創造新的 element,React 判斷可以 bailout,Child 沒有被 update,所以最後 count 仍然是 1。

簡化版 code 的解釋就是,在 mount 時,parent 的 props.children 就固定了:

this.props.children = <Child />

所以當 parent 開始 update 時,React 發現 nextElementprevElement 都是指到相同的 element,所以 bailout 條件成立:

// 因為
nextElement = this.props.children
prevElement = this.props.children
// 所以
nextElement === prevElement

讓我們接著來看沒有 bailout 的狀況。

當 Element 不同時

🌰 2

我們把 🌰 1 的 <Child /> 搬到 Parent 裡,然後把 Parent 改名叫 Owner:

React reconciliation bailout — Owner

這裡的 count 是多少呢?

我們一樣透過 owner.setState() 觸發 owner 被 update。和前一個 🌰 不同的是,這次我們是在 owner 的 render()createElement(Child),所以在 owner update 時也創造了新的 element。

雖然新的 element 內容沒有改變,但因為 element 的 reference 不同, React 沒有啟動 bailout 機制,所以 Child 也被 update 了,所以 count 是 2。

簡單地說,因為:

<Child /> !== <Child /> 

React 判斷不能 bailout reconciliation。

以上是在沒有 context 的環境下 React 的行為。接著,讓我們看看加入 context 之後 React 如何運作。先聲明,多數情況我都不建議使用 context;如果非得使用,請看完以下這段。無論如何,React 許多 library,例如 react-reduxreact-router 都有使用到 context,多了解 context 的運作方式能幫助我們挑選正確的 library。

加上 Context 後的 Bailout 機制

假設你還熟悉 React 的 context 功能,我先簡單介紹一下:我們要傳 data 到 subtree 的某節點,可利用 props 一層一層傳遞;但如果 subtree 太多層這件事就變得很繁瑣。這時 context 就派上用場了。我們可以把 context 想像成隱藏的 props,一旦我們宣告了 context,React 在背後幫我們把這個隱藏的 props 確實地一層層傳到 subtree 所有節點。宣告 context 的方式是宣告 Component 的 childContextTypes getChildContext();在 subtree 的 Component 我們可以用 contextTypes 取出 context。更多關於 context 的使用方式可在 React 文件內找到。

好,讓我們把之前的 🌰 加上 context 吧。

🌰 3

我們把 🌰 1 裡的 Parent 加上 childContextTypes getChildContext(),並改名叫 Provider:

React Reconciliation Bailout — Provider

猜猜看 count 是多少?

答案是 2,因為 update 前後兩次的 context 不同了,所以沒有 bailout。

context !==  internalInstance._context

internalInstance._context 其實就是上次 update 時的 context,如果改名叫 prevContext 會更好理解,這裡我刻意維持 React source code 裡的命名。

為什麼 Child 沒有用到 context,卻不能 bailout 呢?

這是因為 context 的運作方式是從 context 宣告的節點一路傳到 subtree 最底層,不管中間的節點有沒有使用,都必須忠實地把 context 傳給它的subtree。所以當 context 有變化時就不能 bailout,必須把 context 變化反應給 subtree。

如果只看 element 相同就 bailout,那 subtree 有可能永遠只會拿到 mount 時 context,不會發現 context 有更新:

<Provider>
<Middle>
<GrandChild />
</Middle>
</Provider>

如果 Middle 只看 element 相同就 bailout,GrandChild 將永遠不會發現 Provider 內 context 的變化 ,只會維持 mount 時的樣子。

能讓 Context 相同?

如果我們能讓 context 的 reference 不要改變,是否就能觸發 React bailout 呢?

🌰 4

React Reconciliation Bailout — Fixed Context Provider

我們把 context 的 reference 固定了,這樣子 count 會是多少?

答案是 2,Child 還是被 update 了。為什麼固定了 context 的 reference 之後,還是沒有 bailout 呢?

這是因為每次 update 時,React 都會用 Object.assign() 創造新的 context

const context = Object.assign({}, parentContext, this.getChildContext())

這麼做是為了避免 subtree 的 context 污染的上層的 context

<Provider1>
<Child1 /> // 只能看到 Provider1 的 context
<Provider2>
<Child2 /> // 能同時看到 Provider1 和 Provider2 的 context
<Provider2>
</Provider1>

在 Child1 內,只應該看到 Provider1 的 context;在 Child2 內可以看到 Provider1 和 Proverider2 兩者的 context。因此,不管我們在 getChildContext 內提供的 context 是不是同一個 object,React update 時都必須透過 Object.assign() 創造新的 object。

也就是說,我們不應該在 context provider 內觸發 React 的 update,否則整組 subtree 的 bailout 機制都會壞掉。

一個 component 內不應該同時出現 setState 和 getChildContext。

以下是同時使用 setState()getChildContext()錯誤示範。假設我們要做一個計時相關的應用,在 substree 許多地方需要知道 mount 之後過了幾秒,所以我們做了一個 BadProvider 幫我們計時,並利用 context 把經過的秒數傳給 subtree:

class BadProvider extends React.Component {
state = {
time: 0,
}

getChildContext() {
return {
time: this.state.time
}
}
componentDidMount() {
setInterval(() => {
this.setState({
time: this.state + 1,
})
}, 1000)
}
// ...

render() {
// ...
}
}

在 BadProvider 中,每秒我們都會呼叫 setState() 一次來 update 整個 tree,並且把新的 context 傳給 substree。在 subtree 裡,因為 update 時都拿到新的 context,所以永遠不會 bailout。這裡需特別注意,即使我們在 getChildContext() 內 return 相同 object reference,在 subtree 內依然不會 bailout,因為 React 永遠會傳給 subtree 新的 context object。

如果你有在用 redux 的話,你會發現 react-redux 剛好有避開在同一個 Component 內同時使用 setState()getChildContext()

  • <Provider /> 內用 getChildContext 把 store 塞進 context;
  • connect() 內 subscribe store,當 store 有更新時呼叫 setState()。

萬惡的 Mutable

為什麼 React 不在 Object.assign() 前先檢查 getChildContext() 和 parentContext 的 reference 是不是有更新,再決定要不要創造新的 context 呢?

if (prevParentContext === nextParentContext
&& prevComponent.getChildContext() === this.getChildContext()
){
// 不要 create 新的 context
}

React 不能這麼做是因為 React 不能確保開發者沒有用 mutable 的方式更新 context。

有無 Bailout 效能比較

在下面這個 JSFiddle 中,我創造 200 個節點,並分別使用兩種 container components 各用 setState() 更新 100 次。兩個 container components 的唯一差別就是一個有 context 一個沒有,藉此我們可以比較有無 bailout 的效能差異。

打開瀏覽器的 console 就可以看到實驗結果,歡迎在你的電腦上試試。

以下是我在我的 MacBook Pro (Retina, Mid 2012) 上的 Chrome 54.0.2840.59 (64-bit) 實驗結果:

React Reconciliation Bailout 效能實驗

有 bailout 比沒有 bailout 少花了 47% 的時間!

因為 React 有些 update 是 async,所以我把 sync 和 async 分開計算,不過在這個實驗中兩者差異不重要,我們只要看 sum 就好。這實驗使用的是 production 版本的 React,是因為這樣才能比較接近真實產品的情況。

使用 Babel Plugin 增加 Bailout 觸發機會

在 React 0.14 釋出時,Babel 也同時釋出了一個 plugin:React constant elements transformer。這個 plugin 除了幫我們減少 createElement() 的呼叫次數之外,能幫我們提升 bailout reconciliation 的機會。

基本上,這個 React constant elements transformer 是幫我們把 render() 內 create 的element 搬移至 render() 外。

Babel 編譯前

const Hr = () => {
return <hr className="hr" />
}

Babel 編譯後

const _ref = <hr className="hr" />const Hr = () => {
return _ref
}

React constant elements transformer 的文件內有提到當 ref 或是 object rest spread (...object) 時會放棄優化 (deopt),但文件內沒提到其實只要出現任何變數或是非 primitive type (string, number, boolean, undefined, etc)的 inline prop 就會放棄優化,例如以下這種情況:

<div style={{ width: 100 }} />
<div className={className} />

這是因為 JavaScript 的 object、array 都是 mutable,如果我們在程式內其他地方 mutate 這些 prop,Babel 如果優化這些 element 導致編譯前後行為不一致。

Babel 編譯前

render() {
return <div style={{ width: 100 }} />
}
render().props.style.width = 87 // mutate
console.log(render().props.style.width) // 100

Babel 編譯後

const _ref = <div style={{ width: 100 }} />
render() {
return _ref
}
render().props.style.width = 87 // mutate
console.log(render().props.style.width) // 87

React constant elements transformer 目前有一些 bugs,使用時請留意。相關 bugs 可在這個 issue 追蹤。

後記

如果你是 App 開發者,其實你只要會用 shouldComponentUpdate() 並且避免使用 context 就可以了。但是如果你想開發 React 的 library,多了解 React 底層實作能讓你對 code 掌握度更高,防止抽象滲漏

由於 JavaScript 先天沒有 immutable 的缺陷,導致許多效能優化變得不好做。或許未來 immutable data structure 有機會被列入 ECMAScript 規範中,在此之前我們就只能忍耐了,或是轉戰其他有資源 immutable 的語言。

這篇文章的內容是 React 目前(React@15)的實作,React 團隊正在重寫新的 React reconciler:Fiber。如果新的 reconciler 有改變這篇文章提到的 React 內部 bailout 機制,我會再寫篇新的文章,並更新連結在這。

最後,如果這篇文有任何錯誤或是你有任何想法,請留言告訴我。如果你覺得這篇文章不錯,請給我一顆 💚 讓我知道,我會寫更多類似的題目。

感謝 Tom ChenRay ShihMichael Hsu 幫我 review 這篇文章,並提供許多建議。

--

--