Yii + webpack でアセットをビルドする

## デモ環境
- Mac 10.11.x
- Yii 2.0.x
- Node.js 7.8.x
- Yarn 0.22.x
- webpack 2.3.x

## 想定しているサイト
- Qiita のような SPA ではないサイト
- CSS, JavaScript 関連のアセットをがっちゃんこしてリクエストを減らしつつ、ブラウザキャッシュを活かせるようなサイト

## ディレクトリ構成
最終的には以下のような構成になります:

```
.
├── client
│   ├── config
│   │   ├── base.js
│   │   ├── dev.js
│   │   └── prod.js
│   ├── css
│   │   ├── app.less
│   │   └── site.css
│   ├── entries
│   │   └── app.js
│   ├── images
│   │   ├── apple-touch-icon.png
│   │   └── favicon.png
│   ├── js
│   ├── package.json
│   ├── webpack.config.js
│   └── yarn.lock
├── commands
├── composer.json
├── config
├── controllers
├── helpers
...
```

## Yii 側の設定

Yii 2.0.x はアセット関連のパッケージを [composer-asset-plugin](https://github.com/fxpio/composer-asset-plugin) 経由でインストールする形になっていますが、それをやめて、パッケージはすべて npm(Yarn) でインストールするようにします。詳しくは [ここ](http://qiita.com/livejam_db/items/70d674d0d735038ef93f) らへんを参考にしてみてください。

ここからはデモとして [yii2-app-basic](https://github.com/yiisoft/yii2-app-basic) を想定した説明になります。

プロジェクトのテンプレートを作成後、プロジェクトルートに client ディレクトリを作成:
(この client ディレクトリ以下にアセット関連のファイルを置いて管理する形になります)

```:project/clientディレクトリを作成し移動
cd /path/to/project
mkdir client
cd $_
```

```json:project/client/package.jsonの作成
{
  "private": true,
  "dependencies": {
    "bootstrap": "3.3.7",
    "jquery": "2.2.4",
    "typeahead.js": "0.11.1",
    "yii2-pjax": "2.0.6"
  }
}
```

```:パッケージのインストール
yarn
```

Yii 2 側で用意されているアセットバンドル機能を停止し、開発環境で使用するアセットのパスも書き換えておく:

```php:project/config/web.phpの修正
$config = [
    // ...
    'components' => [
        // ...
        'assetManager' => [
            'bundles' => false,
        ],
    ],
    // ...

if (YII_ENV_DEV) {
    // ...
    $config['container']['definitions'] = [
        yii\bootstrap\BootstrapAsset::class => [
            'sourcePath' => '@app/client/node_modules/bootstrap/dist',
        ],
        yii\bootstrap\BootstrapPluginAsset::class => [
            'sourcePath' => '@app/client/node_modules/bootstrap/dist',
        ],
        yii\gii\TypeAheadAsset::class => [
            'sourcePath' => '@app/client/node_modules/typeahead.js/dist',
        ],
        yii\web\JqueryAsset::class => [
            'sourcePath' => '@app/client/node_modules/jquery/dist',
        ],
        yii\widgets\PjaxAsset::class => [
            'sourcePath' => '@app/client/node_modules/yii2-pjax',
        ],
    ];
}
```

webpack 側でアセット更新時のブラウザキャッシュを避けれるよう、最終的に app-fd07f98707077a0a0aee1.css|js みたいなファイル名にしたいので、それを上手く Yii 側でキャッチできるように、コアの Url ヘルパーにアセット専用のメソッドを追加します:

```php:project/helpers/Url.php
namespace yii\helpers;

class Url extends BaseUrl
{
    /**
     * @param string $file
     * @return string
     */
    public static function asset($file)
    {
        $path = \Yii::getAlias('@app/web/assets/manifest.json');

        $manifest = file_exists($path)
            ? json_decode(file_get_contents($path))
            : new \stdClass;

        return property_exists($manifest, $file)
            ? '/assets/'.$manifest->$file
            : '/assets/'.$file;
    }
}
```

```php:project/web/index.php
// ...
Yii::$classMap['yii\helpers\Url'] = '@app/helpers/Url.php';
$config = require(__DIR__ . '/../config/web.php');
// ...
```

最後にレイアウトファイルを修正:

```html+php:project/views/layouts/main.php
<?php
use yii\helpers\Url;
// AppAsset::register($this); は使わない
?>
<?php $this->beginPage() ?>
<!DOCTYPE html>
<html lang="<?= Yii::$app->language ?>">
<head>
    <meta charset="<?= Yii::$app->charset ?>">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <?= Html::csrfMetaTags() ?>
    <title><?= Html::encode($this->title) ?></title>
    <link rel="icon" type="image/png" href="<?= Url::asset('favicon.png') ?>">
    <link rel="apple-touch-icon" sizes="152x152" href="<?= Url::asset('apple-touch-icon.png') ?>">
    <link rel="stylesheet" href="<?= Url::asset('app.css') ?>">
    <?php $this->head() ?>
</head>
<body>
<?php $this->beginBody() ?>
<! -- ... -->
<script src="<?= Url::asset('app.js') ?>"></script>
<?php $this->endBody() ?>
</body>
</html>
<?php $this->endPage() ?>
```

これで Yii 側の準備は終了です :)

## webpack 側の設定

```:webpack関連のパッケージをインストール
yarn add webpack webpack-merge css-loader less-loader less file-loader expose-loader extract-text-webpack-plugin webpack-manifest-plugin compression-webpack-plugin --dev
```
- `webpack` アセットを良い感じにがっちゃんこするために使う
- `webpack-merge` 開発・本番・共通の設定を良い感じにマージするために使う
- `css-loader, less-loader less` Less ファイルを CSS に良い感じにコンパイルするのに使う
- `file-loader` Twitter Bootstrap のアイコン関連のファイルを良い感じに結びつけるために使う
- `expose-loader` jQuery, $ などをグローバル空間で使えるようにするため (最終手段)
- `extract-text-webpack-plugin` .js|css のアセットを分離させるために使う
- `webpack-manifest-plugin` 自動生成したランダムなファイル名と元のファイル名を結びつけるために使う (アセット更新時のブラウザキャッシュを避けるため)
- `comporession-webpack-plugin` アセットを gzip 化するために使う

```json:project/client/package.jsonにアセットのビルド関連のコマンドを追加
{
  "scripts": {
    "watch": "webpack -w --env=dev",
    "build": "webpack --env=dev",
    "build:prod": "webpack --env=prod"
  }
}
```

設定ファイルの作成:
(上記のオプション(--env)の値によって読み込むファイルを設定している感じ)

```js:project/client/webpack.config.js
function bundleConfig(env) {
  return require('./config/' + env + '.js')(env)
}

module.exports = bundleConfig
```

環境ごとの設定ファイルを作成:

- `base.js` 環境関係なく共通で使用する設定ファイル
- `dev.js` 開発環境でのみ使用する設定ファイル
- `prod.js` 本番環境でのみ使用する設定ファイル

このファイル構成や設定方法はオフィシャルのドキュメントを参考にしています。
[webpack - Building for Production](https://webpack.js.org/guides/production-build/)


```js:project/client/config/base.js
const path = require('path')
const ExtractTextPlugin = require('extract-text-webpack-plugin')

module.exports = function (env) {
  return {
    // project/client/entries/app.js に js 関連のアセットを羅列するように設定
    entry: {
      app: './entries/app.js'
    },
    // ビルド先は project/web/assets 以下、ファイル名は app-[hash].js で
    output: {
      path: path.resolve(__dirname, '../../web/assets'),
      publicPath: '/assets/'
    },
    // Yii コアのアセットもモジュール検索してと webpack に伝える
    resolve: {
      modules: [
        'node_modules',
        path.resolve(__dirname, '../../vendor/yiisoft/yii2/assets')
      ]
    },
    module: {
      rules: [
        {
          // Yii はコアのウィジェットなどを使うとインラインに js のコードを吐くので
          // jQuery|$ をグローバルな空間でも使えるようにしておく
          test: require.resolve('jquery'),
          use: [
            { loader: 'expose-loader', options: 'jQuery' },
            { loader: 'expose-loader', options: '$' }
          ]
        },
        {
          // .less ファイルを良い感じにコンパイル
          test: /\.less$/,
          use: ExtractTextPlugin.extract(['css-loader', 'less-loader'])
        },
        {
          // Twitter Bootstrap のアイコン関連の設定
          test: /\.(eot|woff2?|svg|ttf)$/,
          use: 'file-loader'
        },
        {
          // サイトのファビコン・アイコンの設定
          test: /(favicon|apple-touch-icon)\.png$/,
          use: {
            loader: 'file-loader',
            options: {
              name: env === 'dev' ? '[name].[ext]' : '[name]-[hash].[ext]'
            }
          }
        }
      ]
    }
  }
}
```

```js:project/client/config/dev.js
const baseConfig = require('./base')
const merge = require('webpack-merge')
const webpack = require('webpack')

module.exports = function (env) {
  // project/client/config/base.js を読み込んで開発環境の設定とマージする
  return merge.smart(baseConfig(env), {
    output: {
      filename: '[name].js'
    },
    plugins: [
      // js と css を分離させる、ファイル名は app.css に
      new ExtractTextPlugin({ filename: '[name].css' }),
    ]
  })
}
```

```js:project/client/config/prod.js
const baseConfig = require('./base')
const webpack = require('webpack')
const merge = require('webpack-merge')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const ManifestPlugin = require('webpack-manifest-plugin')
const CompressionPlugin = require('compression-webpack-plugin')

module.exports = function (env) {
  // project/client/config/base.js を読み込んで本番環境の設定とマージする
  return merge.smart(baseConfig(env), {
    output: {
      filename: '[name]-[hash].js'
    },
    plugins: [
      // アセット関連を最小化してビルドする、デバッグはしない
      new webpack.LoaderOptionsPlugin({
        minimize: true,
        debug: false // webpack 3 で削除予定
      }),

      // UglifyJS で .js ファイルの圧縮・最適化する
      new webpack.optimize.UglifyJsPlugin(),

      // js と css を分離させる、ファイル名は app-[hash].css に
      new ExtractTextPlugin({ filename: '[name]-[contenthash].css' }),

      // ランダムなファイル名と元のファイル名を結びつける manifest.json ファイルを project/web/assets 以下に生成
      new ManifestPlugin(),

      // .js|css ファイルを gzip 化する
      new CompressionPlugin({ test: /\.(js|css)/ })
    ]
  })
}
```

これで設定が完了したので、JavaScript, CSS 関連のファイルを取り込んでいきます。

```:各ディレクトリの作成
cd /path/to/project/client
mkdir entries css js images
```

```js:project/client/entries/app.js
// app.js
require('jquery')
require('bootstrap/dist/js/bootstrap')
require('yii')
require('yii.activeForm')
require('yii.captcha')
require('yii.gridView')
require('yii.validation')

// app icon (サイトのファビコン類は適当に用意します)
require('../images/favicon.png')
require('../images/apple-touch-icon.png')

// app.css
require('../css/app.less')
```

```:Yiiが持っているサイト用のcssを移動させる
mv /path/to/project/web/css/site.css /path/to/project/client/css
```

```less:project/client/css/app.lessの作成
@import "~bootstrap/less/bootstrap";
@import "site.css";
```

これでとりあえずは OK :)

```:開発環境用ビルド
yarn run build
```

```:.lessファイルを修正したりする場合は以下
yarn run watch
```

```:本番用ビルド
yarn run build:prod
```

開発環境ではアセットファイルの最小化はされず、本番環境でのみ最小化・最適化と gzip 化がされます。

## まとめ

シンプルな構成ですが、Yii + webpack の組み合わせでアセットをビルドする方法を書いていきました。おそらくパターンはいろいろあると思いますが(まだまだ最適化もできると思われる)、そこまで Yii 側をカスタマイズせずにがっちゃんこできるので、Yii の assetManager に不満を持っていたり、最近の gulp や gulp 関連のパッケージのコミット頻度に不安を感じていたりする方(自分)は試してみてはいかがでしょうか。

設定が分かりづらかったり、バージョンの差異で情報がごちゃっていたりで、泥沼にハマりがちな現状ですが、試行錯誤しながらやっていけば、そのうちなんとかなるかと思います。

自分が作成したサイトでもほぼ同じような感じで webpack を使用しているので、もしよければそれも参考にしてみてください。[GitHub - jamband/plusarchive.com](https://github.com/jamband/plusarchive.com)