ビルドツールとエラー回避
アプリのビルドにWebpackを使ったことがあれば、私がWebpackの大ファンというのもうなずけるでしょう。Webpackは複雑なツールですが、バージョン2の優れた機能と新しい公式ドキュメントのおかげでだいぶ理解しやすくなりました。Webpackに触れてコンセプトを理解したら、とてつもないパワーを手にしたことになります。私はReact独自の仕様をしたJSXを含むコードのコンパイルにはBabelを、ローカル環境へのサイト構築にはwebpack-dev-serverを使用しています。ホットリロード機能には利点を感じないため、webpack-dev-serverの自動更新機能で十分満足しています。
依存オブジェクトのインポートとエクスポートには、モジュールの構文としてBabelで変換コンパイルされるES2015を使用しています。WebpackがNode流インポートであるCommonJSをサポートしても、ES2015も登場からしばらくたったこともあり、最良のものを選びたいのです。
WebpackではES2015モジュールを使って、使われないコードをバンドルから排除できます。完璧ではありませんが、便利な機能です。コミュニティがES2015によるnpm向けコードの公開を進めているので今後さらに便利になるでしょう。
Webpackのモジュール「resolve」の設定で複雑なパスのインポートを避ける
何重もの入れ子になったファイルの構造を持つ大型プロジェクトでは、ファイル間の相対パスを考える作業にイライラさせられます。コードが長くなり、こんな見た目になっていませんか。
import foo from './foo'
import bar from '../../../bar'
import baz from '../../lib/baz'
Webpackでのアプリのビルドには、ファイルが見つからなければ特定のフォルダーを参照するようWebpackにセットできます。その特定のフォルダーをすべてのインポートでの相対パスの基点にできます。私はコードをsrcフォルダーに入れて、Webpackに常にこのフォルダーを参照するようセットします。.jsxをはじめ、ほかの拡張子のファイルもここで指定します。
// inside Webpack config object
{
resolve: {
modules: ['node_modules', 'src'],
extensions: ['.js', '.jsx'],
}
}
resolve.modulesの初期値は['node_modules']です。フォルダーを追加しないとnpmやyarnでインストールしたファイルをWebpackにインポートできません。
これで常にsrcフォルダーからの相対パスでインポートできます。
import foo from './foo'
import bar from 'app/bar' // => src/app/bar
import baz from 'an/example/import' // => src/an/example/import
アプリのコードがWebpackに縛られますが、コードが読みやすくなりインポートの追加もしやすくなるため、十分価値があると思います。私はすべての新規プロジェクトでこの設定をしています。
フォルダーの構造
Reactのアプリにおいて正しいフォルダーの構造などありません。自分の好みに合わせて構造を変更してください。私には以下の方法が合っています。
コードをsrcに入れる
分かりやすい構成にするために、アプリケーションのすべてのコードを「src」フォルダーに入れます。最終的にバンドルされるコードだけ入れて、それ以外のコードは入れません。これでBabel(もしくはほかのコード変換ツール)が1つのフォルダーを対象にするだけで、不要なコードの処理がなくなります。Webpackのコンフィグファイルなど上記以外のコードは、それを示した名前のフォルダーに入れます。たとえばフォルダー構造の最上階に以下を含めます。
- src:アプリのコードを入れる
- webpack:webpackのコンフィグファイルを入れる
- scripts:ビルドスクリプトをすべて入れる
- tests:テストのためだけに使用するコード(APIのモックなど)を入れる
通常、最上階層にはindex.html、package.json、.babelrcのようなドット付きファイルを配置します。人によってはBabelの設定をpackage.jsonに含めますが、依存先の多い大型プロジェクトではファイルがとても大きくなるので、.eslintrc、.babelrcを別のファイルにしています。
アプリのコードをsrcにまとめて格納すれば、先に述べた通りresolve.modulesの工夫によりインポートが分かりやすくなります。
Reactコンポーネント
srcフォルダー作成後のポイントはコンポーネント群の構成です。すべてのコンポーネントをsrc/componentsのような1つのフォルダーに格納していましたが、大型プロジェクトでは扱いきれなくなります。
「かしこい」コンポーネントと「おバカ」なコンポーネント(コンポーネントをコンテナとプレゼンテーショナルに分離する)のフォルダーの分類方法ですが、明示的な名称をフォルダー名に使っても役に立ちませんでした。使っているコンポーネント類はざっくり「かしこい」「おバカ」(後述)に分けられますが、専用フォルダーは用意しません。
コンポーネントを、アプリのどの領域で利用するかで分類し、全体で共通して使われているコンポーネント(ボタン、ヘッダー、フッターなど一般的かつ再利用されるもの)はcoreフォルダーに入れています。それ以外のフォルダーにはアプリの特定領域を示します。たとえばcartフォルダーにはショッピングカート機能に関わるすべてのコンポーネント、listingsフォルダーには訪問者が購入できる商品リスト関係のコードを格納します。
フォルダーで分類すれば、コンポーネント名に使われる場面を示す接頭辞は不要になります。訪問者のカートの合計金額を表示するコンポーネントに、CartTotalと付けなくても、cartフォルダーのコーポネントなら、Totalで良いのです。
import Total from 'src/cart/total'
// vs
import CartTotal from 'src/cart/cart-total'
似たような名前のコンポーネントが2、3個あると接頭辞を付けたほうが分かりやすくなり、ルールを破ることもあります。しかし通常はルールを守り、冗長な名前をさけています。
大文字よりも拡張子jsxで区別する
Reactコンポーネントのファイルには大文字の名前を付けて通常のJavaScriptファイルと区別します。上記の例ではインポートするファイルの名はCartTotal.jsあるいはTotal.jsになります。私は、単語をダッシュ(-記号)で区切り、小文字のファイル名にしますが、拡張子.jsxでReactコンポーネントを区別しています。したがってファイル名はcart-total.jsxとします。
これには別の恩恵もあり、.jsxのファイルに限定して検索すれば簡単にReactファイルが見つかります。必要ならWebpackプラグインも適用できます。
どちらにしても、大切なのは一貫性です。同じコードベース内で複数のルールが混同すると、プロジェクトが進行して大量のファイルを扱うときに悪夢となります。
1つのファイルに1つのReactコンポーネント
命名ルールに続き、「1つのファイルに1つのReactコンポーネントファイル、コンポーネントは常にデフォルトのエクスポート」の慣例に従います。
通常のReactファイルは以下の通りです。
import React, { Component, PropTypes } from 'react'
export default class Total extends Component {
...
}
Reduxのデータストアにコーポネントを接続するためにラップ(全体を包む)する場合は、完全にラップしたコンポーネントがデフォルトのエクスポートになります。
import React, { Component, PropTypes } from 'react'
import { connect } from 'react-redux'
export class Total extends Component {
...
}
export default connect(() => {...})(Total)
元のコンポーネントをエクスポートしていることに気が付いたかもしれません。純粋なコンポーネントでテストでき、単体テスト用にReduxをセットアップしなくて済むとても便利な方法なのです。
コンポーネントをデフォルトのエクスポートのままにすれば、正確な名前を思い出す苦労もなく、インポートして使うのも簡単です。total.jsxをインポートするなら、コンポーネントはTotalです。user-header.jsxならばUserHeaderになります。
「かしこい」「おバカな」Reactコンポーネント
「かしこい」コンポーネントと「おバカな」コンポーネントの分離について簡単に触れましたが、コードベースでも一貫しています。フォルダーに分けてはいないもののアプリは2種類のコンポーネントに分けられます。
- 「かしこい」コンポーネント:データを操作したり、Reduxに接続したり、ユーザーとのやり取りをするもの
- 「おバカな」コンポーネント:プロパティを与えられて、画面に特定のデータを表示するもの
「おバカな」コンポーネントの狙いは私のブログ記事:Functional Stateless Components in Reactに詳しく書きました。「おバカな」コンポーネントはアプリの中で多数を占めて、(「かしこい」よりも)扱いやすくてバグも少なく、テストもしやすいため、可能な限り「おバカな」コンポーネントを使うべきです。
「かしこい」コンポーネントを作るときも、すべてのJavaScriptのロジックをそのファイルにおさめています。データを加工するコンポーネントは、データを処理するJavaScriptに渡すだけにするのが理想です。処理用のコードはReactから独立してテストができるので、Reactコンポーネントのテストに必要なら別途モックを用意できます。
メソッドの巨大化を避ける
常に心掛けていることの1つに、「大きなコンポーネントを少しずつ持つのでなく小さなコンポーネントをたくさん持つようにすること」があります。コンポーネントが大きいか判定する基準はrender関数のサイズです。render関数が大きくて扱いづらい、もしくは小さなrender関数に分ける必要があるなら、関数を抽象化する段階かもしれません。
大きさにルールはありませんが、コンポーネントを詰め込む前にチームで最適なサイズを考えるといいでしょう。コンポーネントのrender関数のサイズだけでなく、プロパティの数、ステートの項目数も指標になるかもしれません。コンポーネントに異なった7つのプロパティがあるなら多すぎます。
常にprop-typeを使う
Reactは、コンポーネントに与えたいプロパティの名前と型をprop-typesパッケージ内に記述できます。これはReact 15.5以降からの仕様で、それまではproptypesはReactモジュールの一部でした。
与えるプロパティの名前と型を(必須かオプションかの情報も加えて)あらかじめ宣言すれば、コンポーネントを使う際、正しいプロパティなのか分かり、デバッグでも必要なプロパティが抜けていたり型の不整合があったりすればすぐ分かります。これはESLint-React PropTypes ruleで強化できます。
ファイルを書いている時間は無意味に感じられますが、半年前に書いたコンポーネントを再利用するときに半年前の自分に感謝するでしょう。
Redux
多くのアプリケーションのデータ管理にReduxが採用されたため、Reduxのアプリをどんな構成にするかもよくある疑問です。これも人により意見が分かれるところです。
私たちはDucksがベストだと考えています。Ducksはアプリの各部分のaction、reducer、action creatorをまとめて1つのファイルとして扱う提案です。
互いに関連が密なファイルreducers.jsとactions.jsが別々に存在するよりも、関連のあるコード群は1つのファイルとして扱うほうが合理的というのがDucksの考えです。たとえば2つのトップレベルキーuserとpostsを持つReduxのストア(ステートを保持するオブジェクト)があるとします。フォルダーの構造は以下の通りです。
ducks
- index.js
- user.js
- posts.js
index.jsには(おそらくReduxのcombineReducersを用いて)メインのreducerを生成するコードが入っていて、user.jsとposts.jsには関するすべてのコードが含まれます。通常中身は以下の通りです。
// user.js
const LOG_IN = 'LOG_IN'
export const logIn = name => ({ type: LOG_IN, name })
export default function reducer(state = {}, action) {
..
}
actionとaction creatorを別々のファイルからインポートする必要がなくなり、ストアの別の場所にあるコードを隣り合わせに並べることができます。
単独のJavaScriptモジュール
Reactのコンポーネントを解説していますが、ReactのアプリケーションをビルドするにはReactとは切り離されたコードをたくさん書く必要があります。これもReactが好きな点の1つで、大半のコードはコンポーネントから完全に切り離されてます。
コンポーネントにはビジネスのロジックがたくさん詰まっています。別の場所に移すには、経験上フォルダー名をlibかservicesにするといいと思います。名前が重要ではなく「Reactコンポーネント以外」のファイルを固めることが重要なのです。
これらのコードから、いくつかの関数か関数を持つオブジェクトがエクスポートされることがあります。たとえば次のservices/local-storageは、ネイティブのwindow.localStorageのAPIを含んだラッパーを提供します。
// services/local-storage.js
const LocalStorage = {
get() {},
set() {},
...
}
export default LocalStorage
こうしてコンポーネントからロジックを分離することには大きな利点があります。
- どのReactコンポーネントも走らせずに単独でコードのテストができる
- Reactコンポーネントでは、サービスのスタブ(名前だけ合わせた偽物の関数)を使って特定のふるまいと戻り値をもたせたテストができる
テスト
私たちはコードを徹底的にテストします。そのための最良のツール「FacebookのJestフレームワーク」を採用しています。Jestフレームワークは高速で、大量のテストを扱うのに適しています。ウォッチモードで走らせてフィードバックを得るのも早いし、Reactのテストに便利な機能を標準で備えています。私は以前これについて詳しい記事を書いたので、詳細には踏み込みませんが、テストを構成する方法は解説します。
私は別のtestsフォルダーを作り、すべてのテストを実施することにこだわっていました。ファイルsrc/app/foo.jsxがあるなら、対応したtests/app/foo.test.jsxを作成するのです。実際はアプリケーションが大きくなるにつれて求めるファイルに行きつくのが困難になり、あるファイルをsrcフォルダーに入れたときにtestフォルダーにも入れる作業を忘れがちで、整合性が無くなりました。加えてtestsにあるファイルがsrcにあるファイルをインポートしなければならない場合、長いimportになってしまいます。誰もが経験していると思います。
import Foo from '../../../src/app/foo'
これでは扱うのが大変で、しかもフォルダー構造を変えたときに書き直すのも大変です。
それに対して各テストファイルを元のファイルと並べて置けば問題は起こりません。私たちは両者を区別するため、テストには.specという添え字を加えています。.testや-testでもかまいません。元のコードと同じ名前で同じ場所に並べて置きます。
- cart
- total.jsx
- total.spec.jsx
- services
- local-storage.js
- local-storage.spec.js
フォルダーの構造が変わっても、これなら適切なテストファイルを楽に移動できます。テストされていないファイルがあれば一瞬で見つけて修正できます。
最後に
世の中にあるたくさんの問題を解決する方法は、Reactにも当てはまります。このフレームワークのすばらしい特徴の1つは自分で選択したツール、ビルドツール、フォルダー構造を最大限に活かせることです。それを理解して、本記事がReactの大きなプロジェクトへ取り組む際に参考になれば幸いです。やり方はチームの好みでアレンジしてください。
※本記事は、ゲストライターJack Franklinによるものです。SitePointのゲスト投稿では、Webコミュニティの著名な執筆者や講演者の魅力的なコンテンツの提供を目指しています。
(原文:How to Organize a Large React Application and Make It Scale)
[翻訳:西尾 健史/編集:Livit]