Septeni Engineer's Blog

セプテーニエンジニアが綴る技術ブログ

SPAじゃなくてもWebpack導入(ファイル監視&自動リロード + レガシーブラウザ対応 + JS最適化 + ES2015(ES6) + SCSS + ESLint)

Septeni Originalの大久保です。

image

世間ではSPAが流行ってるかと思います。

ただHTMLのレンダリングはサーバー側で行うケースは以前多いのではないでしょうか?

現在自分が携わってる新規プロダクトでモダンなフロントエンドの技術(WebapackやES2015など)を使いつつ、HTMLレンダリングはサーバーに委ねるようなアーキテクチャ(Multi Page Application)の調査を行ったので、そこでの知識を備忘録的にまとめたいと思います。

今回作成したコードはGithubにあげているので、興味あればご参照ください。 Github URL:https://github.com/yoppe/webpack-es2015-base-for-play

#やりたいこと

 ⇒ リリース時などにバックとフロントを別々にリリースしたりデプロイフローを複雑化させたくない。

  • バックは可能なかぎりフロントの影響を受けないようにする。

 ⇒ フロントからのリクエストを処理するためにOriginを許可したりディレクトリ構成意識をさせたりと無駄に考えること増やしたくない。

  • シームレスな開発ができるようにする。

 ⇒ 昔ながらのMulti Page Applicationで作るからといってF5駆動開発はしたくない。

  • ページ単位でのJSファイルの分割。

 ⇒ ページ遷移時の読み込みを少しでも高速化したい。

  • シンプルにする。

 ⇒ Simple is Best.

  • ビルドツールはWebpackに集約する。

 ⇒ gulpとかは使わない。

#イメージ構成

<本番環境>

image

本番環境ではバックエンドのサーバ単体で実行できるようなMulti Page Applicationらしい構成。

<開発環境>

image

開発時はフロントのソース変更を監視してリアルタイムに画面更新をしたいので、クライアントからのリクエストに対してWebpackのプロキシサーバーを挟む構成。(バックエンドサーバーは9000番でプロキシサーバは3000番)

#バックエンド

フロントエンドもバックエンドに依存するような作りではないため、Railsなどでも使えると思います。

Playの設定

Webpackによって生成ファイルのパスをPlayで参照するAssetsのディレクトリに追加する必要があります。 そのため、下記一行を.sbtに追加します。

unmanagedResourceDirectories in Assets += baseDirectory.value / "frontend"  / "dist"

ちなみにroutesファイルをいじる必要はありません。下記のassetsで指定されてるpath('/public')は、パッケージ化された時に参照するパスで、デフォルトではAssetsは最終的にすべて/publicにまとめられます。

