React 性能優化技巧總結

本文將從 render 函數的角度總結 React App 的優化技巧。需要提醒的是,文中將涉及 React 16.8.2 版本的內容(也即 Hooks),因此請至少了解 useState 以保證食用效果。

正文開始。


當我們討論 React App 的性能問題時,組件的渲染速度是一個重要問題。在進入到具體優化建議之前,我們先要理解以下 3 點:

  1. 當我們在說「render」時,我們在說什麼?
  2. 什麼時候會執行「render」?
  3. 在「render」過程中會發生什麼?

解讀 render 函數

這部分涉及 reconciliation 和 diffing 的概念,當然官方文檔在這裏

當我們在說「render」時,我們在說什麼?

這個問題其實寫過 React 的人都會知道,這裏再簡單說下:

在 class 組件中,我們指的是 render 方法:

class Foo extends React.Component {
 render() {
   return <h1> Foo </h1>;
 }
}

在函數式組件中,我們指的是函數組件本身:

function Foo() {
  return <h1> Foo </h1>;
}

什麼時候會執行「render」?

render 函數會在兩種場景下被調用:

1. 狀態更新時

a. 繼承自 React.Component 的 class 組件更新狀態時
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。

b. 函數式組件更新狀態時

我們用函數實現相同的組件,當然因爲要有狀態,我們用上了 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 函數的調用。

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);

只要點擊了 App 組件內的 Change name 按鈕,就會重新 render。而且可以注意到,不管 Foo 具體實現是什麼,Foo 都會被重新渲染。

總結:無論組件是繼承自 React.Component 的 class 組件還是函數式組件,一旦父容器重新 render,組件的 render 都會再次被調用。

在「render」過程中會發生什麼?

只要 render 函數被調用,就會有兩個步驟按順序執行。這兩個步驟非常重要,理解了它們纔好知道如何去優化 React App。

Diffing

在此步驟中,React 將新調用的 render 函數返回的樹與舊版本的樹進行比較,這一步是 React 決定如何更新 DOM 的必要步驟。雖然 React 使用高度優化的算法執行此步驟,但仍然有一定的性能開銷。

Reconciliation

基於 diffing 的結果,React 更新 DOM 樹。這一步因爲需要卸載和掛載 DOM 節點同樣存在許多性能開銷。

開始我們的 Tips

Tip #1:謹慎分配 state 以避免不必要的 render 調用

我們以下面爲例,其中 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 中的狀態被更新,CounterLabelList 就都會更新。

當然,CounterLabel 重新渲染是正常的,因爲 count 發生了變化,自然要重新渲染;但是對於 List 而言,就完全是不必要的更新了,因爲它的渲染與 count 無關。儘管 React 並不會在 reconciliation 階段真的更新 DOM,畢竟完全沒變化,但是仍然會執行 diffing 階段來對前後的樹進行對比,這仍然存在性能開銷。

還記得 render 執行過程中的 diffing 和 reconciliation 階段嗎?前面講過的東西在這裏碰到了。

因此,爲了避免不必要的 diffing 開銷,我們應當考慮將特定的狀態值放到更低的層級或組件中(與 React 中所說的「提升」概念剛好相反)。在這個例子中,我們可以通過將 count 放到 CounterLabel 組件中管理來解決這個問題。

Tip #2:合併狀態更新

因爲每次狀態更新都會觸發新的 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]

我們的代碼邏輯帶來了下面的問題:

  • 我們觸發了比實際需要更多的狀態更新;
  • 我們出現了「不連續」的渲染結果,即數字列表與 limit 不匹配。

爲了改進,我們應避免在不同的狀態更新中改變數字列表。事實上,我們可以在一次狀態更新中搞定:

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 調用

我們在之前的例子中看到將特定狀態值放到更低的層級來避免不必要渲染的方法,不過這並不總是有用。

我們來看下下面的例子:

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 組件

爲什麼不讓每個組件都繼承 PureComponent 或者用 memo 包呢?

如果這條建議可以讓我們避免不必要的重新渲染,那我們爲什麼不把每個 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 的問題

我們也可以把函數作爲 props 向組件傳遞,當然,在 JavaScript 中函數也會傳遞引用,因此淺比較也是基於其傳遞的引用。

因此,如果我們傳遞的是箭頭函數(匿名函數),組件仍然會在父組件重新渲染時重新渲染

Tip #4:更好的 props 寫法

前面的問題的一種解決方法是改寫我們的 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} />
  }
}

Tip #5:控制更新

還是那句話,任何方法總有其適用範圍。

第三條建議雖然處理了不必要的更新問題,但我們也不總能使用它。

而第四條,在某些情況下我們並不能拆分對象,如果我們傳遞了某種嵌套確實複雜的數據結構,那我們也很難將其拆分開來。

不僅如此,我們也不總能傳遞只聲明瞭一次的函數。比如在我們的例子中,如果 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 對象過深,反而會消耗不少的性能。

總結

上述場景仍不夠全面,但多少能帶來一些啓發性思考。當然在性能方面,我們還有許多其他的問題需要考慮,但遵守上述的準則仍能帶來相當不錯的性能提升。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章