2017年6月29日

JavaScript

WEB+DB PRESS vol.99から連載が始まりました&webpack3の補足

  • このエントリーをはてなブックマークに追加

こんにちは。エンジニアの穴井(@pirosikick)です。去年の10月よりヤフー黒帯制度にて、JavaScriptの黒帯をやっています。普段はヤフーの社内ベンチャー制度からできたリッチラボで福岡で働いていますが、ご飯がおいしすぎて太りました。痩せたい!

ここからが本題ですが、今月24日発売されましたWEB+DB PRESS vol.99より、ヤフーのフロントエンドエンジニアによる連載が始まりました!連載タイトルは「どんとこい!フロントエンド開発」です。ヤフーのフロントエンドエンジニアで、流れの速いWebフロントエンドで使われる技術を分かりやすく解説していきますので、どうぞよろしくお願いします!

WEB+DB PRESS vol.99表紙

初回は「入門webpack2」を寄稿しました。webpackの基本から、webpack2で導入された新機能まで広くカバーした入門記事です。なのですが、執筆を終えた後にwebpack3のリリースが正式に発表されました!

🚀 webpack 3: Official Release!! 🚀 - webpack - Medium

ということで、webpack3について、本記事で補足させていただきます。

webpack2からの移行は簡単

先ほどURLを貼った公式のリリース記事には、「いくつかのプラグインが影響を受ける内部的なBreaking Changeで、98%のユーザーは影響無くアップグレードできる」と書いており、webpack2からwebpack3に移行するに当たって苦労することはほぼなさそうです。試しに、WEB+DB PRESS vol.99用に書いたサンプルコード(WEB+DB PRESS vol.99のサポートページよりダウンロードいただけます)をwebpack3で検証しましたが、設定ファイルに関しては修正せずにwebpack2と同じように動作することを確認できました。LoaderやPluginに関しては、peerDependenciesがwebpack2になっているパッケージがあり、npm install(またはyarn install)時にWarningが出力されることがありましたが、babel-loaderやuglifyjs-webpack-pluginは既にwebpack3に対応したパッケージがリリースされており、パッケージのバージョンを上げれば問題ありませんでした。webpack-dev-serverはまだ対応中(6/28時点)のようですが、動作は問題なさそうに見えました。

という感じで、webpack2への移行が完了している場合は簡単にwebpack3に移行できます。少し注意が必要なのはプラグインを利用している場合ですが、メジャーなプラグインに関してはほぼ問題なさそうで、もしマイナーなプラグインを利用している場合はそのプラグインのIssueを確認するのがよさそうです。

Scope Hoisting

Scope Hoistingは、日本語にすると「スコープ巻き上げ」です。言葉からはちょっとイメージが湧きにくいと思いますので、例を使って説明します。

まず、Scope Hoistingが無効な状態での出力ファイルを確認します。下記のような、entry.jsとhello.jsを作成します。

// hello.js
export default function hello() {
  console.log('Hello, webpack3!!!');
}
// entry.js
import hello from './hello';

hello();

entry.jsをエントリーポイントとして、webpackを実行します。

# webpackのインストール
$ mkdir scope-hoisting
$ cd scope-hoisting
$ echo "{}" > package.json
$ yarn add -D webpack@^3.0.0

# バンドルした結果をbuild/output.jsに出力する設定ファイル
$ cat webpack.config.js
const path = require('path');

module.exports = {
  entry: './entry.js',
  output: {
    filename: 'output.js',
    path: path.join(__dirname, 'build'),
  }
};

# webpackを実行
$ yarn run webpack
yarn run v0.24.6
$ "..../examples/scope-hoisting/node_modules/.bin/webpack"
Hash: 67095ded67fef7baa937
Version: webpack 3.0.0
Time: 79ms
    Asset     Size  Chunks             Chunk Names
output.js  2.97 kB       0  [emitted]  main
   [0] ./entry.js 39 bytes {0} [built]
   [1] ./hello.js 68 bytes {0} [built]
?  Done in 0.71s.

上記設定ファイルでwebpackを実行すると、output.jsが出力されます。output.jsの中身を見てみましょう(可読性向上のため、一部省略、無駄なコメントの削除、インデントの改変など行いましたので、実際の出力ファイルとは異なります)

/**
 * output.js
 * 可読性向上のため、一部省略、無駄なコメントの削除、インデント改変等を行っていますので、
 * 実際の出力ファイルとは異なります。
 */
(function(modules) {
  // webpackBootstrap
  // webpackの実行コード. 長いので省略

  // Load entry module and return exports
  return __webpack_require__(__webpack_require__.s = 0);
})([
  /**
   * entry.jsにあたる部分
   */
  (function(module, __webpack_exports__, __webpack_require__) {

    "use strict";
    Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
    /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__hello__ = __webpack_require__(1);
    // entry.js


    __WEBPACK_IMPORTED_MODULE_0__hello__["a" /* default */]();


  }),

  /**
   * hello.jsにあたる部分
   */
  (function(module, __webpack_exports__, __webpack_require__) {

    "use strict";
    /* harmony export (immutable) */ __webpack_exports__["a"] = hello;
    // hello.js
    function hello() {
      console.log('Hello, World!');
    }


  })
]);

