Webpack Code Splittingで実施したこと

Cover Image for Webpack Code Splittingで実施したこと

webpackを使っていて、アプリケーションがそれなりの規模になってくる(244KiBを超える)とwebpackコンパイル時に以下のような警告メッセージが出力される。

WARNING in asset size limit: The following asset(s) exceed the recommended size limit (244 KiB).
This can impact web performance.
Assets: 
  main-8bb490cf31ebd571347e.js (285 KiB)
  vendor-34ad324c.js (558 KiB)

WARNING in entrypoint size limit: The following entrypoint(s) combined asset size exceeds the recommended limit (244 KiB). This can impact web performance.
Entrypoints:
  main (844 KiB)
      vendor-34ad324c.js
      main-8bb490cf31ebd571347e.js

長い間警告を放置していたので、できるだけBundleのサイズが小さくしてみた。


ライブラリ の Version

  • webpack
    4.39.2を使っている。
webpack@4.39.2
  • React
    16.9.0を使っている。
react@16.9.0

Bundle の状況を可視化する

まず、どのライブラリがどれくらいのサイズなのかを確認するため、 Code Splitting | webpack に記載されている webpack-contrib/webpack-bundle-analyzer: Webpack plugin and CLI utility that represents bundle content as convenient interactive zoomable treemap をインストールした。

  • インストール
# NPM
npm install --save-dev webpack-bundle-analyzer
  • webpack.config.js にプラグインとして追加
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
}

プラグインとして追加した後、どうするのかわからなかったが、試しにビルドしてみるとビルド時に以下のようなメッセージが表示されブラウザに分析結果が表示された。

Webpack Bundle Analyzer is started at http://127.0.0.1:8888
Use Ctrl+C to close it
Hash: 6c00482c8a1eb112f005
Version: webpack 4.39.2
Time: 5547ms
Built at: 2019-08-18 16:36:49

2019-08-18 16.39.22.png - Google ドライブ
メッセージにも記載されているが、ビルド自体は完了している。 コンソールが入力待ちの状態になっているので、確認後は Ctrl+C を押すと入力まちが解除される。


ライブラリのサイズを小さくする

webpack-bundle-analyzerの結果でそもそも容量の大きいライブラリがあるのがわかったので、対象ライブラリの容量を削減できないか試した。

highlight.js

ライブラリのサイズを確認したところ、highlight.jsで1MBくらい使用していたので、Moment.jsのようにサイズを縮小できるか調べたところ以下の記事を見つけた。

highlight.jsを小さくバンドルする方法 | I am mitsuruog

上記記事を参考に、highlight.jsの使用箇所を以下のように実装した。

import hljs from "highlight.js/lib/highlight";
import javascript from "highlight.js/lib/languages/javascript";
import css from "highlight.js/lib/languages/css";
import java from "highlight.js/lib/languages/java";
import xml from "highlight.js/lib/languages/xml";
import python from "highlight.js/lib/languages/python";
import json from "highlight.js/lib/languages/json";
import typescript from "highlight.js/lib/languages/typescript";
import sql from "highlight.js/lib/languages/sql";

hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("css", css);
hljs.registerLanguage("java", java);
hljs.registerLanguage("xml", xml);
hljs.registerLanguage("python", python);
hljs.registerLanguage("json", json);
hljs.registerLanguage("typescript", typescript);
hljs.registerLanguage("sql", sql);

optimization.splitChunks を使用する

optimization.splitChunksを使うと、複数のエントリーポイントで共通して使用するモジュールをバンドルしたファイルを作成できる。node_module配下のライブラリのみをバンドルしたかったため、webpack.config.jsには以下のように記載した。

    optimization: {
        splitChunks: {
            cacheGroups: {
            commons: {
              test: /[\\/]node_modules[\\/]/,
              name: "vendor",
              chunks: "initial",
            }
          }
        }
    },

Dynamic import を使用する

ちゃんと理解するCode Splitting - Qiita に記載があるが、webpackのDynamic Importsを使ってファイルを分割できる。
Dynamic Importsのために実施したことを記載する。

事前準備

Dynamic importsを実行するための事前準備が必要で、WebpackでDynamic importを行ってみる - Qiita が参考になった。
babelはVersion6と Version7でライブラリのパッケージ名が変更されているので、互換性注意。
ここはエラーとなるため間違えたら動かないため気づくと思う。

実装の変更

以下の通り、実装を修正した。

  • import 文の修正
    後述するがSystem.import を使用した。
import React, {Suspense, lazy, Component} from "react";    

