ReactComponent のリファクタリング指針

エンジニアの id:t930 です。
freee Developers Advent Calendar 2017 19日目いきます。

React はその名前を聞くようになってから3年以上が経過し、Webアプリケーション開発の文脈においてはもはや枯れた技術と言えるでしょう。会計freeeでも2015年ごろに Backbone.js から React へのリプレースを行い、現在では Reactコンポーネントだけでも900近いファイルが存在しています。当然このような規模でやっているとリファクタリングも必要になってくるわけで、本記事ではそんな中で得られたReactコンポーネントにおけるリファクタリングの指針について紹介していきます。1

適切な単位に分割する

React に限った話ではないですが、巨大で見通しの悪いコンポーネントはメンテナビリティや再利用性の低下を招きます。表示領域、責務、意味付けに応じて適切な単位に分割します。

Bad

// state, method, rendererの肥大化したコンポーネント
class BigComponent extends React.Component {

  constructor() {
    super(...arguments);
    this.state = {
      //...
    }
  }

  handleClickFoo() {
    //...
  }

  handleClickBar() {
    //...
  }

  handleClickBaz() {
    //...
  }

  //...

  render() {
    <div className="big-component">
      <div className="header">
        <div className="header-content-1" />
        // ...
        <div className="header-content-99" />
      </div>
      <div className="body">
        <div className="body-content-1" />
        // ...
        <div className="body-content-99" />
      </div>
      <div className="footer">
        <div className="footer-content-1" />
        // ...
        <div className="footer-content-99" />
      </div>
    </div>
  }
}

Good

// Header, Body, Footerを別コンポーネントに分割
class BigComponent extends React.Component {
  render() {
    <div className="big-component">
      <Header />
      <Body />
      <Footer />
    </div>
  }
}

内部要素の抽象化

あるコンポーネントをラップするようなコンポーネントは内部要素を props.children として扱い、責務を分解します。これにより Props のバケツリレーも減らすことができます。

Bad

class ParentComponent extends React.Component {
  return() {
    <div className="parent">
      <ChildComponent1 title={this.props.title} value={this.props.value} />
      <ChildComponent2 title={this.props.title} value={this.props.value} />
    </div>
  }
}

class ChildComponent1 extends React.Component {
  return() {
    <div className="child">
      <h3>{this.props.title}</h3>
      <GrandChildComponent type="foo" value={this.props.value} />
    </div>
  }
}

class ChildComponent2 extends React.Component {
  return() {
    <div className="child">
      <h3>{this.props.title}</h3>
      <GrandChildComponent type="bar" value={this.props.value} />
    </div>
  }
}

Good

class ParentComponent extends React.Component {
  return() {
    <div className="parent">
      <ChildComponent title={this.props.title}>
        <GrandChildComponent type="foo" value={this.props.value} />
      </ChildComponent>
      <ChildComponent title={this.props.title}>
        <GrandChildComponent type="bar" value={this.props.value} />
      </ChildComponent>
    </div>
  }
}

class ChildComponent extends React.Component {
  return() {
    <div className="child">
      <h3>{this.props.title}</h3>
      {this.props.children}
    </div>
  }
}

Stateless Functional Componentを使う

State を持たないコンポーネントは Stateless Functional Component(SFC) として関数の形で書くことができます。State を持たない純粋関数とすることでそれだけ副作用を減らせるので、プレゼンテーショナルなコンポーネントはなるべく Stateless にすることをおすすめします。SFC の形で書くことでチーム開発においても Stateless であるという意図が伝わりやすくなるでしょう。

Bad

class Foo extends React.Component {
  render() {
    return (
      <div className="foo">{this.props.value}</div>
    );
  }
}

Good

function Foo(props) {
  return (
    <div className="foo">{props.value}</div>
  );
}

継承ではなく Higher Order Component を使う

あるコンポーネントに共通の振る舞いを持たせたい時、継承ではなく Higher Order Component(HoC) パターンを使う方が依存関係などの見通しが良くなります。HoCは他のコンポーネントをラップして機能を追加したコンポーネントです。

Bad

