Reactを使ったアプリケーションを作る機会があったので、改めてその際に学んだことを整理しました。
これを読むことでReactを使って開発する際のwebpackの基本的な設定は理解していただければ幸いです。
ただ設定を載せるのではなく何のためにそうするのかを意識して伝えるようにしました。
実際のソースコードはこちらです。
前提
対象
- 今までReactを書いていたが、どうやって環境構築されているのかが分からない
- webpackを使ってReactの環境構築をしたい
- flowも使って型定義もしたい
環境
$ node -v
v10.11.0
$ yarn webpack -v
4.20.2
webpackとは
webpackとは、モジュールバンドラのこと。モジュールとは、依存関係(互いに参照したりしている関係)のある複数のファイルのまとまりのことで、それらを一つのファイルに束ねる(bundleする)主体をバンドラと呼ぶ。
つまり、webpackは複数のファイルを一つのファイルにまとめることができる。
ではなぜそんなことをするのかと言うと、JavaScript, HTML, CSSなどはブラウザから実行され、描画されることが前提としてある。そのためには、それらのファイルをサーバーからダウンロードし、ブラウザが実行する必要があるが、その際に問題になることがある。
- サーバーにある複数のファイル分だけリクエストをして読み込むことはできない(ユーザーは永遠と待ち続けることになる)
- ブラウザのJavaScriptエンジンなどが必ずしも実行可能、最適化されている訳ではない(人が書いたコードがそのまま機械が実行しやすい訳ではない)
これらの問題を解決するためにモジュールバンドラは複数のファイルをまとめてブラウザが実行しやすいようなファイルを生成する。生成されたファイルはバンドルファイルと呼ばれる。
ここで一旦用語を整理しておく。
- モジュール: 機能ごとに分割されたファイルまとまり
- バンドルファイル: まとめられたファイル
- ビルド: バンドルファイルを作ること
- エントリーポイント:最初に読み込まれるファイル
CommonJSをバンドルする
では実際にWebpack(4)を使ってモジュールをバンドルしてみる。
以下のようなディレクトリ構造で実装する。
├ dist/ <- ビルド先
| ├ index.html <- ブラウザから最初に読み込まれるHTML
| └ javascripts/
| └ index.js
├ src/ <- ビルド対象の実装
| ├ roots/ <- webpackから最初に読み込まれるJSファイル
| | └index.js
| └ components/
| └app.js
├ webpack.config.js
└ package.json
$ mkdir -p dist/javascripts
$ mkdir -p src/roots
$ mkdir -p src/components
webpackとwebpack-cliをインストールする。
$ yarn add -D webpack webpack-cli
// src/components/app.js
export default function printMe() {
console.log('I get called from app.js!');
}
// src/roots/index.js
import App from '../components/app.js';
function component() {
const element = document.createElement('div');
const btn = document.createElement('button');
btn.innerHTML = 'Click me and check the console!';
btn.onclick = App;
element.appendChild(btn);
return element;
}
document.body.appendChild(component());
// dist/index.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>デモ</title>
</head>
<body>
<div id="app"></div>
<script src="./javascripts/index.js"></script>
</body>
</html>
entryでビルドするソースコードを指定する
entryで指定したソースコードの依存関係(そのファイルから連鎖的にimportされているファイルの関係)を解決する。
出力するバンドルファイルの名前を指定したり、複数ファイルを指定したりできる。
module.exports = {
entry: {
app: './src/app.js', // app.jsに
adminApp: './src/adminApp.js' // adminApp.jsに
}
};
outputでバンドルファイルを書き出すパスとファイル名を指定する
entryで指定したモジュールをビルドして、それをどのファイル名でどこに出力するかを指定できる。
[name]
で複数のモジュールの名前を動的に埋め込めることも可能。
module.exports = {
entry: {
app: './src/app.js', // app.jsに
search: './src/search.js' // search.jsに
},
output: {
filename: '[name].js', // dist/app.jsとdist/search.jsが出力される
path: __dirname + '/dist'
}
};
実際にやってみる。
// webpack.config.js
const path = require('path');
module.exports = {
entry: './src/roots/index.js',
output: {
filename: 'index.js',
path: path.resolve(__dirname, './dist/javascripts')
}
};
$ yarn webpack --mode production
Hash: b2a81a358517f65d5c09
Version: webpack 4.20.2
Time: 98ms
Built at: 2018/10/07 13:15:00
Asset Size Chunks Chunk Names
index.js 1.17 KiB 0 [emitted] main
Entrypoint main = index.js
[0] ./src/roots/index.js + 1 modules 409 bytes {0} [built]
| ./src/roots/index.js 327 bytes [built]
| + 1 hidden module
Done in 0.71s.
これが実際にブラウザから読み込まれることになる。
// dist/javascripts/index.js
!function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=0)}([function(e,t,n){"use strict";function r(){console.log("I get called from app.js!")}n.r(t),document.body.appendChild(function(){const e=document.createElement("div"),t=document.createElement("button");return t.innerHTML="Click me and check the console!",t.onclick=r,e.appendChild(t),e}())}]);
webpack-dev-serveでhtmlを返すようにする。
$ yarn add -D webpack-dev-server
// webpack.config.js
const path = require('path');
module.exports = {
...
devServer: {
contentBase: './dist'
},
};
$ yarn webpack-dev-server
$ curl http://localhost:8080 // index.htmlが返ってくる
$ curl http://localhost:8080/javascripts/index.js
ReactのJSをバンドルする
Reactを導入する
$ yarn add react react-dom
// src/roots/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from '../components/app.js';
ReactDOM.render(<App message={'これはデモです。'}/>, document.getElementById('app'));
// src/components/app.js
import React from 'react';
export default class App extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
this._handleOnClick = this.handleOnClick.bind(this);
}
handleOnClick() {
this.setState(prevState => ({ count: prevState.count + 1 }));
}
render() {
return (
<div>
<div>{this.props.message}</div>
<button onClick={this._handleOnClick}>
{`Current Count is ${this.state.count}`}
</button>
</div>
);
}
}
エラーが起こる。理由はes6などの記法に対応できていないから。
$ yarn webpack
...
ERROR in ./src/roots/index.js 5:16
Module parse failed: Unexpected token (5:16)
You may need an appropriate loader to handle this file type.
| import App from '../components/app.js';
|
> ReactDOM.render(<App message={'これはデモです。'}/>, document.getElementById('app'));
|
error Command failed with exit code 2.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
babelの設定
babelとはJavaScriptのトランスパイラ。ブラウザが実行可能なに変換することができる。
具体的にはES6以上のJavaScriptをそれ以前にバージョンに変換したり、JSXやこの後で出てくるflowによる型宣言なども変換したり取り外してくれる。ここで実際に試してみることもできる。
// src/roots/index.js
'use strict';
var _react = require('react');
var _react2 = _interopRequireDefault(_react);
var _reactDom = require('react-dom');
var _reactDom2 = _interopRequireDefault(_reactDom);
var _app = require('../components/app.js');
var _app2 = _interopRequireDefault(_app);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
_reactDom2.default.render(_react2.default.createElement(_app2.default, { message: 'これはデモです。' }), document.getElementById('app'));
babelを使ってブラウザが実行可能なJavaScriptに変換した上でwebpackがビルドすることで上記のエラーを回避することができる。
そしてwebpackのloaderによってビルドする前にどのようにファイルを変換するかを設定することができる。
また、loaderは複数指定することができ、それらは右から左にそれぞれの結果を次に渡して、実行される。
以下は、SCSSファイルをCSSファイルに(sass-loader)、それらのCSSファイルの依存関係を解消し(css-loader)、Styleタグに埋め込む処理(style-loader)を逐次的に行なっている。
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
{ loader: 'style-loader' },
{
loader: 'css-loader',
options: {
modules: true
}
},
{ loader: 'sass-loader' }
]
}
]
}
};
ここではbabel-loaderを使い、optionsで各loaderの設定を指定する。
$ yarn add -D babel-loader @babel/core @babel/preset-env
$ yarn add -D @babel/preset-react // react用のbabelの設定
// webpack.config.js
module.exports = {
...
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
"presets": ['@babel/preset-env', '@babel/preset-react']
}
},
exclude: [/node_module/]
}
]
},
};
より実装しやすくするためのwebpackの設定
resolve
モジュールの依存関係の解決に関する設定で、webpackが読み込まれている(import
されている)パスをどのように見つけるかを決められる。
// webpack.config.js
const path = require('path');
module.exports = {
...
resolve: {
extensions: ['.js'], // 指定した拡張子は省略できる
modules: [ // どこからモジュールを探すべきなのかを指定できる
path.join('src'),
path.join('node_modules')
]
}
};
このような設定をすると、以下のようにimport
ができるようになり、大きなプロジェクトでは便利になる。
// roots/index.js
...
import App from 'components/app'; // import App from '../components/app.js';
...
モード
modeによってビルド時に使用されるpluginが変わる。
デフォルトではproductionモードで、productionモードではuglifyJspluginなどがonになる(圧縮される)。
それらの違いはこちらを参照のこと。
package.jsonでコマンドを追加する
package.jsonのscripts
に指定したオブジェクトのキーでyarn 指定したcommand
のように、対応するオブジェクトの値のコマンドを実行できる。下記のようにすると、yarn build
でProductionモードでビルドしたり、開発時はwatchオプションでビルドしたりできる。
- --mode=(development|production): モードの指定
- --watch: ファイルに差分が生じると自動で再ビルドする
- --progress: ビルドの進捗をパーセントで表示する
- --config: 設定ファイル(デフォルトでは webpack.config.js or webpackfile.js)
{
"scripts": {
"build": "webpack --mode production",
"watch": "webpack --mode development --watch --progress",
"server": "webpack-dev-server --port 8080 --open"
},
"devDependencies": {
...
},
...
}
その他のプラグイン
他にも便利なプラグインがあるので開発に応じて選ぶことができる。
-
contenthash
- 内容が変わる度にハッシュ値をファイル名に含め、キャッシュされないようにする
- railsの場合はconfig.assets.digestの設定で自動でやってくれる
-
html-webpack-plugin
- 生成したバンドルファイルを読み込むhtmlを自動生成してくれる
- バンドルファイル名が動的に変わってもそのパスが指定された状態で生成されるので、hash値で動的にバンドルファイル名を変えた時などに有用
- templateを指定して雛形を用意することもできる
-
clean-webpack-plugin
- 古いバンドルファイルをビルド前に削除する
flowの導入
flowをインストールするだけでなく、babelでflowの記述を取り除いてビルドできるようにもする。
preset-flowを参照。
$ yarn add -D flow-bin
$ yarn add -D @babel/preset-flow
// .flowconfig
[ignore]
.*/node_modules/.*
const path = require('path');
module.exports = {
...
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
"presets": [
'@babel/preset-env',
'@babel/preset-react',
'@babel/preset-flow' // 追加
]
}
},
exclude: [/node_module/]
}
]
},
...
};
// components/app.js
// @flow
import React from 'react';
type Props = {
message: string
};
type State = {
count: number
}
export default class App extends React.Component<Props, State> {
_handleOnClick: () => void;
constructor(props: Props) {
...
}
...
}
// @flow
import React from 'react';
import ReactDOM from 'react-dom';
import App from 'components/app';
ReactDOM.render(<App message={'これはデモです。'}/>, document.getElementById('app'));
yarn flow check
でflowのチェックができるようになる。
webpackの名前解決の設定との整合性をとる
ここで、以下のようにsrc/components/sub.js
というコンポーネントを追加し、src/components/app.js
から読み込むようにする。
// src/components/sub.js
// @flow
import React from 'react';
const Sub = (props: { message: string }) => <div>{props.message}</div>;
export default Sub;
// src/components/app.js
// @flow
import React from 'react';
import Sub from 'components/sub';
...
export default class App extends React.Component<Props, State> {
...
render() {
return (
<div>
<Sub message={this.props.message} />
...
</div>
);
}
}
しかし、flowでエラーになってしまう。
$ yarn flow check
Error ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ src/components/app.js:4:17
Cannot resolve module components/sub.
1│ // @flow
2│
3│ import React from 'react';
4│ import Sub from 'components/sub';
5│
6│ type Props = {
7│ message: string
Found 1 error
error Command failed with exit code 2.
理由は上記でwebpackが名前解決するモジュールのルートパスを./src
として、以下のような書き方ができるようになったが、flowはwebpackの設定とは別なのでflowにも設定が必要なため。
// src/components/app.js
// @flow
import React from 'react';
import Sub from 'components/sub';
// import Sub from './sub.js'; だとエラーにならない
...
そこで同じようにflowの設定をして解決してあげることができる。
詳しくはmodule.name_mapperを参照のこと。
[options]
module.name_mapper='^components\(.*\)$' -> '<PROJECT_ROOT>/src/components/\1'
これでflowも通る。
$ yarn flow check
Found 0 errors
Done in 1.24s.