問題提起
React.jsでSingle-Page-Applicationを作るときに、どんな構成にしたら捗るのか?を考えてみました。
ReactでSPAといったときにざっと問題として考えられそうなのは、
- サーバーサイドを実装しなくても、クライアントだけで動作確認したい。
- 再利用できそうなコンポーネントと、そのページでしか使わないコンポーネントを分けたい。
- コンポーネントは独立しているべき(グローバル依存しない)
- Reactコンポーネントだけじゃ足りないので、古いライブラリやCSSも扱いたい。
- 開発時のビルドが遅いのはイヤだ。(差分ビルド欲しい)
- 全体をテストするのは重たいから部分的にテストしたい。
- 設定ファイルがあちこちに散らばるのはイヤだ。
- ES6のフィーチャーを使いたいが、下位互換性も担保しないと。
あたりでしょうか。
今回は、この辺の問題をある程度汎用的に解決する案を考えてみました。
ボイラープレートとしてGitHubにも上げてみました。
アプローチ
概要
- タスク、設定は全てgulpで管理する。
- webAPIを備えた仮サーバを立てる。
- bootstrapなど、グローバルに置かざるを得ないものはindex.htmlで読み込む。
- ビルドが重たくなってしまうので、cssや画像などはbowerで取得してindex.htmlで読み込む。
- bower_components以下は、ビルド時にそのままコピーする。
- jestはコマンドライン引数に応じて部分的にテストできるようにする。
- transpilerは、jsxに対応している
6to5
に統一する。
要素技術
- webpack
- gulp
- 6to5
- React.js, jsx
- easymock
- jest
- ES6 Module
要素技術を使ってみた例をいくつか載せているので、よければ見てみてください。
- jest入門 - Mockテストと非同期テスト
- jest + gulpでReactのテストを書く
- gulpでwebAPIを備えた仮サーバを立てる
- gulp + webpack + reactの最小構成
- [require()とは何か?何が便利なのか]http://qiita.com/uryyyyyyy/items/b10b012703b5396ded5a
構成
フォルダ構成はおおまかにこんなかんじです。
.
├── gulpfile.js
├── package.json
├── [buildしたものを入れる]
├── src
│ ├── bower.json
│ ├── index.html
│ ├── main.js
│ ├── [ページ毎にフォルダを設ける]
│ │ └── __tests__
│ │ └── [各ページのコンポーネントのテスト]
│ └── utils
│ ├── functions
│ │ ├── [汎用的な関数群]
│ │ └── __tests__
│ │ └── [functionsのテスト]
│ └── myComponents
│ ├── [今回のアプリで汎用的に使えるコンポーネント群]
│ └── __tests__
│ └── [myComponentsのテスト]
└── webAPI
└── [各種APIのMock]
テストの置き場所
JSはJavaなど静的型言語と違って、コードジャンプが難しかったり対象ファイルを相対パスで指定しなきゃいけなかったりします。
なので、コードとテストは近い位置にあることが望ましいです。
また、jestを使うとテストファイルを勝手に探してくれるので、テストコードがあちこちに散らばってても問題ありません。
bower
bower.jsonをsrc以下においています。なので、bower_componentsもこの位置にインストールされることになります。理由は後のgulpfileのときに説明します。
各ページ毎にフォルダを分ける
domainで区切っています。
SPAはあまり多くのことをするには向いてないとは思いますが、複数ページある場合はそれぞれをはじめから分離しておくのが一般的かと思います。
utils
これも一般的な気がします。
そのアプリケーションの中で繰り返し出てくる関数やコンポーネントは、それぞれのドメインの下ではなくutilityとしてまとめておきたいですよね。
index.html
cssもjsも全て一つにまとめて、html側はbundle.jsだけ読みこめば完了、ってしたいのですが、ビルドに時間がかかったりして現実的ではなかったです。
どうせグローバルに読み込まれるファイル(css)や、モジュール化できないファイル(jqueryPluginとか)は、仕方ないのでindex.htmlで読み込んでしまいます。
(必然的にjQueryもグローバルにインストールされてしまいますね。)
ここは仕方ないかなと思っていますが、いい案があればおしえてください。
gulpfile
長いですが、設定は全てここに収まっています。
var gulp = require('gulp');
var gulpWebpack = require('gulp-webpack');
var webpack = require('webpack');
require("harmonize")();
gulp.task('cleanBuild', function (cb) {
var rimraf = require('rimraf');
rimraf('./build/', cb);
});
gulp.task('copyIndex', ['cleanBuild'], function () {
return gulp.src('./src/index.html')
.pipe(gulp.dest('./build/'));
});
gulp.task('webpackInc', function (cb) {
var config = {
entry: './src/main.js',
output: {
filename: './build/bundle.js'
},
devtool: 'inline-source-map',
module: {
loaders: [
{ test: /\.js$/, loader: '6to5-loader' }
]
},
resolve: {
extensions: ['', '.js']
},
watch: true,
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': 'development'
})
]
};
return gulp.src('')
.pipe(gulpWebpack(config))
.pipe(gulp.dest(''));
});
gulp.task('copyBower', ['copyIndex'], function () {
return gulp.src('./src/bower_components/**')
.pipe(gulp.dest('./build/bower_components/'));
});
gulp.task('webpack', ['copyBower'], function (cb) {
var config = {
entry: './src/main.js',
output: {
filename: './build/bundle.js'
},
module: {
loaders: [
{ test: /\.js$/, loader: '6to5-loader' }
]
},
resolve: {
extensions: ['', '.js']
},
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': 'production'
})
]
};
return gulp.src('')
.pipe(gulpWebpack(config))
.pipe(gulp.dest(''));
});
gulp.task('jest', function (callback) {
var jest = require('jest-cli');
var argv = require('minimist')(process.argv.slice(2));
console.dir(argv);
testPathDirs = argv.path || './src';
var options = {
rootDir: __dirname,
scriptPreprocessor: "<rootDir>/node_modules/6to5-jest",
unmockedModulePathPatterns: ["<rootDir>/node_modules/bluebird"],
testPathDirs: [testPathDirs],
testDirectoryName: "__tests__",
testFileExtensions: ["js"]
}
var onComplete = function(result){callback();};
jest.runCLI({config: options}, __dirname, onComplete);
});
gulp.task('easymock', function () {
var MockServer = require('easymock').MockServer;
var options = {
keepalive: true,
port: 3000,
path: './webAPI'
};
var server = new MockServer(options);
server.start();
});
gulp.task('devServer', ['easymock'], function() {
var webserver = require('gulp-webserver');
gulp.src('./build/')
.pipe(webserver({
livereload: false,
directoryListing: false,
open: false,
proxies: [{
source: '/webAPI',
target: 'http://localhost:3000/'
}]
}));
});
gulp.task('watch', ['webpackInc'], function () {
gulp.watch(['./src/**/*.js', './src/index.html'], ["webpackInc"]);
});
gulp.task("develop", ["devServer", "watch", "copyBower"]);
gulp.task("build", ["webpack", "jest"]);
6to5
transpilerは6to5に統一しています。
理由は、jsxもサポートしていてjestでもwebpackでも使えるから。
bower_components
cssやモジュール化されていないライブラリを使うときは、下手にrequire()
しようとするよりindex.htmlでそのまま読み込んだほうが良いと思いました。理由は色々ミスるしビルドが重くなるから。
で、そのまま読み込めるようにするためにbower_componentsフォルダはビルド時にbuildフォルダにそのままコピーするようにしています。
できれば全部npmで管理したいんですけどね。。。
webpack
webpackのタスクは開発時と本番時で分けています。
開発時はソースマップ付きの差分ビルド、本番時は余計なことしないビルドです。
gulpファイルにまとめたことで設定も分けられています。
jest
全体テスト用と部分テスト用で分けています。
特に引数がないときは全体ビルド用、--path
オプションがあるときはその階層以下のテストファイルだけテストします。
本当はファイル名を指定したかったんだけど、そういう設定は見つかりませんでした。
webserver
クライアントサイド用の仮サーバーを立てます。
webAPI付きなので、実際とほぼ変わらない操作ができます。
まとめ
心配な点は、ある程度のグローバル汚染を許容していることと、まだ実運用で使ってないので漏れがあるだろうということですね。。。
早くReactコンポーネントが充実してくることを願うばかりです。
ちなみに、今回の構成を考えるにあたって試行錯誤したリポジトリはこちらになります。