各モジュール(entry.js、hello.js)が別々のスコープになるように、(function(module, __webpack_exports__, __webpack_require__){ ... })で囲われているのがわかると思います。

では、Scope Hoistingを有効にしてみましょう。Scope Hoistingを有効にするには、ModuleConcatenationPluginを使います。webpack.config.jsのpluginsフィールドにModuleConcatenationPluginのインスタンスを追加し、webpackを実行します。

// webpack.config.js
const path = require('path');
const webpack = require('webpack');

module.exports = {
  entry: './entry.js',
  output: {
    filename: 'output.js',
    path: path.join(__dirname, 'build'),
  },
  plugins: [
    // Scope Hoistingを有効にするには、
    // ModuleConcatenationPluginを使う
    new webpack.optimize.ModuleConcatenationPlugin()
  ]
};
$ yarn run webpack
yarn run v0.24.6
$ ".../examples/scope-hoisting/node_modules/.bin/webpack"
Hash: fe9debf5d04fa36c54db
Version: webpack 3.0.0
Time: 93ms
    Asset     Size  Chunks             Chunk Names
output.js  2.73 kB       0  [emitted]  main
   [0] ./entry.js + 1 modules 107 bytes {0} [built]
?  Done in 0.79s.

出力されるoutput.jsをもう一度確認してみます。(同様に可読性を高めるために、改変しています)

/**
 * output.js
 * 可読性向上のため、一部省略、無駄なコメントの削除、インデント改変等を行っていますので、
 * 実際の出力ファイルとは異なります。
 */
(function(modules) {

  // webpackBootstrap

  // Load entry module and return exports
  return __webpack_require__(__webpack_require__.s = 0);
})([
  (function(module, __webpack_exports__, __webpack_require__) {

    "use strict";
    Object.defineProperty(__webpack_exports__, "__esModule", { value: true });

    // CONCATENATED MODULE: ./hello.js
    // hello.js
    function hello() {
      console.log('Hello, World!');
    }

    // CONCATENATED MODULE: ./entry.js
    // entry.js


    hello();


   })
]);

Scope Hoistingを有効にしたoutput.jsでは、hello.js、entry.jsが結合され、同じスコープになっているのが確認できます。これが「スコープ巻き上げ」です。 Scope Hoistingによって、巻き上げた分だけ関数の実行回数が減ることになるのでアプリケーションの実行時間の短縮と、出力されるファイル容量の若干の削減が期待できます。昨今のnpmパッケージを活用したフロントエンド開発では、小さなモジュールを大量に読み込む可能性が高いので、アプリケーションが大きいほど効果が高そうです。

注意点としては、Scope HoistingはES Modulesで記述されているコードにしか適応されないので、Babelのbabel-preset-es2015などを使いES ModulesをCommonJSのモジュール方式に変換している場合は、設定で無効にする必要があります。下記.babelrcを参考に設定してみてください。

// .babelrc
{
  // ES Modulesの変換を無効にする
  presets: [["es2015", { "modules": false }]]
}

また、Scope Hoisting有効時はHot Module Replacementが使えない、無効時に比べるとビルドやコード圧縮に時間がかかる可能性がある、などのデメリットもあります。モジュールによってはScope Hoisting有効時でも通常の方法にフォールバックすることもあるようで、手持ちのプロダクトで検証しましたがフォールバックされているモジュールがいくつかありました。webpack実行時に--display-optimization-bailoutフラグを付けるとフォールバックされているモジュールと原因となっているファイルがコンソールに出力されます。

# Scope Hoistingを適用できていないモジュールの情報を出力
# 「ModuleConcatenation:」の部分
$ yarn run webpack -- --display-optimization-bailout

... 省略 ...

       ModuleConcatenation: Cannot concat with ./..../xxx.js because of ./..../xxxx.jsx
       ModuleConcatenation: Cannot concat with ./..../xxx.js because of ./..../xxxx.js
       ModuleConcatenation: Cannot concat with ./..../xxx.js because of ./..../xxxx.js

... 省略 ...

個人的には、まだ実験段階なのかなという印象を受けたので、導入にはもう少し検証が必要に感じましたが、裏を返すと伸びしろがある機能でもあるので、今後の改善に期待です。

参考記事

dynamic importとmagic comment

※2017/6/30追記:とdynamic importの標準サポートはwebpack2.2から、magic commentはwebpack2.4から導入されたということを指摘いただきましたので、こちらに追記させていただきます。指摘いただき、ありがとうございました!


WEB+DB PRESSの記事でも取り上げましたが、webpackでは、require.ensure関数やdynamic importを使ってモジュールを読み込むと、そのモジュールは他のモジュールとは別のファイルに出力され、必要になったタイミングでブラウザーにロードされます。この機能を使うことで、初期表示に必要のないモジュールを遅延してブラウザーにロードできるので、アプリケーションの初期ロード時間を短縮できます。