GET     /assets/*file                     controllers.Assets.versioned(path="/public", file: Asset)

#jsの読み込み

Multi Page Applicationなので各ページでバンドルされたjsを読み込ませる必要があります。

@(message: String)

@main("Welcome to Play") {

  <script type="text/javascript" src="@routes.Assets.versioned("todo.bundle.js")"></script>

...

共通のjs(vendor.jsなど)も同じようにHTML上で読み込ませます。

...

<head>
  ...
  <script type="text/javascript" src="@routes.Assets.versioned("vendor.js")"></script>
</head>

...

#フロントエンド

  • 言語:ES2015
  • トランスパイラ:Babel
  • ビルドツール:Webpack
  • CSSメタ言語:SCSS

その他、開発環境と本番環境で設定ファイルを分けれるようにし、コード上でも環境変数を通して開発環境と本番環境のコードを分けれるようにする。また開発環境ではESLintのチェックを行い、本番環境ではコードの圧縮・最適化が行えるようにWebpackの設定自体を分けれるようにしました。

それでは以下に各Webpackの設定を説明していきます。

■ package.json

タスクの設定

Webpackを使うためgulpなどは使わず、基本的にnpmのコマンドのみで実行できるように、scriptsに開発上必要な各タスクを記述。

一部抜粋。

npm run start: 開発用のサーバの立ち上げ。

npm run clean: npmのキャッシュとdistに生成されたコードを削除。

npm run build:prod: 本番環境用の設定ファイルを使い、デプロイ用のコードを生成。

--config にて読み込むWebpackのファイルを切り替えることで環境を切り分けれるようにしてる。

{
  "name": "todo",
  "version": "0.0.1",
  "description": "todoのnodeライブラリ管理",
  "main": "index.js",
  "scripts": {
    "build:base": "webpack --display-error-details --progress --profile --bail",
    "build:prod": "npm run build:base -- --config config/webpack.production.babel.js",
    "build:dev": "npm run build:base -- --config config/webpack.development.babel.js",
    "build": "npm run build:dev",
    "watch:prod": "npm run build:prod -- --watch",
    "watch:dev": "npm run build:dev -- --watch",
    "watch": "npm run watch:dev",
    "start:base": "webpack-dev-server --inline --hot --open",
    "start:prod": "npm run start:base -- --config config/webpack.production.babel.js",
    "start:dev": "npm run start:base -- --config config/webpack.development.babel.js",
    "start": "npm run start:dev",
    "clean:base": "npm cache clean && rimraf dist",
    "clean:all": "npm run clean:base && rimraf node_modules",
    "clean": "npm run clean:base"
  },
  "license": "ISC",
  "devDependencies": {
    "autoprefixer": "^6.5.1",
    "babel-core": "^6.17.0",
    "babel-loader": "^6.2.5",
    "babel-polyfill": "^6.16.0",
    "babel-preset-es2015": "^6.16.0",
    "babel-register": "^6.16.3",
    "css-loader": "^0.25.0",
    "eslint": "^3.8.1",
    "eslint-config-standard": "^6.2.1",
    "eslint-loader": "^1.6.0",
    "eslint-plugin-promise": "^3.3.0",
    "eslint-plugin-standard": "^2.0.1",
    "file-loader": "^0.9.0",
    "jquery": "^3.1.1",
    "lodash": "^4.16.4",
    "node-sass": "^3.10.1",
    "postcss-loader": "^1.0.0",
    "require-dir": "^0.3.1",
    "rimraf": "^2.5.4",
    "sass-loader": "^4.0.2",
    "style-loader": "^0.13.1",
    "uglify-save-license": "^0.4.1",
    "url-loader": "^0.5.7",
    "webpack": "^1.13.2",
    "webpack-dev-server": "^1.16.2",
    "webpack-merge": "^0.15.0"
  }
}

<各パッケージの説明>

autoprefixer: CSSに自動でブラウザ互換用のプレフィックス付与。

babel-core: JSのトランスパイルに必要。

babel-loader: JSのトランスパイルに必要。

babel-polyfill: レガシーブラウザでもある程度動くコードにJSを変換できるようにする。

babel-preset-es2015: ES2015をES5トランスパイルするのに必要。

babel-register: webpackの設定ファイル(.babel.js)をES2015で記述できるようにする。(これを使わない場合、Node.JSでの記述が必要になる。昔はbabel-core/register内に入っていたらしい)

css-loader: WebpackでCSSの読み込み設定を記述できるようにする。

eslint: ES2015のコーディング規約の静的チェックツール。

eslint-config-standard: コーディング規約のテンプレート。(他にも、eslint-config-google、eslint-config-airbnbなどあり)

eslint-loader: WebpackでESLintを実行できるようにする。(IDEでチェックする場合は不要)

eslint-plugin-promise: コーディング規約のテンプレート。

eslint-plugin-standard: コーディング規約のテンプレート。

file-loader: Webpackで画像などのファイルを読み込めるようにする。

node-sass: SCSSのコンパイルに必要。

postcss-loader: WebpackでCSSオートプレフィックスの設定を記述できるようにする。

rimraf: nodeでファイル/ディレクトリを削除できるようにする。

sass-loader: SCSSのコンパイルに必要。

style-loader: CSSの依存関係(import)解決に必要。

uglify-save-license: ライセンス情報を保持した状態でJSを圧縮できるようにする。

url-loader: Webpackで画像などのファイルを読み込めるようにする。

webpack-dev-server: Webpackのファイル監視/プロキシサーバーを立ち上げるのに必要。

webpack-merge: 複数のwebpackのファイルをマージできるようにする。

一部あいまいな箇所がありますが、ざっくりこんな感じだったと思います。(何か訂正などあればご指摘ください。しかし多いな、、)

■ webpackの記述

[ビルド対象のファイル設定、バンドル先の設定]

  ...
  
  entry: {
    vendor: ['babel-polyfill'],

    todo: [
      `${ROOT}/src/main/scripts/todo/index.js`,
      `${ROOT}/src/main/styles/todo/index.js`
    ]
  },
  
  output: {
    path: `./dist`,
    filename: '[name].bundle.js',
    publicPath: '/assets/'
  },

  ...  
- entry

JSの入力に関する設定を記述。

vendorは各JS上で読み込む全ページ共通のライブラリを指定し、その他はページ単位で指定。

todoの場合、todoページで必要なJSのみ読み込みバンドルする。

もし、全ページで読み込みたくはないが複数ページで読み込みたいモジュールがある場合は、別途CommonsChunkPluginの設定をいじる必要あり。(下記[使用ライブラリの共通化]の項に記載)

公式:https://github.com/webpack/docs/wiki/Configuration#entry

- output

JSの出力に関する設定を記述。

publicPathは埋め込む画像などの参照に必要な(URLに組み込まれる)パスとして指定される。

また、WebpackDevServerの起動時もAssetsの配信サーバーのURLとして機能する。

バックもフロントもこのURL(publicPath)からJSなどを配信するため、バックエンドに合わせて指定する必要がある。

公式:https://github.com/webpack/docs/wiki/Configuration#entry

[各ローダーの設定]

> ES2015の読み込み
  ...
  
  module: {
    loaders: [

      {
        test: /\.js$/,
        exclude: /(node_modules|bower_components)/,
        loader: 'babel-loader'
      },
  ...  

.babelrc

{
  "presets": ["es2015"]
}

ES2015をbabelでトランスパイルして、ES5に変換する。

ライブラリはトランスパイルする必要ないのでexcludeで指定。

ES2015の指定に関しては.babelrcファイルで行う。このファイル名にしてれば勝手に読み込んでくれる。(babel-loaderのパラメータ指定での設定では動かないことがあった。何かミスってたかも)

query.cacheDirectory: trueを設定すると高速になるらしい(未検証)

query.commentscompactを設定することでUglifyみたいなことができるらしい(未検証)

ES5の対応ブラウザ: http://kangax.github.io/compat-table/es5/

公式: https://github.com/babel/babel-loader

> SCSSの読み込み
  ...
  
  module: {
    loaders: [

      {
        test: /\.scss$/,
        loaders: [
          'style-loader',
          'css-loader',
          'postcss-loader',
          'sass-loader'
        ]
      },
  ...  

SCSSをコンパイルして、CSSに変換する。

style-loader: DOMへのスタイルタグを差し込む

css-loader: CSSの依存関係(import)解決

postcss-loader: CSSのプレフィックスを付けて

sass-loader: SCSSのコンパイル

このローダーには順序があり下から順番に実行されていく(たぶん)

公式: https://webpack.github.io/docs/list-of-loaders.html#styling

> 画像の読み込み
  ...
  
  module: {
    loaders: [

      {
        test: /\.(jpg|png|gif)$/,
        loader: 'url-loader',
        query: {
          limit: 10240
        }
      }
  ...  

JSやCSS上で扱っている画像ファイルのURLを自動解決し、10Kバイト以下の画像ファイルはBase64で出力。

Base64にしてるのは画像のHTTPリクエスト数を減らすことで表示を高速化できるため。

公式: https://webpack.github.io/docs/list-of-loaders.html#packaging

[CSSプレフィックスの設定]

  ...

  postcss: [
    autoprefixer({
      browsers: ['> 0.1% in JP']
    })
  ],
  
  ...  

対応するレガシーブラウザを設定。

browsersで設定れたブラウザに対応できるようにプレフィックスを自動付与。

[ > 0.1% in JP ]の設定では、日本で利用者が0.1%以上いるブラウザを対象に、CSSプレフィックスを付与するように指定してる。

[ > 0.1% in JP ]で実際に適用されるブラウザのチェック: http://browserl.ist/?q=%3E+0.1%25+in+JP

browserslist公式: https://github.com/ai/browserslist

autoprefixer公式: https://github.com/postcss/autoprefixer

[Webpack Dev Serverの定義]

  ...

  devServer: {
    contentBase: './dist',
    port: 3000,
    proxy: {
      '**': {
        'target': {
          'protocol': 'http:',
          'host': 'localhost',
          'port': 9000
        }
      }
    }
  },
    
  ...  

3000番のフロント開発サーバーを立ち上げ、contentBaseのパス上にないファイルのリクエストに関しては、プロキシによって9000番に飛ばす。

公式:https://webpack.github.io/docs/webpack-dev-server.html

[別ファイルのモジュールを読み込む際のルート定義]

  ...

  resolve: {
  
    root: [
      `${ROOT}/src/main/scripts`,
      `${ROOT}/src/main/styles`
    ]
  },
    
  ...  

JSやSCSS上で他のモジュールをimportする際のルートパスの定義。

これによって、import sum from ./../log.jsimport sum from log.js と書けるようになる

公式:https://webpack.github.io/docs/configuration.html#resolve-root

[ソースマップの出力形式]

  ...

  devtool: 'inline-source-map',
    
  ...  

比較的軽量で一般的に使われてるらしいinline-source-mapを採用。

公式:https://webpack.github.io/docs/configuration.html#devtool

[使用ライブラリの共通化]

  ...

  plugins: [
  
    new webpack.optimize.CommonsChunkPlugin('vendor', 'vendor.js'),
  ]
    
  ...  

Multi Page Application用の設定。HTMLでロードされる各JSごと(ページ、entryごと)で使用されてるライブラリを依存性を解決した上で共通化してくれる。これがあることで、各JSのファイルサイズを小さくできる。

ただ、最初は巨大なライブラリJSを読み込む必要があるので初回ロード時だけ遅くなる。(グローバルをライブラリで汚染したくないのもあって使ってる)

例)

① pageA.js: 1.4MB, pageB.js: 1.5MB

② vendor: 2.1MB, pageA.js: 15KB, pageB.js: 30KB

①のようなサイズのファイルが②のようになる。

CommonsChunkPlugin公式:https://webpack.github.io/docs/list-of-plugins.html#commonschunkplugin

webpackのoptimization公式:https://github.com/webpack/docs/wiki/optimization#multi-page-app

[最適化 for 本番環境]

  ...

  plugins: [
  
    new webpack.optimize.OccurrenceOrderPlugin(),
  ]
    
  ...  

JSに圧縮をする際にモジュールの利用頻度を考慮して並び替える。

これにより、よく利用されるモジュールほど短い名前に変換される。

圧縮後に使用される変数名の多くが短くなるため、ファイルサイズをより小さくできる。

公式:https://webpack.github.io/docs/list-of-plugins.html#occurrenceorderplugin

[ファイル圧縮 for 本番環境]

  ...

  plugins: [
  
    new webpack.optimize.UglifyJsPlugin({
      output: {
        comments: saveLicense
      }
    }),
  ]
    
  ...  

スペースや改行、コメントを削除して出力されるJSを最小化。

outputの設定は、minify時のコメント削除でLicense情報まで消してしまわないようにするためのもの。これにはuglify-save-licenseが必要。

公式:https://webpack.github.io/docs/list-of-plugins.html#uglifyjsplugin

[重複排除 for 本番環境]

  ...

  plugins: [
  
    new webpack.optimize.DedupePlugin(),
  ]
    
  ...  

バンドル時に依存関係木を作り重複しているモジュールを探索、削除することでファイルサイズを減らす。

削除モジュールの参照元には関数のコピーを適応することで、意味的な整合性を保ってる。

公式:https://webpack.github.io/docs/list-of-plugins.html#dedupeplugin

[コード圧縮 for 本番環境]

  ...

  plugins: [
  
    new webpack.optimize.AggressiveMergingPlugin()
  ]
    
  ...  

ファイルを細かく分析し、まとめられるところはできるだけまとめてコードを圧縮する。

公式:https://webpack.github.io/docs/list-of-plugins.html#aggressivemergingplugin

[ESLint for 開発環境]

  ...

  module: {
    loaders: [

      {
        enforce: 'pre',
        test: /\.js$/,
        exclude: /(node_modules|bower_components)/,
        loader: 'eslint-loader'
      }
    ]
  },

  eslint: {
    configFile: `${__dirname}/eslint.json`
  },
    
  ...  

eslint.json

/*
eslint-config-standardをベースとしたESLintの設定。
lodashの_、jQueryの$のような無視してほしいグローバルオブジェクトはglobalsで指定。
※グローバルオブジェクトは作らないのが望ましい。

rules value:
  "off" or 0 - turn the rule off
  "warn" or 1 - turn the rule on as a warning (doesn’t affect exit code)
  "error" or 2 - turn the rule on as an error (exit code will be 1)

*/

