webpack2 でのコード分割 (Code Splitting) について調べる

  • 22
    Like
  • 0
    Comment

code splittingを頑張らずに切り抜けられるかと思ったけど、まだちょっと逃げ切れなそうだったのでちゃんと調べた。
Chrome Canaryに<script module> が来てたり数年スパンだと不要になりそうな技術だけど、もう少しの間はこのへんを扱っていかないといけなそう)

Code splitting

まず普通のコード

こんなコードを用意する

// index.js
const lib = require("./lib")

lib()
// lib.js
module.exports = () => {
  console.log("This is Lib")
}

するとこんなふうにビルドされる

// モジュール解決する基本的な部分が差し込まれる
// ...
// ...
/******/    function __webpack_require__(moduleId) {
/******/        // Check if module is in cache
/******/        if(installedModules[moduleId]) {
/******/            return installedModules[moduleId].exports;
/******/        }
/******/    }
// ...
// ...
// それが終わると、自分が書いたモジュールが差し込まれる

/******/ ([
/* 0 */
/***/ (function(module, exports) {

module.exports = () => {
  console.log("This is Lib")
}


/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {

const lib = __webpack_require__(0)

lib()

/***/ })
/******/ ]);

__webpack_require__によってrequireが解決される様子が見て取れる。

__webpack_require__の詳細は、lib/MainTemplate.jsあたりで構築されている(文字列でjavascriptが組み立てられているっぽい。つらそう)

webpackランタイム部分の分離(Manifest分離)

この__webpack_require__に関するランタイム部分をCommonChunkPluginを利用することで分離することが紹介されている。

webpack公式ではこれをManifest fileと呼称している。
railsあたりと組み合わせた時のmanifestと役割が似ているが、微妙に違う

https://webpack.js.org/plugins/commons-chunk-plugin/#manifest-file

// webpack.config.js
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({ 
      name: 'manifest',
      minChunks: Infinity
    })
  ]

CommonChunkPluginで分離されたmanifestには、webpack内部でのモジュール解決に利用される関数のみが抽出される。
非同期ローディング用の関数(requireEnsure)も含まれるようになる。

// manifest.js
 (function(modules) { // webpackBootstrap
    // install a JSONP callback for chunk loading
    var parentJsonpFunction = window["webpackJsonp"];
    window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
    // manifest外に書かれたscriptをJSONPとして実行する。
    // ...
    };

    // webpack内部のrequire関数
    function __webpack_require__(moduleId) {
    // ...
    // ...
    }

  // requireEnsure。関数を非同期ロードする仕組み。
    __webpack_require__.e = function requireEnsure(chunkId) {
    // ... 
    // ...

        // コード読み込みは、<script>タグを生成してDOMに埋め込んで読み込む
        var head = document.getElementsByTagName('head')[0];
        var script = document.createElement('script');
        script.type = 'text/javascript';
    // ...
    // ...
        // 後述する非同期読み込みを行うと、それらの読み込みをうまく解決するコードが差し込まれる。manifestっぽさのある部分
        script.src = __webpack_require__.p + "" + ({"1":"main"}[chunkId]||chunkId) + ".js?" + {"0":"d5186cef317c5c895373","1":"eb51fb214d292368d33e"}[chunkId] + "";

        head.appendChild(script);

        return promise;
    };
  // ここから下は様々便利関数などが展開される
  // ...
  // ...

 })
 ([]);

残されたメインのコード側は、webpackJsonpで囲まれる。
これがmanifest.jsによってうまく読み込み解決される

// main.js

