エンジニアの大塔です。 現在はRailsアプリのフロントをメインで触ってます。
webpackerに頼らないフロントエンドビルド環境を構築してみたので、そのお話をします。
背景
フロントエンドではNode.jsを使ってJavaScriptやCSS等のアセットをトランスパイルすることが一般的になったかと思います。
思い出すだけでも、フロントエンドではgrunt, Browserify, webpack, SystemJS, rollup.jsと色々なフロントエンドのビルドに絡むツールが出てきました。
自分自身も、元々はBrowserifyやwebpackでES6やTypeScriptのビルドを実行していました。
そのため、初めてRails絡みのプロジェクトに参加してから、Sprocketsによって管理されるアセットに触れ、モダンなフロントエンドとRailsの折衷をどうすれば良いか色々考えていました。 例えば、webpackやBrowserifyでビルドを行い、Railsには成果物にダイジェストをつけてもらうだけの構成や、ダイジェストまでつけてそのままpublicディレクトリに置いてしまうなど。。。
そんな中でRailsでもwebpackがサポートされるなど、ここにきてRailsでもフロントエンドの進展が取り込まれました。
Rails 5.1.0.beta1 を webpacker と一緒に触ってみましたが、すごく手早く、簡単にReact等の環境を構築することができました。
しかし、色々やってみるなかで、サブディレクトリ以下のJavaScriptは現時点でのデフォルトでは、エントリーポイントにならなかったり、逆にエントリーポイントにしないようにするための条件を設定する方法など、色々自分で調べてカスタマイズする必要がありそうです。
おそらくこれから、どんどんRails内部でブラッシュアップされて、多様なカスタマイズも簡単にできるようになっていくと思われます。
ということで、まだ細かくカスタマイズする場合には、webpackで環境構築する知識がまだまだ求められそうということで、今回はwebpackerに頼らないフロントエンドビルド環境を一例としてつくってみたいと思います。
- JavaScriptをwebpackでビルド
- StylesheetはScssをgulp-sassでビルド

作成したものはこちらに配置しておきます。
https://github.com/otoatarumf/webpack-rails-integration-sample
環境
- Rails 5.0.1
- Ruby 2.3.1p112
- NodeJS v6.9.5
全体
全てのページには下記のCSSファイルとJSファイルがロードされます。 これらのファイルは巨大になりがちなので、まとめておいて、ブラウザにキャッシュさせることを想定しています。
- application.css (リセットCSSやSMACSSにおけるCSSコンポーネント群など複数ページにまたがって使用されるもの)
- application.js (全ページで共通に用いられるJavaScriptライブラリをバンドルしたもの。)
app/views/layouts/application.html.slim
doctype html
html
head
meta (charset="UTF-8")
meta (name="viewport" content="width=device-width")
title SampleAssets
= stylesheet_link_tag('application')
= render 'partial/assets/stylesheet'
body
= yield
= javascript_include_tag('application')
= render 'partial/assets/javascript'
= csrf_meta_tags
それと同時に各ページには個別にページ固有のJavaScriptファイルとCSSファイルがロードされます(存在すれば)。
例えば、sample1_controller.rbのindexアクションであれば、 "staylesheets/sample1/index.css" と "javascripts/sample1/index.js" が読み込まれます。
app/views/partial/assets/_javascript.html.slim
- file_name = "#{controller_path}/#{action_name}.js"
- file_path = Rails.root.join('app', 'assets', 'javascripts', file_name)
- if File.exists?(file_path) || Rails.env.development?
= javascript_include_tag(file_name)
app/views/partial/assets/_stylesheet.html.slim
- file_name = "#{controller_path}/#{action_name}.css"
- file_path = Rails.root.join('app', 'assets', 'stylesheets', file_name)
- if File.exists?(file_path) || Rails.env.development?
= stylesheet_link_tag(file_name)
ディレクトリ構成
assetsディレクトリの同階層に新たに assets_src ディレクトリを作成し、トランスパイル前のソースコード群を格納します。

フロントエンドのタスク群に関連するものは全て、node_tasksディレクトリ中に収めます。今回はやっていないのですが、ESLintの設定ファイルやCSSから自動生成するスタイルガイドの設定、csscombなどもこのディレクトリに含めてもいいかもしれません。

