webpackでjQueryの多重ロードを回避する方法

最適解だと確信が持てないので、あえてQiitaに晒してパブリックコメントを募りたい。
今回は[Materialize-CSS](http://materializecss.com)と[jQuery Validation Plugin](https://jqueryvalidation.org)で発生したけど、似たシチュエーションは多そう。

Qiita内だとこの辺の記事とか。

* [結構困ったwebpackのお話](http://qiita.com/tomoktan_cansell/items/75613c578d723a4fd398)
* [【Typescript】DefenitelyTypedはjQuery objectをbootstrapで拡張しない？](http://qiita.com/jamashita/items/341177dc5670472bedd4)

stack overflowだとこの記事がまさに。

* [Managing jQuery plugin dependency in webpack](http://stackoverflow.com/questions/28969861/managing-jquery-plugin-dependency-in-webpack)

## TL; DR
### 序
jQuery2系を内包する[Materialize-CSS](http://materializecss.com)を使いつつ、
jQuery1系を推奨する[jQuery Validation Plugin](https://jqueryvalidation.org)が効いたフォームを作りたい。

### 破
あちらを立てればこちらが立たず。気づけばjQuery3系も混じっていた。

### 急
アプローチはいろいろあれど、alias作戦が一番良さそう。

## 序
Google Formのクローンを作る、的な案件にて。
修正前のコード(= **Materialize-CSSは動くが、jQuery.validationは動かない状態**)。

さぁ、間違い探しの時間だよ！

### package.json
デプロイ先は[Flask](flask) on GAE/Python。
Yarnが `node_modules/` 以下をフラットにしてくれるおかげで、パッケージは追いやすい。

```js:package.json
{
  "version": "1.0.0",
  "dependencies": {
    "jquery": "^3.1.1",
    "materialize-css": "^0.98.0"
  },
  "devDependencies": {
    "babel": "^6.5.2",
    "babel-core": "^6.22.1",
    "babel-loader": "^6.2.10",
    "babel-polyfill": "^6.22.0",
    "babel-preset-es2015": "^6.22.0",
    "babel-register": "^6.22.0",
    "chai": "^3.5.0",
    "css-loader": "^0.26.1",
    "eslint": "^3.14.1",
    "eslint-loader": "^1.6.1",
    "file-loader": "^0.10.0",
    "html-loader": "^0.4.4",
    "html-webpack-plugin": "^2.28.0",
    "mocha": "^3.2.0",
    "mocha-generators": "^1.2.0",
    "nightmare": "^2.9.1",
    "node-sass": "^4.5.0",
    "sass-loader": "^5.0.1",
    "style-loader": "^0.13.1",
    "url-loader": "^0.5.7",
    "webpack": "^2.2.1",
    "webpack-dashboard": "^0.3.0",
    "webpack-shell-plugin": "^0.5.0"
  },
  "scripts": {
    "clean": "rm -f static/* *.pyc *.pyo",
    "stage": "dev_appserver.py .",
    "build:debug": "webpack-dashboard -- webpack -d --progress",
    "build:watch": "webpack-dashboard -- webpack -d --progress --watch",
    "build:production": "webpack-dashboard -- webpack -p --progress",
    "start": "yarn run build:watch & yarn run stage",
    "test": "mocha",
    "test:production": "env production=true mocha",
    "test:watch": "mocha --watch",
    "log": "gcloud app logs read -s default"
  }
}
```

### webpack.config.js
webpackは2系。
[html-webpack-plugin](https://github.com/jantimon/html-webpack-plugin)で、 `<head>` 内に読み込みたいhead.jsファイルとejsテンプレート、そのテンプレートを使うa、b、cを生成する構成。

```js:webpack.config.js
const path = require('path');
const webpack = require('webpack');
const htmlWebpackPlugin = require('html-webpack-plugin');
const webpackShellPlugin = require('webpack-shell-plugin');
const webpackDashboardPlugin = require('webpack-dashboard/plugin');

module.exports = {
  context: path.resolve(__dirname, './src'),
  entry: {
    head: './head.js',
    a: './a.js',
    b: './b.js',
    c: './c.js'
  },
  output: {
    path: path.resolve(__dirname, './static'),
    filename: '[name].js'
  },
  module: {
    rules: [{
      test: /\.js$/,
      enforce: 'pre',
      exclude: /node_modules/,
      use: [{
        loader: 'eslint-loader',
        options: {
          configFile: './.eslintrc.json'
        }
      }, {
        loader: 'babel-loader',
        query: {
          presets: ['es2015'],
          comments: false,
          compact: true
        }
      }]
    }, {
      test: /\.html$/,
      loader: 'html-loader'
    }, {
      test: /\.css$/,
      loader: ['style-loader', 'css-loader']
    }, {
      test: /\.(sass|scss)$/,
      loader: ['style-loader', 'css-loader', 'sass-loader']
    }, {
      test: /\.(jpg|png)$/,
      loader: 'url-loader?limit=10240&name=../static/[name].[ext]'
    }, {
      test: /\.(otf|eot|svg|ttf|woff|woff2)(\?.+)?$/,
      loader: 'url-loader'
    }]
  },
  plugins: [
    new webpack.EnvironmentPlugin({
      'NODE_ENV': 'development',
      'DEBUG': false
    }),
    new htmlWebpackPlugin({
      filename: '../templates/head.html',
      chunks: ['head'],
      template: './head.html',
      inject: 'head',
      minify: {
        collapseWhitespace: true,
        removeComments: true
      }
    }),
    new htmlWebpackPlugin({
      filename: '../templates/a.html',
      chunks: ['a'],
      template: './a.html',
      inject: 'body',
      minify: {
        collapseWhitespace: true,
        removeComments: true
      }
    }),
    new htmlWebpackPlugin({
      filename: '../templates/b.html',
      chunks: ['b'],
      template: './b.html',
      inject: 'body',
      minify: {
        collapseWhitespace: true,
        removeComments: true
      }
    }),
    new htmlWebpackPlugin({
      filename: '../templates/c.html',
      chunks: ['c'],
      template: './c.html',
      inject: 'body',
      minify: {
        collapseWhitespace: true,
        removeComments: true
      }
    }),
    new webpack.optimize.UglifyJsPlugin({
      comments: false,
      compress: {
        dead_code: true,
        unused: true,
        drop_console: true
      }
    }),
    new webpack.optimize.OccurrenceOrderPlugin(),
    new webpack.optimize.AggressiveMergingPlugin(),
    new webpack.ProvidePlugin({
      // $: 'jquery',
      // jQuery: 'jquery',
      'window.jQuery': 'jquery'
    }),
    new webpackShellPlugin({
      onBuildStart: ['yarn run clean'],
      onBuildEnd: ['say webpack completed']
    }),
    new webpackDashboardPlugin()
  ]
};
```

### head.js
読み込みを確認するためだけのconsole.log。
`--production` すれば、UglifyJSPluginがdrop_consoleしてくれる。

```js:head.js
import 'jquery';
import 'materialize-css';
import 'materialize-css/sass/materialize.scss';
import './head.scss'

$(document).ready(() => {
  console.log('HEAD');  // XXX: for Debug!
});

```

### a.js
共有部分はhead.jsに寄せたので、差分だけ。
`.materialbox()` や `.material_select()` がMaterialize-CSS(= 動く)。
`.validate()` がjQuery Validation(= 上記設定だと動かない)。

```js:a.js
import './a.scss';

$(document).ready(() => {
  console.log('A');  // XXX: for Debug!

  $('.materialboxed').materialbox();
  $('select').material_select();

  let form = $('#form');
  form.validate();
});

```

## 破
### 思い違い
「Materialize-CSSはjQueryに依存してるのか」 → `yarn add jquery`

「 `HEAD` も `A` も表示される、jQueryは読み込めてるな」

「Materialize-CSSってjQuery3でも動くのかー」

この辺でモヤモヤしはじめ、 `.validate() is not a function.` が出て「やっぱり依存解決できてないじゃん」と。

### 現象の確認
依存解決が失敗している理由を探す。

#### webpack -d --display-modules true
`--display-modules true` で組み込んだファイルの一覧が得られる。

```sh:webpack
$ webpack -d --display-modules true
[0] ../~/materialize-css/~/jquery/dist/jquery.js 258 kB {0} {1} {2} [built]
[1] ../~/css-loader/lib/css-body.js 1.51 kB {0} {1} {2} {3} [built]
[2] ../~/style-loader/addStyles.js 7.15 kB {0} {1} {2} {3} [built]
[3] ../~/materialize-css/bin/materialize.js 146 kB {0} {1} {2} [built]
[4] ../~/hammerjs/hammer.js 73.8 kB {0} {1} {2} [built]
[5] ../~/webpack/buildin/amd-options.js 82 bytes {0} {1} {2} [built]
[6] ../~/jquery/dist/jquery.js 267 kB {0} {1} [built]
```

Materialize-CSSのjQueryとyarnで入れたjQuery、どっちも組み込んでいた。

jQuery ValidationとMaterialize-CSSで追加する先のjQueryが食い違って動かないと推測。

## 急
### 解決方法は6つある
[stack overflow](http://stackoverflow.com/a/28989476)で紹介されていたのは下記の6つ。

#### 1. 読み込みたいファイルへaliasを張る
`node_modules/` なりへインストールされている当該ファイルへのパスをベタ書き。

```js:webpack.config.js
module.exports = {
    ...
    resolve: {
        alias: {
            jquery: "jquery/src/jquery"
        }
    }
};
```

#### 2. ProvidePluginを使う
グローバル変数として参照できる名前を追加する。

```js:webpack.config.js
var webpack = require("webpack");
    ...
    plugins: [
        new webpack.ProvidePlugin({
            $: "jquery",
            jQuery: "jquery"
        })
    ]
```

#### 3. import-loaderでthisを置き換える
`this` を `window` に置き換える。

```js:webpack.config.js
module: {
    loaders: [
        {
            test: /[\/\\]node_modules[\/\\]some-module[\/\\]index\.js$/,
            loader: "imports?this=>window"
        }
    ]
}
```

#### 4. import-loaderでAMDを殺す
Asynchronous Module Definitionで依存解決しないようにする。

```js:webpack.config.js
module: {
    loaders: [
        {
            test: /[\/\\]node_modules[\/\\]some-module[\/\\]index\.js$/,
            loader: "imports?define=>false"
        }
    ]
}
```

#### 5. `<script>` でグローバルに読み込む
script-loaderを使って旧態然にする。

#### 6. noParseを指定してパースさせない
webpack的なことをすべて諦める。

```js:webpack.config.js
module: {
    noParse: [
        /[\/\\]node_modules[\/\\]angular[\/\\]angular\.js$/
    ]
}
```

### aliasを張って解決
6つの方法、それぞれにPros/Consがあって、状況によって選び分けなければならない。
とはいえ、回答者はオススメ順に挙げているらしい。
ProvidePluginではライブラリごとの読み込み先を制御できないし、不用意にグローバル変数を増やしたくない。
一番最初の「aliasを張っての解決」であればDedupePluginなどの最適化も効く。

今回は、jQuery Validationが2系でも動くことが確認できていたので、Materialize-CSS内のjQueryだけ参照するように書き換えた。

```js:webpack.config.js
const path = require('path');
const webpack = require('webpack');
const htmlWebpackPlugin = require('html-webpack-plugin');
const webpackShellPlugin = require('webpack-shell-plugin');

module.exports = {
  context: path.resolve(__dirname, './src'),
  entry: {
    head: './head.js',
    a: './a.js',
    b: './b.js',
    c: './c.js'
  },
  output: {
    path: path.resolve(__dirname, './static'),
    filename: '[name].js'
  },
  resolve: {
    alias: {
      jquery: 'materialize-css/bin/jquery-2.1.1.min.js'
    }
  },
  module: {
    rules: [{
      test: /\.js$/,
      enforce: 'pre',
      exclude: /node_modules/,
      use: [{
        loader: 'eslint-loader',
        options: {
          configFile: './.eslintrc.json'
        }
      }, {
        loader: 'babel-loader',
        query: {
          presets: ['es2015'],
          comments: false,
          compact: true
        }
      }]
    }, {
      test: /\.html$/,
      loader: 'html-loader'
    }, {
      test: /\.css$/,
      loader: ['style-loader', 'css-loader']
    }, {
      test: /\.(sass|scss)$/,
      loader: ['style-loader', 'css-loader', 'sass-loader']
    }, {
      test: /\.(jpg|png)$/,
      loader: 'url-loader?limit=10240&name=../static/[name].[ext]'
    }, {
      test: /\.(otf|eot|svg|ttf|woff|woff2)(\?.+)?$/,
      loader: 'url-loader'
    }]
  },
  plugins: [
    new webpack.EnvironmentPlugin({
      'NODE_ENV': 'development',
      'DEBUG': false
    }),
    new htmlWebpackPlugin({
      filename: '../templates/head.html',
      chunks: ['head'],
      template: './head.html',
      inject: 'head',
      minify: {
        collapseWhitespace: true,
        removeComments: true
      }
    }),
    new htmlWebpackPlugin({
      filename: '../templates/a.html',
      chunks: ['a'],
      template: './a.html',
      inject: 'body',
      minify: {
        collapseWhitespace: true,
        removeComments: true
      }
    }),
    new htmlWebpackPlugin({
      filename: '../templates/b.html',
      chunks: ['b'],
      template: './b.html',
      inject: 'body',
      minify: {
        collapseWhitespace: true,
        removeComments: true
      }
    }),
    new htmlWebpackPlugin({
      filename: '../templates/c.html',
      chunks: ['c'],
      template: './c.html',
      inject: 'body',
      minify: {
        collapseWhitespace: true,
        removeComments: true
      }
    }),
    new webpack.optimize.UglifyJsPlugin({
      comments: false,
      compress: {
        dead_code: true,
        unused: true,
        drop_console: true
      }
    }),
    new webpack.optimize.OccurrenceOrderPlugin(),
    new webpackShellPlugin({
      onBuildStart: ['yarn run clean'],
      onBuildEnd: ['say webpack completed']
    })
  ]
};
```

resolve>aliasにパスを追加、ProvidePluginは使わなくなったので削除。

```sh:webpack
$ webpack -d --display-modules true
[0] ../~/materialize-css/bin/jquery-2.1.1.min.js 84.3 kB {1} {2} [built]
[1] ../~/css-loader/lib/css-base.js 1.51 kB {0} {1} {2} {3} [built]
[2] ../~/style-loader/addStyles.js 7.15 kB {0} {1} {2} {3} [built]
[3] ../~/webpack/buildin/amd-options.js 82 bytes {1} {2} [built]
[4] ../~/materialize-css/bin/materialize.js 146 kB {1} {2} [built]
[5] ../~/hammerjs/hammer.js 73.8 kB {1} {2} [built]
```

1つしか組み込んでいないことを確認、問題解決。

## まとめ
問題は解決したけれど、なんだかモヤモヤする。
Forumやsoを読む限り、Materialize-CSSは積極的にmodule化する気配はないし。
脱jQueryの動きがあっても、依然としてjQuery Validationが定番だし。
なにより、webpackの仕組みが完成されているとも思えず、またすぐに別の技術で取って代わられそうなのが…