從 React render 談談效能優化

貓酥酥發表於2019-02-23

本文將從 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 物件過深,反而會消耗不少的效能。

總結

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

相關文章