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