webpackJsonp([0],[
/* 0 */
/***/ (function(module, exports) {

module.exports = () => {
  console.log("This is Lib")
}

/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {

const lib = __webpack_require__(0)

lib()

/***/ })
],[1]);

import() / require.ensure / bundle でLazy importする

非同期処理するのは現在webpackは3つほどやり方が提供されている。

import()

import()Dynamic importと呼ばれる現在stage-3の機能。
非同期をPromise読み込みする。一番記述量が少なく書ける。

https://webpack.js.org/guides/code-splitting-async/#dynamic-import-import-

const main = () => {
  const lib = import("./lib")
  lib()
}

// main.js
webpackJsonp([1],[
/* 0 */
/***/ (function(module, exports, __webpack_require__) {

const main = () => {
  const lib = __webpack_require__.e/* import() */(0).then(__webpack_require__.bind(null, 1))
  lib()
}


/***/ })
],[0]);

webpackでは内部的にrequireEnsure(__webpack_require__.e)が利用される

require.ensure

webpack1時代から存在するwebpack固有のrequire拡張。import()より少し機能が多いが、import()に置き換えられるということがアナウンスされている。

https://webpack.js.org/guides/code-splitting-async/#require-ensure-

const main = () => {
  require.ensure([], (require) => {
    const lib = require("./lib")
    lib()
  })
}
webpackJsonp([1],[
/* 0 */
/***/ (function(module, exports, __webpack_require__) {

const main = () => {
  __webpack_require__.e/* require.ensure */(0).then(((require) => {
    const lib = __webpack_require__(1)
    lib()
  }).bind(null, __webpack_require__)).catch(__webpack_require__.oe)
}


/***/ })
],[0]);

bundle-loader

require-ensureの別解としてbundle-loaderも存在する。
こちらもおそらくimport()を利用すれば使う事は無いと思われるが、比較として掲載する

const main = () => {
  const lib = require("bundle-loader?lazy!./lib")
  lib()
}
// main.js
webpackJsonp([1],[
/* 0 */
/***/ (function(module, exports, __webpack_require__) {

module.exports = function(cb) {
    __webpack_require__.e/* require.ensure */(0).then((function(require) {
        cb(__webpack_require__(2));
    }).bind(null, __webpack_require__)).catch(__webpack_require__.oe);
}

/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {

const main = () => {
  const lib = __webpack_require__(0)
  lib()
}


/***/ })
],[1]);

System.import

System.import is deprecated だそうです
https://webpack.js.org/guides/code-splitting-async/#system-import-is-deprecated
さして難しくも無いはずなので、import()へ置き換えるだけでよさそう

import()についてもうちょっと深いトピック

webpackでのimport()の制限

なんとなくimport()の構文を見ると

const lazy = (modulePath) => import(modulePath)

的なことが出来そうな気がしてしまうが、これは下記のようなWarningが出る

10:9-22 Critical dependency: the request of a dependency is an expression

webpackにおいては、import()をビルド時に静的解決するので、これが出来ないような作りになっている。

実際のビルド結果では、不正なパッケージとして扱われ、その実態はwebpackEmptyContextに置き換えられてしまう。

var lazySomeApp = lazy("./SomeApp");

var lazy = path = __webpack_require__(281)(path);

/***/ 281:
/***/ (function(module, exports) {

function webpackEmptyContext(req) {
    throw new Error("Cannot find module '" + req + "'.");
}
webpackEmptyContext.keys = function() { return []; };
webpackEmptyContext.resolve = webpackEmptyContext;
module.exports = webpackEmptyContext;
webpackEmptyContext.id = 281;

/***/ }),

babel-plugin-syntax-dynamic-import

babelのbabel-plugin-syntax-dynamic-importを使うやり方も記載されている。

https://webpack.js.org/guides/code-splitting-async/#usage-with-babel

webpack側では解決されず、ただのpromiseに変換される。ファイル分割もされなくなる。
「import()は使いたいけどコード分離はしたくない」とかの場合には使えるかもしれない。

// babelがimport()をこんな感じの関数に置き換える。ただのpromiseになる
const importConverted = (module) => Promise.resolve(module)

TypeScriptとの組み合わせ

TypeScriptと組み合わせた時、現状だと構文エラー扱いになってしまう。
https://github.com/Microsoft/TypeScript/issues/12364

import()を利用する箇所だけjsファイル化する妥協が必要になる。型がつかない問題もあるものの、そのへんはd.tsの型定義ファイルを別で作るか、2.3以上から対応されたJSDocでカバーすると良さそう。

// import.js
export const lazySomeApp = () => import("./SomeApp")
// import.d.ts
import * as SomeApp from "./SomeApp"

export const lazySomeApp = () => Promise<typeof SomeApp>
// Typescript 2.3以降でのJSDoc利用
/**
 * @template T
 * @return {Promise<T>}
 */
export const lazySomeApp = () => import("./SomeApp")

その他にもissueでは、_import()でちょろまかす手段も提案されている。かなりトリッキーかもしれない。
https://github.com/Microsoft/TypeScript/issues/12364#issuecomment-270819757

declare function _import<T>(path: string): Promise<T>;
const lazyPromise = _import("./SomeApp")
// webpack.config.js
// webpack側でこれを置き換えている。力技感
{
    loader: 'string-replace-loader',
    options: {
        search: '_import(',
        replace: 'import('
    }
},

reactとの組み合わせ

非同期なcomponentをreactで扱おうとすると一工夫する必要が出て来る。
そんなに難しいものではないのでreact-routerのcode splittingページあたりを参考に作るでも良いが、ざっくり調べるとこのへんがあった

  • async-reactor
    • Star: 200 over
    • async/awaitをベースにしたcomponentローダー
    • コードベースが小さく素直な作りに見える
  • react-async-component
    • Star: 300 over
    • ベータ扱い
    • SSRも対応している
  • react-loadable
    • Star: 2000 over
    • Issueが閉じられてPull Requ受付のみになっている。
    • 内部で色々やっている

react-loadableは良いのだがissueを受け付けてないという点に少々怖さもあり、個人的に利用するならasync-reactorかreact-async-componentが良さそうだなと感じている。

(おまけ)更なるCode splittingについても少し調べる

ちょっと実戦投入するには勇気がいりそうなものを触りだけ。

SWPrecacheWebpackPlugin

https://github.com/goldhand/sw-precache-webpack-plugin

service-workerのPrecacheをする

const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin')
// ...
// ...
// ...
plugins: [
  new SWPrecacheWebpackPlugin({
    cacheId: 'dynamic-example',
  })

これを実行すると、service-worker.jsというファイルが出力される。
これを下記のように読み込むことでservice-workerが有効になる

(function() {
  if('serviceWorker' in navigator) {
    navigator.serviceWorker.register('service-worker.js');
  }
})();

react-routerのwebsiteでも使われているが、現在はコメントアウトされている

AggressiveSplittingPlugin

読んで字の如くアグレッシブに分割するplugin。特性上、ほぼHTTP2向けという謳い文句。

https://github.com/webpack/webpack/tree/master/examples/http2-aggressive-splitting

作者による記事もある。
https://medium.com/webpack/webpack-http-2-7083ec3f3ce6

plugins: [
  new webpack.optimize.AggressiveSplittingPlugin({
    minSize: 30000,
    maxSize: 50000
  }),
]

指定されたminSize以上maxSize未満を目指して分離していく。
reactあたりをimportしてみるとわかりやすく分割される

const react = require("react")
const main = () => {
  console.log("This is main")
  console.log(react)
}

0,1,2に分割される

      Asset     Size  Chunks             Chunk Names
       0.js  50.4 kB       0  [emitted]
       1.js    52 kB       1  [emitted]
       2.js  42.4 kB       2  [emitted]

__webpack_require__などの挙動、コードベースは特に変更されない。
また、こちらは特にlazyなloadingを目指したものではないので、<script>タグで全部読み込むのが前提に鳴る

<script src="0.js"></script>
<script src="1.js"></script>
<script src="2.js"></script>