KitchHike Tech Blog

KitchHike Product, Design and Engineering Teams

コードを読んでやっと理解できた React の shouldComponentUpdate

PureComponent さえ使えばパフォーマンスを改善できるのか?

パフォーマンスを改善したいとき、 PureComponent や React.memo を使うことがあると思います。しかし、 React の仕組みを理解していないと、 PureComponent などを使うだけでは不要な再レンダリングを制御できないことがあります。コードを読んで仕組みを正しく理解する大切さを改めて実感したので、知見を共有できればと思います。

はじめに

こんにちは。 KitchHike エンジニアのタクです。

先日アプリの不具合調査で、不必要に再レンダリングが行われている箇所を発見しました。パフォーマンスへの懸念もあるため、不具合修正と一緒に再レンダリングを減らすような対応もすることにし、通常の Class コンポーネントから PureComponent へリファクタリングを行いました。その過程で React のコードを読んで PureComponent の仕組みを深く理解することの大切さを再認識したので、共有したいと思います。

不要な再レンダリングが多すぎて不具合調査が進まない問題

アプリのイベント詳細画面を最下部までスクロールすると、関連のある他イベントの一覧が10件ずつ表示されます。今回見つかったのは、表示されるイベントが重複してしまうという不具合でした。そして、不具合が発生しているコンポーネントが Class コンポーネントで実装されていました。

不具合を解決するため、初めにプリントデバッグを行いました。すると、画面の見た目は変わっていないのに、コンポーネントが何度も再レンダリングされていることがわかりました。

これでは不具合を解決しても、パフォーマンスの悪さが原因で UX を損なってしまいます。かといって、プリントデバッグしようにも出力される調査ログが非常に多くなり、不具合解決の妨げになっていました。

f:id:yamataku3831:20210417151316p:plain

パフォーマンス改善を念頭に置いた上で、まずは不具合解決のため、デバッグしやすい状況を作ることに集中しました。

PureComponent を使用しても再レンダリングがなくならない

まずは不要な再レンダリングがなくせるかを調査しました。具体的には shouldComponentUpdate にプリントデバッグを入れて、再レンダリング前に新しく受け取った props と既存の props の変化を確認する方法で調査しました。

すると、予想通り props に変化がないことがわかりました。 props に変化がないということは、対象のコンポーネントを React.PureComponent で再実装することで、必要なレンダリングだけを実行できるようになるはずです。

しかし、実際に React.PureComponent で実装しても、不要な再レンダリングを制御することはできませんでした。

※ Hooks 化して memo を使用する方法もあったのですが、割と大きめなファイルだったのと、いち早く不具合を解決したかったため、 React.PureComponent を使用する方法を選択しました。

原因は React の 浅い比較 への理解不足

props に差分はないのに、 React.PureComponent を使用してもレンダリング回数を最適化できない理由がわかりませんでした。

そこで React の公式ドキュメントを読み直し、 React.PureComponent について改めて勉強しました。すると、 props の比較方法である 浅い比較 を考慮していない実装が原因だとわかりました。

不具合が起きたコンポーネントを見ると、プリミティブデータ型ではなく、オブジェクトが props として渡ってることがわかりました。

import React from 'react';
import {Text, Button} from 'react-native';

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      hoge: 0,
    };
  }

  render() {
    const params = {name: 'Taku', age: 28};

    return (
      <>
        <Greeting params={params} />
        <Button
          title={'Press'}
          onPress={() => {
            this.setState({hoge: this.state.hoge + 1});
          }}
        />
      </>
    );
  }
}

class Greeting extends React.PureComponent {
  constructor(props) {
    super(props);
  }

  render() {
    console.log('re-rendered');
    return (
      <>
        <Text>Hello, {this.props.params.name}</Text>
        <Text>age: {this.props.params.age}</Text>
      </>
    );
  }
}

export default App;

上記の例の場合、 Button をタップすると setState が実行されるため、親コンポーネントである App コンポーネントが再レンダリングされます。

すると、 const params が再び宣言されることで、再レンダリングされる前とは異なるメモリ領域をアロケートするため、 props として渡しているオブジェクト params の参照が変わります。

さらに React の 浅い比較 では、 props としてオブジェクトが渡ってきた場合、オブジェクトの参照が比較対象になります。 React のソースコードを見ると明白です。

React のライフサイクルを見れば、 shouldComponentUpdate の結果によって、コンポーネントが再度 render されることわかります。

