本文將從 render 函數的角度總結 React App 的優化技巧。須要提醒的是,文中將涉及 React 16.8.2 版本的內容(也即 Hooks),所以請至少了解 useState 以保證食用效果。javascript
正文開始。html
當咱們討論 React App 的性能問題時,組件的渲染速度是一個重要問題。在進入到具體優化建議以前,咱們先要理解如下 3 點:java
這部分涉及 reconciliation 和 diffing 的概念,固然官方文檔在這裏。react
這個問題其實寫過 React 的人都會知道,這裏再簡單說下:算法
在 class 組件中,咱們指的是 render 方法:api
class Foo extends React.Component {
render() {
return <h1> Foo </h1>;
}
}
複製代碼
在函數式組件中,咱們指的是函數組件自己:數組
function Foo() {
return <h1> Foo </h1>;
}
複製代碼
render 函數會在兩種場景下被調用:bash
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); 複製代碼
能夠看到,代碼中的邏輯是咱們點擊就會更新 count,到 10 之後,就會維持在 10。增長一個 console.log,這樣咱們就能夠知道 render 是否被調用了。從執行結果能夠知道,即便 count 到了 10 以上,render 仍然會被調用。數據結構
總結:繼承了 React.Component 的 class 組件,即便狀態沒變化,只要調用了setState 就會觸發 render。dom
咱們用函數實現相同的組件,固然由於要有狀態,咱們用上了 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); 複製代碼
咱們能夠注意到,當狀態值再也不改變以後,render 的調用就中止了。
總結:對函數式組件來講,狀態值改變時纔會觸發 render 函數的調用。
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); 複製代碼
只要點擊了 App 組件內的 Change name 按鈕,就會從新 render。並且能夠注意到,無論 Foo 具體實現是什麼,Foo 都會被從新渲染。
總結:不管組件是繼承自 React.Component 的 class 組件仍是函數式組件,一旦父容器從新 render,組件的 render 都會再次被調用。
只要 render 函數被調用,就會有兩個步驟按順序執行。這兩個步驟很是重要,理解了它們纔好知道如何去優化 React App。
在此步驟中,React 將新調用的 render 函數返回的樹與舊版本的樹進行比較,這一步是 React 決定如何更新 DOM 的必要步驟。雖然 React 使用高度優化的算法執行此步驟,但仍然有必定的性能開銷。
基於 diffing 的結果,React 更新 DOM 樹。這一步由於須要卸載和掛載 DOM 節點一樣存在許多性能開銷。
咱們如下面爲例,其中 App 會渲染兩個組件:
CounterLabel
,接收 count 值和一個 inc 父組件 App 中狀態 count 的方法。List
,接收 item 的列表。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);
複製代碼
執行上面代碼可知,只要父組件 App 中的狀態被更新,CounterLabel
和 List
就都會更新。
固然,CounterLabel
從新渲染是正常的,由於 count 發生了變化,天然要從新渲染;可是對於 List
而言,就徹底是沒必要要的更新了,由於它的渲染與 count 無關。儘管 React 並不會在 reconciliation 階段真的更新 DOM,畢竟徹底沒變化,可是仍然會執行 diffing 階段來對先後的樹進行對比,這仍然存在性能開銷。
還記得 render 執行過程當中的 diffing 和 reconciliation 階段嗎?前面講過的東西在這裏碰到了。
所以,爲了不沒必要要的 diffing 開銷,咱們應當考慮將特定的狀態值放到更低的層級或組件中(與 React 中所說的「提高」概念恰好相反)。在這個例子中,咱們能夠經過將 count 放到 CounterLabel
組件中管理來解決這個問題。
由於每次狀態更新都會觸發新的 render 調用,那麼更少的狀態更新也就能夠更少的調用 render 了。
咱們知道,React class 組件有 componentDidUpdate(prevProps, prevState)
的鉤子,能夠用來檢測 props 或 state 有沒有發生變化。儘管有時有必要在 props 發生變化時再觸發 state 更新,但咱們總能夠避免在一次 state 變化後再進行一次 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); 複製代碼
這裏渲染了一個範圍數字序列,即範圍爲 0 到 limit。只要用戶改變了 limit 值,咱們就會在 componentDidUpdate 中進行檢測,並設定新的數字列表。
毫無疑問,上面的代碼是能夠知足需求的,可是,咱們仍然能夠進行優化。
上面的代碼中,每次 limit 發生改變,咱們都會觸發兩次狀態更新:第一次是爲了修改 limit,第二次是爲了修改展現的數字列表。這樣一來,每次 limit 的變化會帶來兩次 render 開銷:
// 初始狀態
{ 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]
複製代碼
咱們的代碼邏輯帶來了下面的問題:
爲了改進,咱們應避免在不一樣的狀態更新中改變數字列表。事實上,咱們能夠在一次狀態更新中搞定:
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); 複製代碼
咱們在以前的例子中看到將特定狀態值放到更低的層級來避免沒必要要渲染的方法,不過這並不老是有用。
咱們來看下下面的例子:
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 的狀態值 isFooVisible 發生變化,Foo 和 Bar 就都會被從新渲染。
這裏由於爲了決定 Foo 是否要被渲染出來,咱們須要將 isFooVisible 放在 App中維護,所以也就不能將狀態拆出放到更低的層級。不過,在 isFooVisible 發生變化時從新渲染 Bar 仍然是沒必要要的,由於 Bar 並不依賴 isFooVisible。咱們只但願 Bar 在傳入屬性 name 變化時從新渲染。
那咱們該怎麼搞呢?兩種方法。
其一,對 Bar 作記憶化(memoize):
const Bar = React.memo(function Bar({name}) {
return <h1>{name}</h1>;
});
複製代碼
這就能保證 Bar 只在 name 發生變化時才從新渲染。
此外,另外一個方法就是讓 Bar 繼承 React.PureComponent 而非 React.Component:
class Bar extends React.PureComponent {
render() {
return <h1>{name}</h1>;
}
}
複製代碼
是否是很熟悉?咱們常常提到使用 React.PureComponent 能帶來必定的性能提高,避免沒必要要的 render。
總結:避免組件沒必要要的渲染的方法有:React.memo 包裹的函數式組件,繼承自 React.PureComponent 的 class 組件。
若是這條建議可讓咱們避免沒必要要的從新渲染,那咱們爲何不把每一個 class 組件變成 PureComponent、把每一個函數式組件用 React.memo 包起來?爲何有了更好的方法還要保留 React.Component 呢?爲何函數式組件不默認記憶化呢?
毫無疑問,這些方法並不老是萬靈藥。
咱們先來考慮下 PureComponent 和 React.memo 的組件到底作了什麼?
每次更新的時候(包括狀態更新或上層組件從新渲染),它們就會在新 props、state 和舊 props、state 之間對 key 和 value 進行淺比較。淺比較是個嚴格相等的檢查,若是檢測到差別,render 就會執行:
// 基本類型的比較
shallowCompare({ name: 'bar'}, { name: 'bar'}); // output: true
shallowCompare({ name: 'bar'}, { name: 'bar1'}); // output: false
複製代碼
儘管基本類型(如字符串、數字、布爾)的比較能夠工做的很好,但對象這類複雜的狀況可能就會帶來意想不到的行爲:
shallowCompare({ name: {first: 'John', last: 'Schilling'}},
{ name: {first: 'John', last: 'Schilling'}}); // output: false
複製代碼
上述兩個 name 對應的對象的引用是不一樣的。
咱們從新看下以前的例子,而後修改咱們傳入 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 值並無發生變更,每次父組件從新渲染時它仍然會從新渲染。這是由於儘管每次比較的兩個對象擁有相同的值,引用並不一樣。
咱們也能夠把函數做爲 props 向組件傳遞,固然,在 JavaScript 中函數也會傳遞引用,所以淺比較也是基於其傳遞的引用。
所以,若是咱們傳遞的是箭頭函數(匿名函數),組件仍然會在父組件從新渲染時從新渲染。
前面的問題的一種解決方法是改寫咱們的 props。
咱們不傳遞對象做爲 props,而是將對象拆分紅基本類型:
<Bar firstName="John" lastName="Schilling" />
複製代碼
而對於傳遞箭頭函數的場景,咱們能夠代以只惟一聲明過一次的函數,從而總能夠拿到相同的引用,以下所示:
class App extends React.Component{
constructor(props) {
this.doSomethingMethod = this.doSomethingMethod.bind(this);
}
doSomethingMethod () { // do something}
render() {
return <Bar onSomething={this.doSomethingMethod} /> } } 複製代碼
仍是那句話,任何方法總有其適用範圍。
第三條建議雖然處理了沒必要要的更新問題,但咱們也不總能使用它。
而第四條,在某些狀況下咱們並不能拆分對象,若是咱們傳遞了某種嵌套確實複雜的數據結構,那咱們也很難將其拆分開來。
不只如此,咱們也不總能傳遞只聲明瞭一次的函數。好比在咱們的例子中,若是 App 是個函數式組件,恐怕就不能作到這一點了(在 class 組件中,咱們能夠用 bind 或者類內箭頭函數來保證 this 的指向及惟一聲明,而在函數式組件中則可能會有些問題)。
幸運的是,不管是 class 組件仍是函數式組件,咱們都有辦法控制淺比較的邏輯。
在 class 組件中,咱們可使用生命週期鉤子 shouldComponentUpdate(prevProps, prevState)
來返回一個布爾值,當返回值爲 true 時纔會觸發 render。
而若是咱們使用 React.memo,咱們能夠傳遞一個比較函數做爲第二個參數。
**注意!**React.memo 的第二參數(比較函數)和
shouldComponentUpdate
的邏輯是相反的,只有當返回值爲 false 的時候纔會觸發 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
);
複製代碼
儘管這條建議是可行的,但咱們仍要注意比較函數的性能開銷。若是 props 對象過深,反而會消耗很多的性能。
上述場景仍不夠全面,但多少能帶來一些啓發性思考。固然在性能方面,咱們還有許多其餘的問題須要考慮,但遵照上述的準則仍能帶來至關不錯的性能提高。