{
  "extends": "standard",
  "env": {
    "node": true,
    "browser": true,
    "es6": true
  },
  "rules": {
    "no-var": "error",
    "arrow-parens": ["error", "as-needed"],
    "no-console": "warn",
    "space-before-function-paren": ["error", "never"],
    "no-new": "warn"
  },
  "parserOptions": {
    "sourceType": "module"
  }
}

ES2015のJSに対してESLintの静的機解析チェックを適応する。(IDEなどのサポートが受けれる場合は不要と思われる。)

ES2015のトランスパイル前に実施され、チェック内容をコンソール出力。

eslintではLintの適応ルールを記述した.eslintrcファイルのパス指定。

eslint-loader公式:https://github.com/MoOx/eslint-loader

ESLintのルール公式:http://eslint.org/docs/rules/

eslint-config-standard:https://github.com/feross/eslint-config-standard/blob/master/eslintrc.json

[環境変数の定義 for 各環境]

  ...

  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('development')
    })
  ]

  ...  

developmentproductionにするなどしてグローバルで呼び出せる環境ごとの変数を設定する。

JS上ではprocess.env.NODE_ENVによってこの変数にアクセスできるため、if文などで開発環境と本番環境で異なるコードをデプロイすることができる。

公式:https://webpack.github.io/docs/list-of-plugins.html#defineplugin