// hello.js
// 動的に読み込まれる側のファイル
export default function hello() {
  console.log('Hello, World!!');
}
// entry.js

// 必要な場合は、PromiseのPolyfillを別途インストールしてください
// import 'es-promise/auto';

window.callHello = function callHello() {
  // dynamic import
  // callHello実行後に読み込みを開始
  import('./hello')
    .then(({ default: hello }) => {
      // hello.jsのロード後に実行
      hello();
    })
    .catch(err => {
      // モジュール読み込みが失敗した場合
      console.log('failed to import hello', err);
    });
};

webpack2では、dynamic importを利用するのに、babel-loader(babel-plugin-syntax-dynamic-import)を使う必要がありましたが、webpack3では不要です。 訂正:webpack2からdynamic importは標準サポートされていました。ただ、babel-loaderを使っている場合は、babelがdynamic importの文法をパースするできるようにするためにbabel-plugin-syntax-dynamic-importが必要です。

$ yarn add -D webpack

# CLI
$ yarn run webpack -- \
  entry.js \
  --output-filename '[name].bundle.js' \
  --output-chunk-filename '[name].chunk.js' \
  --output-path build \
  --output-public-path '/build/'
yarn run v0.24.6
$ ".../examples/dynamic-import/node_modules/.bin/webpack" entry.js --output-filename [name].bundle.js --output-chunk-filename [name].chunk.js --output-path build --output-public-path /build/
Hash: 79e246b7d4d9f75c75c6
Version: webpack 3.0.0
Time: 97ms
        Asset       Size  Chunks             Chunk Names
   0.chunk.js  347 bytes       0  [emitted]
main.bundle.js    6.24 kB       1  [emitted]  main
   [0] ./entry.js 384 bytes {1} [built]
   [1] ./hello.js 81 bytes {0} [built]
?  Done in 0.95s.

上記の例では、main.bundle.js0.chunk.jsの2つが出力されます。HTMLにはmain.bundle.jsをscriptタグで読み込み、0.chunk.jsは必要になったタイミング(今回はcallHello関数が実行された時)にブラウザーに読み込まれます。

webpack2では、動的に読み込まれるモジュールに付けられる名前が連番だったので、出力されるファイル名が0.chunk.jsなどの分かりづらい名前になっていました。webpack3からは、Magic commentと呼ばれる下記のようなコメントを入れることで、開発者が任意の名前を指定できます。

// Magic comment
// このdynamic importによって生成されるチャンクに
// 'hello'という名前を付ける
import(/* webpackChunkName: 'hello' */'./hello').then(...);
$ yarn run webpack -- \
  entry.js \
  --output-filename '[name].bundle.js' \
  --output-chunk-filename '[name].chunk.js' \
  --output-path build \
  --output-public-path '/build/'

yarn run v0.24.6
$ ".../examples/dynamic-import/node_modules/.bin/webpack" entry.js --output-filename [name].bundle.js --output-chunk-filename [name].chunk.js --output-path build --output-public-path /build/
Hash: 4f6bb4bcd11cb23f6c8e
Version: webpack 3.0.0
Time: 97ms
         Asset       Size  Chunks             Chunk Names
hello.chunk.js  347 bytes       0  [emitted]  hello
 main.bundle.js    6.25 kB       1  [emitted]  main
   [0] ./entry.js 415 bytes {1} [built]
   [1] ./hello.js 81 bytes {0} [built]
?  Done in 0.79s.

0.chunk.jshello.chunk.jsになってますね!動作を確認してみましょう。main.bundle.jsを読み込むだけのHTMLを用意し、ブラウザーで開きます。

# main.bundle.jsを読み込むだけのHTML
$ cat index.html
<!-- index.html -->
<body>
  <script src="build/main.bundle.js"></script>
</body>

# 確認用Webサーバーのインストールと起動
$ yarn add -D http-server
$ yarn run http-server -- -o

Chrome dev toolのコンソールからcallHello関数を呼び出すと、hello.chunk.jsが読み込まれているのがネットワークタブから確認できます。

dynamic importをChromeで検証

今回は出力されるファイルが1つですが、増えると連番ではどのファイルにどのモジュールが含まれているのかファイル名からは判別がつかず、デバッグが大変です。dynamic importを使う際はMagic commentを活用しましょう。

おわり

webpack3の変更点について、解説しました。「webpack2に移行したばかりなのに、もうwebpack3かよ」と思う方もいらっしゃるかもしれませんが、移行は簡単なので、試してみてください。

WEB+DB PRESSの「どんとこい!フロントエンド開発」、よろしくお願い致しますm(_ _)m

Yahoo! JAPANでは情報技術を駆使して人々や社会の課題を一緒に解決していける方を募集しています。詳しくは採用情報をご覧ください。

  • このエントリーをはてなブックマークに追加