f:id:yamataku3831:20210417150330p:plain

shouldComponentUpdate のコードから辿っていくと、最終的に shallowDiffers というファイルに行きつきます。

f:id:yamataku3831:20210417145730p:plain

react/packages/react-devtools-shared/src/node_modules/react-window/src/shallowDiffers.js

export default function shallowDiffers(prev: Object, next: Object): boolean {
  for (let attribute in prev) {
    if (!(attribute in next)) {
      return true;
    }
  }
  for (let attribute in next) {
    if (prev[attribute] !== next[attribute]) {
      return true;
    }
  }
  return false;
}

React の shallowDiffers 関数は既存の props と新しく渡ってくる props を 浅い比較 し、差分があるかを Boolean で返します。注目したいのは12行目のコードで、 props の各プロパティが !== で比較されています。

JavaScript の厳密等価演算子(===)は、「オブジェクトへの参照」を比較します。

2 つの異なるオブジェクトは、同じプロパティを持っていても等値とは見なされません。同じオブジェクトへの参照を比較した時のみ真となります。

引用元:MDN Web Docs

オブジェクトを比較する場合は、参照で比較するだけで内部の値まで見ないという意味で、 浅い比較 と呼んでいるのだと思います。

ちなみにですが、 JavaScript の場合、等価演算子(==)も厳密等価演算子(===)と同様にオブジェクトを参照で比較します。

f:id:yamataku3831:20210417152101p:plain

つまり、呼び出し側のコンポーネントが再レンダリングされる度に、 props として渡ってきたオブジェクトの参照が変わるため、オブジェクトの中身が同じであっても、再レンダリングされてしまいます。

そこで今回は、オブジェクトで受け取っていた値を複数の props に分解し、プリミティブデータ型で受け取るようにしました。

import React from 'react';
import {Text, Button} from 'react-native';

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      hoge: 0,
    };
  }

  render() {
-   const params = {name: 'Taku', age: 28};
+   const name = 'Taku';
+   const age = 28;

    return (
      <>
-       <Greeting params={params} />
+       <Greeting name={name} age={age} />
        <Button
          title={'Press'}
          onPress={() => {
            this.setState({hoge: this.state.hoge + 1});
          }}
        />
      </>
    );
  }
}

class Greeting extends React.PureComponent {
  constructor(props) {
    super(props);
  }

  render() {
    console.log('re-rendered');
    return (
      <>
-       <Text>Hello, {this.props.params.name}</Text>
-       <Text>age: {this.props.params.age}</Text>
+       <Text>Hello, {this.props.name}</Text>
+       <Text>age: {this.props.age}</Text>
      </>
    );
  }
}

export default App;

すると、 props の各値が参照ではなく値で比較されるようになったため、不要な再レンダリングがなくなり、必要なレンダリングだけが実行されるようになりました。

before after
f:id:yamataku3831:20210417150915g:plain f:id:yamataku3831:20210417150931g:plain

必要なレンダリングだけ行われるようになったことで、不具合解決に必要な調査ログが見やすくなり、それがきっかけで不具合も解決することできました。さらに副次的な効果としていたパフォーマンス改善も達成することができました。

おわりに

今回の経験から PureComponent を使用して必要なレンダリングだけを実行しようとするときは、以下の知識が必要であることがわかりました。

  • React は shallowDiffers という関数で 浅い比較 を行う
  • shallowDiffers 関数では2つ値を厳密等価演算子(!==)で比較する
  • JavaScript の厳密等価演算子は、プリミティブデータ型は値を、オブジェクトは参照を比較する
  • props として渡しているオブジェクトが親コンポーネントで再び宣言されると参照が変わる

こういった知識がなかったがゆえに、 props の値が変わっていないのに PureComponent が再レンダリングされる理由がわからず、想定以上に時間を使ってしまいました。

不要な再レンダリングをなくすには、 React の 浅い比較 の仕組みを理解することが必要でした。今回はオブジェクトのプロパティをプリミティブデータ型に分解して、値を比較するように修正することで、必要なレンダリングだけを行うようにすることができました。

今回の件であらためて、浅い理解のままで開発していてはいけないと痛感しました。 React の仕組みをより深く理解し、正しく実装することの大切さを再確認する良い経験となりました。

We're Hiring

キッチハイクでは、React Native アプリエンジニアを募集中です!

www.wantedly.com www.wantedly.com www.wantedly.com