ビルド方針
"assets_src/stylesheets" 以下にある Scssファイル と "assets_src/javascripts" 以下にある .jsファイル がトランスパイルされて、 "assets/stylesheets" および "assets/javascripts" に配置されます。先頭に "_" が付与されているファイルおよび "_stylesheets"ディレクトリ 、および "_javascripts"ディレクトリ 中のファイルはエントリーポイントにしません。これらのファイルはそのファイル自体をエントリーポイントにはせずに、エントリーポイントにすべきファイルからのインポート対象としてコンパイルされることを想定しています (例えば、ReduxにおけるReducersやActionCreators, AngularにおけるComponentなど)。
app/assets 以下にファイル群がトランスパイルして、吐き出されるのであとはそれを bundle exec rails assets:precompile でプリコンパイルします。
JavaScriptのビルド設定
webpackを使います。アプリ全体で横断的に用いるライブラリは CommonsChunkPlugin を使って application.js にバンドルします。
const glob = require('glob');
const Util = require('./util');
const commonFileName = "app/assets/javascripts/application.js";
const webpack = require('webpack');
const entries = (() => {
let ret= {};
const files = glob.sync(Util.path.javascript, {
ignore: Util.path.javascript_partial
});
files.forEach((filePath) => {
const key = filePath.replace(/assets_src/, 'assets');
ret[key] = filePath;
});
return ret;
})();
const getCommonChunksPlugin = () => {
return new webpack.optimize.CommonsChunkPlugin({
name: commonFileName,
minChunks: 2
})
};
const getPlugins = () => {
if (process.env.NODE_ENV === 'production') {
return [
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify('production')
}
}),
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false
}
}),
getCommonChunksPlugin()
]
} else {
return [
getCommonChunksPlugin()
]
}
};
module.exports = {
entry: entries,
module: {
loaders: [{
test: /\.js?$/,
exclude: /node_modules/,
loader: 'babel-loader',
query: {
"presets": ["react", "es2015"],
"plugins": []
}
}]
},
output: {
filename: "[name]",
jsonpFunction: "myBundleFunction"
},
plugins: getPlugins()
};
スタイルシート
ちょっと古い構成かもしれませんが、gulp-sass でビルドします。 chokidar を使って、ファイルの変更の監視を行いつつ、ビルドします。
'use strict';
const autoprefixer = require('gulp-autoprefixer');
const chokidar = require('chokidar');
const clean = require('gulp-clean-css');
const glob = require("glob");
const gulp = require('gulp');
const plumber = require('gulp-plumber');
const scss = require('gulp-sass');
const sourcemaps = require('gulp-sourcemaps');
const Util = require('./util');
class StyleTask {
constructor() {
const styleSheetWatcher = chokidar.watch(
Util.path.stylesheets, { persistent: true }
);
const componentWatcher = chokidar.watch(
Util.path.style_components, { persistent: true }
);
styleSheetWatcher
.on('add', (filePath) => {
this.compile(filePath);
})
.on('change', (filePath) => {
this.compile(filePath);
});
componentWatcher.on('change', () => {
glob(Util.path.stylesheets, {}, (err, files) => {
if(err) Util.methods.logError(err);
files.forEach((file) => {
this.compile(file);
})
});
});
}
compile(filePath) {
Util.methods.logInfo(`Compiling ${filePath} ...`);
return gulp.src(filePath, { base: Util.path.stylesheets_src_dir })
.pipe(plumber())
.pipe(sourcemaps.init())
.pipe(scss())
.pipe(autoprefixer({browsers: ['ie 11', 'safari >= 6', 'Android >= 4', 'last 4 versions']}))
.pipe(clean({ advanced:false }))
.pipe(sourcemaps.write())
.pipe(gulp.dest(Util.path.stylesheets_dir));
}
}
module.exports = StyleTask;
パターン
ちょっと雑なまとめかたですが glob や chokidar で用いるパターンはここにまとめておきます。
'use strict';
const chalk = require('chalk');
module.exports = {
path: {
stylesheets: "./app/assets_src/stylesheets/**/*.scss",
style_components: "./app/assets_src/stylesheets/**/*.scss",
stylesheets_src_dir: "./app/assets_src/stylesheets/",
stylesheets_dir: "./app/assets/stylesheets/",
javascript: "./app/assets_src/javascripts/**/*.js",
javascript_partial: "./app/assets_src/javascripts/**/_*.js",
},
methods: {
logInfo: (msg) => {
console.log(chalk.magenta.bold(msg));
},
logError: (msg) => {
console.log(chalk.magenta.bold(msg));
}
}
};
動作
package.json (一部)
"scripts": {
"build": "node node_tasks/index.js & webpack --watch --config node_tasks/webpack.config.js",
"prod": "NODE_ENV=production node node_tasks/index.js & NODE_ENV=production webpack --watch --config node_tasks/webpack.config.js"
},
npm install // yarnがインストールされていれば yarn install npm run prod // yarnがインストールされていれば yarn run prod

"assets_src" 以下のファイル群が"assets"以下にトランスパイルされます。 "_" をつけたファイルはエントリーポイントではないので、トランスパイルされていません。それぞれのimport元にインポートされて、コンパイルされます。
- Before

- After

あとは assets:precompile でプリコンパイルして、publicディレクトリに放出するとともにダイジェストをつけます。
bundle exec rails assets:precompile
最後に
マネーフォワードでは、エンジニアを募集しています。 ご応募お待ちしています。
【採用サイト】 ■マネーフォワード採用サイト ■Wantedly | マネーフォワード
【プロダクト一覧】 自動家計簿・資産管理サービス『マネーフォワード』 ■Web ■iPhone,iPad ■Android
ビジネス向けクラウドサービス『MFクラウドシリーズ』 ■会計ソフト『MFクラウド会計』 ■確定申告ソフト『MFクラウド確定申告』 ■請求書管理ソフト『MFクラウド請求書』 ■給与計算ソフト『MFクラウド給与』 ■経費精算ソフト『MFクラウド経費』 ■入金消込ソフト『MFクラウド消込』 ■マイナンバー管理ソフト『MFクラウドマイナンバー』 ■資金調達サービス『MFクラウドファイナンス』
メディア ■くらしの経済メディア『MONEY PLUS』