class InputWithHandler extends React.Component {
  constructor() {
    super(...arguments);
    this.state = {
      value: null
    };
  }

  handleChange(e) {
    this.setState({ value: e.target.value });
  }

  render() {
    return (
      <input
        value={this.state.value}
        onChange={this.handleChange.bind(this)}
      />
    );
  }
}

class SelectWithHandler extends InputWithHandler {
  render() {
    return (
      <select
        value={this.state.value}
        onChange={this.handleChange.bind(this)}
      >
        <option value="foo">foo</option>
        <option value="bar">bar</option>
        <option value="baz">baz</option>
      </select>
    );
  }
}

Good

上記の継承を HoC を返すファクトリ関数で置き換えると以下のようになります。

function handlerHoC(BaseComponent) {
  return class extends React.Component {
    constructor() {
      super(...arguments);
      this.state = {
        value: null
      };
    }

    handleChange(e) {
      this.setState({ value: e.target.value });
    }

    render() {
      return (
        <BaseComponent
          value={this.state.value}
          handleChange={this.handleChange.bind(this)}
        />
      );
    }
  }
}

const InputWithHandler = handlerHoC((props) => {
  return (
    <input
      value={props.value}
      onChange={props.handleChange}
    />
  );
});

const SelectWithHandler = handlerHoC((props) => {
  return (
    <select
      value={props.value}
      onChange={props.handleChange}
    >
      <option value="foo">foo</option>
      <option value="bar">bar</option>
      <option value="baz">baz</option>
    </select>
  );
});

recompose

recompose はHoCを定義するための関数群で、これを使用することでHoCをより簡潔に定義することができます。

const enhance = compose(
  withState('value', 'setValue', null),
  withHandlers({
    handleChange({ setValue }) {
      return (e => setValue(e.target.value));
    }
  })
);

const InputWithHandler = enhance(props => {
  return (
    <input
      value={props.value}
      onChange={props.handleChange}
    />
  );
});

パフォーマンスチューニング

実際のところ ReactComponent そのもののパフォーマンスイシューに遭遇した機会はそこまでありませんが、例えば無限スクロールする ListView や、スプレッドシートのようなセルベースの入力インターフェースの実装といったケースでは、VirtualDOM といえどその差分計算時のコストが原因でパフォーマンスが悪化することがありました。推測するな、計測せよということで、まずは react-addons-perf2 を使ってパフォーマンスの計測を行います。

import * as React from 'react';
import Perf from 'react-addons-perf';

class ParentComponent extends React.Component {
  constructor() {
    super(...arguments);
    this.state = {
      label: 'button'
    };
  }

  onClick() {
    Perf.start();
    this.setState({ label: 'clicked' }, () => {
      Perf.stop();
      Perf.printWasted(Perf.getLastMeasurements());
    });
  }

  render(): any {
    return (
      <ChildComponent
        label={this.state.label}
        onClick={this.onClick.bind(this)}
      />
    );
  }
}

function ChildComponent(props) {
  return (
    <button onClick={props.onClick}>
      {props.label}
    </button>
  );
}

クリックすると一度だけラベルを変更するボタンです。2回目のクリック以降は再レンダリングの必要はなさそうですが、react-addons-perf を使用したプロファイリング結果を見ると2回目以降のクリック時にも ChildComponent まで VirtualDOM のレンダリングが走っていることがわかります。

f:id:t930:20171219183259p:plain

そこで、React のライフサイクルメソッドである shouldComponentUpdate で現在の Props と新しく受け取る Props を比較して再レンダリングが必要か否かを判定するようにします。

compose(
  lifecycle({
    shouldComponentUpdate(nextProps) {
      return this.props.label !== nextProps.label;
    }
  })
)((props) => {
  return (
    <button onClick={props.onClick}>
      {props.label}
    </button>
  );
});

これで2回目以降のクリックイベント発火時に ChildComponent の renderer が走ることはなくなりました。

f:id:t930:20171219183310p:plain