const PostList = lazy(() => System.import(/* webpackChunkName: 'postList', webpackPrefetch: true */"./postList.js"));
const PostDetail = lazy(() => System.import(/* webpackChunkName: 'postDetail', webpackPrefetch: true */"./postDetail.js"));
const TagPostList = lazy(() => System.import(/* webpackChunkName: 'tagPostList', webpackPrefetch: true */"./tagPostList.js"));
const About = lazy(() => System.import(/* webpackChunkName: 'about', webpackPrefetch: true */"./about.js"));
const SearchPostList = lazy(() => System.import(/* webpackChunkName: 'searchPostList', webpackPrefetch: true */"./searchPostList.js"));
const NoMatch = lazy(() => System.import(/* webpackChunkName: 'searchPostList', webpackPrefetch: true */"./noMatch.js"));

Suspense と、lazy は React 16.6 から追加された機能。Code-Splitting – React に使用方法が記載されている。

webpackChunkNamewebpackPrefetch 等のMAGIC COMMENTはカンマ区切りで複数記載できる。 Module Methods | webpack<link rel=”prefetch/preload”> in webpack - webpack - Medium が参考になった。

  • Suspenseの使用箇所
    ちなみに、lazy でimportしたコンポーネントを Suspense で囲わないとエラーになる。
<Suspense fallback={<div>Loading...</div>}>
    <Switch>
        <Route exact path="/" component={PostList}/>
        <Route exact path="/posts" component={PostList}/>
        <Route exact path="/posts/:slug" component={PostDetail}/>
        <Route exact path="/about" component={About}/>
        <Route exact path="/search/:query" component={SearchPostList}/>
        <Route exact path="/tag/:tagName" component={TagPostList}/>
        <Route component={NoMatch}/>
    </Switch>
<Suspense>
  • System.import を使用する理由
    dynamic import naming doesn't work · Issue #4861 · webpack/webpack に記載されているが、importでは webpackChunkName が効いていない動作をしていて、System.import を使用すると期待通りに動作した。
    一部 .babelrc の記載を変更したので、.eslintrc.json も一緒に貼り付けておく。

    • .babelrc
    {
    "presets": [
        [
        "es2015",
        {
            "modules": false
        }
        ],
        "react"
    ],
    "plugins": [
        "transform-object-rest-spread",
        ["babel-root-import", {
        "rootPathSuffix": "src/js"
        }],
        "babel-plugin-syntax-dynamic-import",
        "dynamic-import-webpack"
    ],
    "env": {
        "test": {
        "plugins": [
            "transform-es2015-modules-commonjs"
        ]
        }
    }
    }
    
    • .eslintrc.json
    {
    "env": {
        "serviceworker": true,
        "browser": true,
        "es6": true
    },
    "globals": {
        "System": true
    },
    "extends": "eslint:recommended",
    "parser": "babel-eslint",
    "parserOptions": {
        "allowImportExportEverywhere": true,
        "ecmaFeatures": {
        "experimentalObjectRestSpread": true,
        "jsx": true
        },
        "sourceType": "module"
    },
    "plugins": [
        "react"
    ],
    "rules": {
        "react/jsx-uses-react": 1,
        "indent": [
        "error",
        4
        ],
        "react/jsx-uses-vars": 1,
        "indent": [
        "error",
        4
        ],
        "linebreak-style": [
        "error",
        "unix"
        ],
        "quotes": [
        "error",
        "double"
        ],
        "semi": [
        "error",
        "always"
        ]
    }
    }
    
  • System.import の警告を抑止する
    webpackで、System.importを使用していると以下の警告が出力される。

WARNING in ./src/js/components/main.js 39:11-104
System.import() is deprecated and will be removed soon. Use import() instead.
For more info visit https://webpack.js.org/guides/code-splitting/
 @ ./src/js/index.js 46:0-37 58:59-63
 @ multi ./src/js/index ./src/js/vendor ./src/js/sw/client/bsMessenger

これは、webpack.config.jsに parser: { system: true } を記載すると抑止できる。

    {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/, // 除外するファイル/ディレクトリ(正規表現可)
        loader: "babel-loader", // 使用するloader
        query: {
            plugins: ["transform-react-jsx"] // babelのtransform-react-jsxプラグインを使ってjsxを変換
        },
        parser: { system: true } 
    },

JavaScript ファイルの出力結果

以下のように出力される。
Dynamic importsの結果にsplitChunksの設定が効いているようで、名前付きのJavaScriptファイルごとのvendors JavaScriptファイルが出力された。

about-0ed78f04.js
main-6c00482c8a1eb112f005.js
postDetail-b0c09e07.js
postList-ee0eebb1.js
precache-manifest.0783219453ca8a343aa46aeeeccf4f48.js
searchPostList-09ffa4b1.js
tagPostList-9d1370e9.js
vendor-99fd9d74.js
vendors~about~postDetail-6b6d532c.js
vendors~postDetail-d6a6302f.js
vendors~postDetail~postList~searchPostList~tagPostList-4f615395.js
vendors~postList~searchPostList~tagPostList-fea913a6.js

感想

Bundleの状況を可視化は、最適化前には必ず実施した方が良い。
今回一番効果があったのは、ライブラリのサイズの削減で、それに気づいたのは可視化したから。


参考