React 性能優(yōu)化技巧總結(jié)
本文將從 render 函數(shù)的角度總結(jié) React App 的優(yōu)化技巧。需要提醒的是,文中將涉及 React 16.8.2 版本的內(nèi)容(也即 Hooks),因此請(qǐng)至少了解 useState 以保證食用效果。
正文開(kāi)始。
當(dāng)我們討論 React App 的性能問(wèn)題時(shí),組件的 渲染 速度是一個(gè)重要問(wèn)題。在進(jìn)入到具體優(yōu)化建議之前,我們先要理解以下 3 點(diǎn):
- 當(dāng)我們?cè)谡f(shuō)「render」時(shí),我們?cè)谡f(shuō)什么?
- 什么時(shí)候會(huì)執(zhí)行「render」?
- 在「render」過(guò)程中會(huì)發(fā)生什么?
解讀 render 函數(shù)
這部分涉及 reconciliation 和 diffing 的概念,當(dāng)然官方文檔在 這里 。
當(dāng)我們?cè)谡f(shuō)「render」時(shí),我們?cè)谡f(shuō)什么?
這個(gè)問(wèn)題其實(shí)寫(xiě)過(guò) React 的人都會(huì)知道,這里再簡(jiǎn)單說(shuō)下:
在 class 組件中,我們指的是 render 方法:
- class Foo extends React.Component {
- render() {
- return <h1> Foo </h1>;
- }
- }
在函數(shù)式組件中,我們指的是函數(shù)組件本身:
- function Foo() {
- return <h1> Foo </h1>;
- }
什么時(shí)候會(huì)執(zhí)行「render」?
render 函數(shù)會(huì)在兩種場(chǎng)景下被調(diào)用:
1. 狀態(tài)更新時(shí)
a. 繼承自 React.Component 的 class 組件更新?tīng)顟B(tài)時(shí)
- import React from "react";
- import ReactDOM from "react-dom";
- class App extends React.Component {
- render() {
- return <Foo />;
- }
- }
- class Foo extends React.Component {
- state = { count: 0 };
- increment = () => {
- const { count } = this.state;
- const newCount = count < 10 ? count + 1 : count;
- this.setState({ count: newCount });
- };
- render() {
- const { count } = this.state;
- console.log("Foo render");
- return (
- <div>
- <h1> {count} </h1>
- <button onClick={this.increment}>Increment</button>
- </div>
- );
- }
- }
- const rootElement = document.getElementById("root");
- ReactDOM.render(<App />, rootElement);
可以看到,代碼中的邏輯是我們點(diǎn)擊就會(huì)更新 count,到 10 以后,就會(huì)維持在 10。增加一個(gè) console.log,這樣我們就可以知道 render 是否被調(diào)用了。從執(zhí)行結(jié)果可以知道,即使 count 到了 10 以上,render 仍然會(huì)被調(diào)用。
總結(jié):繼承了 React.Component 的 class 組件,即使?fàn)顟B(tài)沒(méi)變化,只要調(diào)用了setState 就會(huì)觸發(fā) render。
b. 函數(shù)式組件更新?tīng)顟B(tài)時(shí)
我們用函數(shù)實(shí)現(xiàn)相同的組件,當(dāng)然因?yàn)橐袪顟B(tài),我們用上了 useState hook:
- import React, { useState } from "react";
- import ReactDOM from "react-dom";
- class App extends React.Component {
- render() {
- return <Foo />;
- }
- }
- function Foo() {
- const [count, setCount] = useState(0);
- function increment() {
- const newCount = count < 10 ? count + 1 : count;
- setCount(newCount);
- }
- console.log("Foo render");
- return (
- <div>
- <h1> {count} </h1>
- <button onClick={increment}>Increment</button>
- </div>
- );
- }
- const rootElement = document.getElementById("root");
- ReactDOM.render(<App />, rootElement);
我們可以注意到,當(dāng)狀態(tài)值不再改變之后,render 的調(diào)用就停止了。
總結(jié):對(duì)函數(shù)式組件來(lái)說(shuō),狀態(tài)值改變時(shí)才會(huì)觸發(fā) render 函數(shù)的調(diào)用。
2. 父容器重新渲染時(shí)
- import React from "react";
- import ReactDOM from "react-dom";
- class App extends React.Component {
- state = { name: "App" };
- render() {
- return (
- <div className="App">
- <Foo />
- <button onClick={() => this.setState({ name: "App" })}>
- Change name
- </button>
- </div>
- );
- }
- }
- function Foo() {
- console.log("Foo render");
- return (
- <div>
- <h1> Foo </h1>
- </div>
- );
- }
- const rootElement = document.getElementById("root");
- ReactDOM.render(<App />, rootElement);
只要點(diǎn)擊了 App 組件內(nèi)的 Change name 按鈕,就會(huì)重新 render。而且可以注意到,不管 Foo 具體實(shí)現(xiàn)是什么,F(xiàn)oo 都會(huì)被重新渲染。
總結(jié):無(wú)論組件是繼承自 React.Component 的 class 組件還是函數(shù)式組件,一旦父容器重新 render,組件的 render 都會(huì)再次被調(diào)用。
在「render」過(guò)程中會(huì)發(fā)生什么?
只要 render 函數(shù)被調(diào)用,就會(huì)有兩個(gè)步驟按順序執(zhí)行。這兩個(gè)步驟非常重要,理解了它們才好知道如何去優(yōu)化 React App。
Diffing
在此步驟中,React 將新調(diào)用的 render 函數(shù)返回的樹(shù)與舊版本的樹(shù)進(jìn)行比較,這一步是 React 決定如何更新 DOM 的必要步驟。雖然 React 使用高度優(yōu)化的算法執(zhí)行此步驟,但仍然有一定的性能開(kāi)銷(xiāo)。
Reconciliation
基于 diffing 的結(jié)果,React 更新 DOM 樹(shù)。這一步因?yàn)樾枰遁d和掛載 DOM 節(jié)點(diǎn)同樣存在許多性能開(kāi)銷(xiāo)。
開(kāi)始我們的 Tips
Tip #1:謹(jǐn)慎分配 state 以避免不必要的 render 調(diào)用
我們以下面為例,其中 App 會(huì)渲染兩個(gè)組件:
- CounterLabel
- List
- import React, { useState } from "react";
- import ReactDOM from "react-dom";
- const ITEMS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
- function App() {
- const [count, setCount] = useState(0);
- const [items, setItems] = useState(ITEMS);
- return (
- <div className="App">
- <CounterLabel count={count} increment={() => setCount(count + 1)} />
- <List items={items} />
- </div>
- );
- }
- function CounterLabel({ count, increment }) {
- return (
- <>
- <h1>{count} </h1>
- <button onClick={increment}> Increment </button>
- </>
- );
- }
- function List({ items }) {
- console.log("List render");
- return (
- <ul>
- {items.map((item, index) => (
- <li key={index}>{item} </li>
- ))}
- </ul>
- );
- }
- const rootElement = document.getElementById("root");
- ReactDOM.render(<App />, rootElement);
執(zhí)行上面代碼可知,只要父組件 App 中的狀態(tài)被更新, CounterLabel 和 List 就都會(huì)更新。
當(dāng)然, CounterLabel 重新渲染是正常的,因?yàn)?count 發(fā)生了變化,自然要重新渲染;但是對(duì)于 List 而言,就完全是不必要的更新了,因?yàn)樗匿秩九c count 無(wú)關(guān)。 盡管 React 并不會(huì)在 reconciliation 階段真的更新 DOM,畢竟完全沒(méi)變化,但是仍然會(huì)執(zhí)行 diffing 階段來(lái)對(duì)前后的樹(shù)進(jìn)行對(duì)比,這仍然存在性能開(kāi)銷(xiāo)。
還記得 render 執(zhí)行過(guò)程中的 diffing 和 reconciliation 階段嗎?前面講過(guò)的東西在這里碰到了。
因此,為了避免不必要的 diffing 開(kāi)銷(xiāo),我們應(yīng)當(dāng)考慮將特定的狀態(tài)值放到更低的層級(jí)或組件中(與 React 中所說(shuō)的「提升」概念剛好相反)。在這個(gè)例子中,我們可以通過(guò)將 count 放到 CounterLabel 組件中管理來(lái)解決這個(gè)問(wèn)題。
Tip #2:合并狀態(tài)更新
因?yàn)槊看螤顟B(tài)更新都會(huì)觸發(fā)新的 render 調(diào)用,那么更少的狀態(tài)更新也就可以更少的調(diào)用 render 了。
我們知道,React class 組件有 componentDidUpdate(prevProps, prevState) 的鉤子,可以用來(lái)檢測(cè) props 或 state 有沒(méi)有發(fā)生變化。盡管有時(shí)有必要在 props 發(fā)生變化時(shí)再觸發(fā) state 更新,但我們總可以避免在一次 state 變化后再進(jìn)行一次 state 更新這種操作:
- import React from "react";
- import ReactDOM from "react-dom";
- function getRange(limit) {
- let range = [];
- for (let i = 0; i < limit; i++) {
- range.push(i);
- }
- return range;
- }
- class App extends React.Component {
- state = {
- numbers: getRange(7),
- limit: 7
- };
- handleLimitChange = e => {
- const limit = e.target.value;
- const limitChanged = limit !== this.state.limit;
- if (limitChanged) {
- this.setState({ limit });
- }
- };
- componentDidUpdate(prevProps, prevState) {
- const limitChanged = prevState.limit !== this.state.limit;
- if (limitChanged) {
- this.setState({ numbers: getRange(this.state.limit) });
- }
- }
- render() {
- return (
- <div>
- <input
- onChange={this.handleLimitChange}
- placeholder="limit"
- value={this.state.limit}
- />
- {this.state.numbers.map((number, idx) => (
- <p key={idx}>{number} </p>
- ))}
- </div>
- );
- }
- }
- const rootElement = document.getElementById("root");
- ReactDOM.render(<App />, rootElement);
這里渲染了一個(gè)范圍數(shù)字序列,即范圍為 0 到 limit。只要用戶改變了 limit 值,我們就會(huì)在 componentDidUpdate 中進(jìn)行檢測(cè),并設(shè)定新的數(shù)字列表。
毫無(wú)疑問(wèn),上面的代碼是可以滿足需求的,但是,我們?nèi)匀豢梢赃M(jìn)行優(yōu)化。
上面的代碼中,每次 limit 發(fā)生改變,我們都會(huì)觸發(fā)兩次狀態(tài)更新:***次是為了修改 limit,第二次是為了修改展示的數(shù)字列表。這樣一來(lái),每次 limit 的變化會(huì)帶來(lái)兩次 render 開(kāi)銷(xiāo):
- // 初始狀態(tài)
- { limit: 7, numbers: [0, 1, 2, 3, 4, 5, 6]
- // 更新 limit -> 4
- render 1: { limit: 4, numbers: [0, 1, 2, 3, 4, 5, 6] } //
- render 2: { limit: 4, numbers: [0, 2, 3]
我們的代碼邏輯帶來(lái)了下面的問(wèn)題:
- 我們觸發(fā)了比實(shí)際需要更多的狀態(tài)更新;
- 我們出現(xiàn)了「不連續(xù)」的渲染結(jié)果,即數(shù)字列表與 limit 不匹配。
為了改進(jìn),我們應(yīng)避免在不同的狀態(tài)更新中改變數(shù)字列表。事實(shí)上,我們可以在一次狀態(tài)更新中搞定:
- import React from "react";
- import ReactDOM from "react-dom";
- function getRange(limit) {
- let range = [];
- for (let i = 0; i < limit; i++) {
- range.push(i);
- }
- return range;
- }
- class App extends React.Component {
- state = {
- numbers: [1, 2, 3, 4, 5, 6],
- limit: 7
- };
- handleLimitChange = e => {
- const limit = e.target.value;
- const limitChanged = limit !== this.state.limit;
- if (limitChanged) {
- this.setState({ limit, numbers: getRange(limit) });
- }
- };
- render() {
- return (
- <div>
- <input
- onChange={this.handleLimitChange}
- placeholder="limit"
- value={this.state.limit}
- />
- {this.state.numbers.map((number, idx) => (
- <p key={idx}>{number} </p>
- ))}
- </div>
- );
- }
- }
- const rootElement = document.getElementById("root");
- ReactDOM.render(<App />, rootElement);
Tip #3:使用 PureComponent 和 React.memo 以避免不必要的 render 調(diào)用
我們?cè)谥暗睦又锌吹綄⑻囟顟B(tài)值放到更低的層級(jí)來(lái)避免不必要渲染的方法,不過(guò)這并不總是有用。
我們來(lái)看下下面的例子:
- import React, { useState } from "react";
- import ReactDOM from "react-dom";
- function App() {
- const [isFooVisible, setFooVisibility] = useState(false);
- return (
- <div className="App">
- {isFooVisible ? (
- <Foo hideFoo={() => setFooVisibility(false)} />
- ) : (
- <button onClick={() => setFooVisibility(true)}>Show Foo </button>
- )}
- <Bar name="Bar" />
- </div>
- );
- }
- function Foo({ hideFoo }) {
- return (
- <>
- <h1>Foo</h1>
- <button onClick={hideFoo}>Hide Foo</button>
- </>
- );
- }
- function Bar({ name }) {
- return <h1>{name}</h1>;
- }
- const rootElement = document.getElementById("root");
- ReactDOM.render(<App />, rootElement);
可以看到,只要父組件 App 的狀態(tài)值 isFooVisible 發(fā)生變化,F(xiàn)oo 和 Bar 就都會(huì)被重新渲染。
這里因?yàn)闉榱藳Q定 Foo 是否要被渲染出來(lái),我們需要將 isFooVisible 放在 App中維護(hù),因此也就不能將狀態(tài)拆出放到更低的層級(jí)。不過(guò),在 isFooVisible 發(fā)生變化時(shí)重新渲染 Bar 仍然是不必要的,因?yàn)?Bar 并不依賴 isFooVisible。我們只希望 Bar 在傳入屬性 name 變化時(shí)重新渲染。
那我們?cè)撛趺锤隳兀績(jī)煞N方法。
其一,對(duì) Bar 做記憶化(memoize):
- const Bar = React.memo(function Bar({name}) {
- return <h1>{name}</h1>;
- });
這就能保證 Bar 只在 name 發(fā)生變化時(shí)才重新渲染。
此外,另一個(gè)方法就是讓 Bar 繼承 React.PureComponent 而非 React.Component:
- class Bar extends React.PureComponent {
- render() {
- return <h1>{name}</h1>;
- }
- }
是不是很熟悉?我們經(jīng)常提到使用 React.PureComponent 能帶來(lái)一定的性能提升,避免不必要的 render。
總結(jié):避免組件不必要的渲染的方法有:React.memo 包裹的函數(shù)式組件,繼承自 React.PureComponent 的 class 組件。
為什么不讓每個(gè)組件都繼承 PureComponent 或者用 memo 包呢?
如果這條建議可以讓我們避免不必要的重新渲染,那我們?yōu)槭裁床话衙總€(gè) class 組件變成 PureComponent、把每個(gè)函數(shù)式組件用 React.memo 包起來(lái)?為什么有了更好的方法還要保留 React.Component 呢?為什么函數(shù)式組件不默認(rèn)記憶化呢?
毫無(wú)疑問(wèn),這些方法并不總是萬(wàn)靈藥。
嵌套對(duì)象的問(wèn)題
我們先來(lái)考慮下 PureComponent 和 React.memo 的組件到底做了什么?
每次更新的時(shí)候(包括狀態(tài)更新或上層組件重新渲染),它們就會(huì)在新 props、state 和舊 props、state 之間對(duì) key 和 value 進(jìn)行淺比較。淺比較是個(gè)嚴(yán)格相等的檢查,如果檢測(cè)到差異,render 就會(huì)執(zhí)行:
- // 基本類(lèi)型的比較
- shallowCompare({ name: 'bar'}, { name: 'bar'}); // output: true
- shallowCompare({ name: 'bar'}, { name: 'bar1'}); // output: false
盡管基本類(lèi)型(如字符串、數(shù)字、布爾)的比較可以工作的很好,但對(duì)象這類(lèi)復(fù)雜的情況可能就會(huì)帶來(lái)意想不到的行為:
- shallowCompare({ name: {first: 'John', last: 'Schilling'}},
- { name: {first: 'John', last: 'Schilling'}}); // output: false
上述兩個(gè) name 對(duì)應(yīng)的對(duì)象的引用是不同的。
我們重新看下之前的例子,然后修改我們傳入 Bar 的 props:
- import React, { useState } from "react";
- import ReactDOM from "react-dom";
- const Bar = React.memo(function Bar({ name: { first, last } }) {
- console.log("Bar render");
- return (
- <h1>
- {first} {last}
- </h1>
- );
- });
- function Foo({ hideFoo }) {
- return (
- <>
- <h1>Foo</h1>
- <button onClick={hideFoo}>Hide Foo</button>
- </>
- );
- }
- function App() {
- const [isFooVisible, setFooVisibility] = useState(false);
- return (
- <div className="App">
- {isFooVisible ? (
- <Foo hideFoo={() => setFooVisibility(false)} />
- ) : (
- <button onClick={() => setFooVisibility(true)}>Show Foo</button>
- )}
- <Bar name={{ first: "John", last: "Schilling" }} />
- </div>
- );
- }
- const rootElement = document.getElementById("root");
- ReactDOM.render(<App />, rootElement);
盡管 Bar 做了記憶化且 props 值并沒(méi)有發(fā)生變動(dòng),每次父組件重新渲染時(shí)它仍然會(huì)重新渲染。這是因?yàn)楸M管每次比較的兩個(gè)對(duì)象擁有相同的值,引用并不同。
函數(shù) props 的問(wèn)題
我們也可以把函數(shù)作為 props 向組件傳遞,當(dāng)然,在 JavaScript 中函數(shù)也會(huì)傳遞引用,因此淺比較也是基于其傳遞的引用。
因此,如果我們傳遞的是箭頭函數(shù)(匿名函數(shù)),組件仍然會(huì)在父組件重新渲染時(shí)重新渲染。
Tip #4:更好的 props 寫(xiě)法
前面的問(wèn)題的一種解決方法是改寫(xiě)我們的 props。
我們不傳遞對(duì)象作為 props,而是 將對(duì)象拆分成基本類(lèi)型 :
- <Bar firstName="John" lastName="Schilling" />
而對(duì)于傳遞箭頭函數(shù)的場(chǎng)景,我們可以代以只***聲明過(guò)一次的函數(shù),從而總可以拿到相同的引用,如下所示:
- class App extends React.Component{
- constructor(props) {
- this.doSomethingMethod = this.doSomethingMethod.bind(this);
- }
- doSomethingMethod () { // do something}
- render() {
- return <Bar onSomething={this.doSomethingMethod} />
- }
- }
Tip #5:控制更新
還是那句話,任何方法總有其適用范圍。
第三條建議雖然處理了不必要的更新問(wèn)題,但我們也不總能使用它。
而第四條,在某些情況下我們并不能拆分對(duì)象,如果我們傳遞了某種嵌套確實(shí)復(fù)雜的數(shù)據(jù)結(jié)構(gòu),那我們也很難將其拆分開(kāi)來(lái)。
不僅如此,我們也不總能傳遞只聲明了一次的函數(shù)。比如在我們的例子中,如果 App 是個(gè)函數(shù)式組件,恐怕就不能做到這一點(diǎn)了(在 class 組件中,我們可以用 bind 或者類(lèi)內(nèi)箭頭函數(shù)來(lái)保證 this 的指向及***聲明,而在函數(shù)式組件中則可能會(huì)有些問(wèn)題)。
幸運(yùn)的是, 無(wú)論是 class 組件還是函數(shù)式組件,我們都有辦法控制淺比較的邏輯 。
在 class 組件中,我們可以使用生命周期鉤子 shouldComponentUpdate(prevProps, prevState)來(lái)返回一個(gè)布爾值,當(dāng)返回值為 true 時(shí)才會(huì)觸發(fā) render。
而如果我們使用 React.memo,我們可以傳遞一個(gè)比較函數(shù)作為第二個(gè)參數(shù)。
注意! React.memo 的第二參數(shù)(比較函數(shù))和 shouldComponentUpdate 的邏輯是相反的,只有當(dāng)返回值為 false 的時(shí)候才會(huì)觸發(fā) render。 參考文檔 。
- const Bar = React.memo(
- function Bar({ name: { first, last } }) {
- console.log("update");
- return (
- <h1>
- {first} {last}
- </h1>
- );
- },
- (prevProps, newProps) =>
- prevProps.name.first === newProps.name.first &&
- prevProps.name.last === newProps.name.last
- );
盡管這條建議是可行的,但我們?nèi)砸⒁?nbsp;比較函數(shù)的性能開(kāi)銷(xiāo) 。如果 props 對(duì)象過(guò)深,反而會(huì)消耗不少的性能。
總結(jié)
上述場(chǎng)景仍不夠全面,但多少能帶來(lái)一些啟發(fā)性思考。當(dāng)然在性能方面,我們還有許多其他的問(wèn)題需要考慮,但遵守上述的準(zhǔn)則仍能帶來(lái)相當(dāng)不錯(cuò)的性能提升。

























