エンジニアの 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 value={this.props.value} <ChildComponent2 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> <GrandChildComponent type="foo" value={this.props.value} /> </ChildComponent> <ChildComponent> <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 のレンダリングが走っていることがわかります。
そこで、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 が走ることはなくなりました。
しかし、この形式では 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)} /> ); })
Storybookを使うことで、コンポーネントのユースケース別の解説や、Props変更時の振る舞いについて確認することができます。
最後に
実際の開発ではここに flux によるデータフロー、flowtype による型付けといった要素が加わってきますが、今回はReactComponentそのものにフォーカスして、普段設計やリファクタリング時に気をつけていることなどを紹介させていただきました。少しでも参考になれば幸いです。
明日は社内勉強会を主催されている futoase さんです!お楽しみに〜
-
本記事で使用しているReactのバージョンはv15.6.2となります。↩
-
React v16以降は非対応となり、ブラウザのデベロッパーツールで計測することが推奨されています。↩
-
意図的に変更がある場合は
--updateSnapshot
でスナップショット自体を更新することができます。↩