しかし、この形式では Props 毎に比較する必要がある上、列挙している Props に漏れがあった場合再描画されるべき時にされないといったバグを生み出してしまうリスクが生じます。 React v15 から導入された React.PureComponent では shouldCOmponentUpdate をオーバーライドして自動的に Props を比較してくれるので、そちらを使うようにします。

// FYI: recomposeを使用しない場合はReact.PureComponentを継承します
pure((props) => {
  return (
    <button onClick={props.onClick}>
      {props.label}
    </button>
  );
});

PureComponent では shallowEqual で Props の比較を行うため、ネストした Props を扱う場合は差分を正確には検知できません。lodash の deepEqual や Immutable.js などを使って比較することはできますが、あまり複雑になってくると比較そのもののコストがレンダリングのコストを上回る・・・といった事態になりかねないので、その点は天秤にかけながら実装していくのが良いでしょう。

Jestでスナップショットを取る

React と同じく facebook が公開しているテストフレームワーク Jest では通常のアサーションに加えてコンポーネントのレンダリング結果をスナップショットとして保存し次回実行時に比較することができます。

import * as React from 'react';
import renderer from 'react-test-renderer';

test('snapshot', () => {
  const component = renderer.create(
    <div>foo</div>
  );
  const tree = component.toJSON();
  expect(tree).toMatchSnapshot();
});
$ yarn jest

 PASS  sample.test.js
  ✓ snapshot (15ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 passed, 1 total
Time:        1.328s, estimated 3s

一度実行し、スナップショットが作成されました。元のコンポーネントに意図的に変更を加えた上で再度実行してみます。

import * as React from 'react';
import renderer from 'react-test-renderer';

test('snapshot', () => {
  const component = renderer.create(
    <div>bar</div>
  );
  const tree = component.toJSON();
  expect(tree).toMatchSnapshot();
});
$ yarn jest

 FAIL  sample.test.js
  ✕ snapshot (18ms)

  ● snapshot

    expect(value).toMatchSnapshot()
    
    Received value does not match stored snapshot 1.
    
    - Snapshot
    + Received
    
      <div>
    -   foo
    +   bar
      </div>
      
      at Object.<anonymous> (sample.test.js:11:16)
      at process._tickCallback (internal/process/next_tick.js:103:7)

 › 1 snapshot test failed.

Snapshot Summary
 › 1 snapshot test failed in 1 test suite. Inspect your code changes or run with `yarn run jest -- -u` to update them.

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   1 failed, 1 total
Time:        4.245s

前回実行時のスナップショットと比較し、diff が生じていることが検知されました。これにより意図しないレンダリング結果の変更を防ぐことができます。3

Storybookでドキュメントを生成する

チーム開発の中で新しいメンバーがどのコンポーネントを利用すべきかわからなかったり、それにより似たようなコンポーネントが生まれてしまう状況を防ぐために、Storybookを使用してコンポーネントのドキュメントを生成すると良いでしょう。

import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { text, boolean } from '@storybook/addon-knobs';
import DateInput from 'components/input/date_input';

storiesOf('components/input/date_input', module)
.add('with date range', () => {
  return (
    <DateInput
      value={text('value', '2018-01-01')}
      onChange={action('change')}
      minDate={text('minDate', '2017-12-15')}
      maxDate={text('maxDate', '2018-01-15')}
      disabled={boolean('disabled', false)}
    />
  );
})

f:id:t930:20171219183336p:plain

Storybookを使うことで、コンポーネントのユースケース別の解説や、Props変更時の振る舞いについて確認することができます。

最後に

実際の開発ではここに flux によるデータフロー、flowtype による型付けといった要素が加わってきますが、今回はReactComponentそのものにフォーカスして、普段設計やリファクタリング時に気をつけていることなどを紹介させていただきました。少しでも参考になれば幸いです。

明日は社内勉強会を主催されている futoase さんです!お楽しみに〜


  1. 本記事で使用しているReactのバージョンはv15.6.2となります。

  2. React v16以降は非対応となり、ブラウザのデベロッパーツールで計測することが推奨されています。

  3. 意図的に変更がある場合は --updateSnapshot でスナップショット自体を更新することができます。