実行方法

<開発環境>

バックとフロントを同時起動

バックエンドのサーバー起動

$ ./bin/activator run

フロントエンドのパッケージインストール&開発サーバー起動

(ブラウザが立ち上がり http://localhost:3000/ が開かれます。)

$ cd frontend/
$ npm i
$ npm run start

<本番環境>

バックエンド単体での実行

$ npm run build
$ ./bin/activator run

#まとめ

これらの設定によって、開発時はプロキシサーバーを使ったリアルタイムなブラウザ反映を実現した開発が可能になり、本番環境ではPlayのサーバー単体で駆動するようなシステムを実現できるかと思います。(試験的に作ったものなのでどこか不備があるかも)

以上が設定になります。

上記設定のプロジェクトはGithubに上げているので興味ある方は参照ください。

Github URL:https://github.com/yoppe/webpack-es2015-base-for-play

#感想

さくっと作るつもりがだいぶ大きくなってしまった。。

最近のフロントエンドは開発環境を整備するだけでも覚えること、調べることが多すぎてつらいですね。これにさらにテスト環境のことも考えたらなかなか一から開発環境を整備することのハードルの高さを感じます。

それでも一昔前よりはかなりツールがそろってきて、ES2015をはじめとするよりきれいなコードの記述ができるようになっていて、日々進化を実感します。

WebpackのHot Module ReplacementはCSSの修正などが画面リロードもなしに反映されるので感動します。

次は一度挫折したRollupも試したい。

結局、新しいプロダクトでは、Angular2 + TypeScript + angular-cliを採用することになったので、近々そこらへんの記事もまとめて上げれたらと思います。

誤りや改善点などあれば、修正するのでコメント&編集リクエストいただければと思います。

最後までお読みいただきありがとうございます!


参考:

ほかにもいっっっぱいあるけど覚えてない。。