見出し画像

bundleサイズ削減、minify、gzipでフロントエンドのパフォーマンス改善をためしてみた

はじめに

こんにちは、SHIFT の開発部門に所属している Katayama です。

バックエンドでは配置する資材のサイズについて気にする事はないが、フロントエンドの場合は違う。 ブラウザの読み込み速度は利用者の快適さに直結するので、できるだけフロントエンドの資材のネットワーク転送量は少ない方がいい。

そこで今回は、以下に挙げた 3 つの方法を使って、できるだけネットワーク転送量が少なくなるようにし読み込み速度を向上させるという事をやってみた。

webpack-bundle-analyzerを用いて webpack で build する際に bundle サイズを小さくする
②HTML/CSS/JavaScript を縮小する(minify)
③gzip による圧縮する

以下でそれぞれの方法についてみていく(①・② については、実際にその方法を試すのは純粋な webpack ではなく、Vue CLIを利用したものになっている)。

webpack-bundle-analyzerを用いて webpack で build する際に bundle サイズを小さくする

これはwebpack-bundle-analyzerで bundle しているモジュールのサイズを可視化して、その中で無駄に大きなものを削減するというアプローチ。Vue CLI であれば、vue.config.js を以下のように設定する事で webpack-bundle-analyzer が利用できるようになる。

// vue.config.js
// 省略
const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer");

module.exports = {
  configureWebpack: {
    plugins: [
      // 省略
      new BundleAnalyzerPlugin({
        analyzerMode: "static",
        reportFilename: "./../report/index.html", // デフォルトだと./dist以下にファイルができるので、./report/index.htmlにファイルができるようにしている
      }),
    ],
  },
  // 省略
};

この設定をした上で、npx vue-cli-service build でビルドを行うと、以下のようなサイトが見れるようになる(今回は static なので html が出力されるだけだが、VS Code のLive Serverを利用するなどで簡単にブラウザで見れるようになる。また、analyzerMode=server にすれば webpack-bundle-analyzer 側でサーバーを起動してくれるが、ビルド時にサーバーが立ち上がるので、ビルドが完了せず CI 等で困るので注意)。

例えば、上記のように分析ができたとしたらどのような事を考えるかだが、今回であればまずは、momentlodashの bundle サイズを削減できないか?を考える(そもそも moment については新規プロジェクトでの利用は非推奨なので利用は減ってきているかもしれないが)。

moment については locale が大量に bundle されており、仮に ja, en の 2 種類しか想定していないのであれば、それ以外は不要で無駄が多くなっていると言えるので、そこを削減する。今回はContextReplacementPluginを利用した方法で削減をしてみようと思う。実装としては以下のようになる。

const webpack = require("webpack");
// 省略

module.exports = {
  configureWebpack: {
    plugins: [
      // 省略
      new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /ja|en/),
      new BundleAnalyzerPlugin({
        analyzerMode: "static",
        reportFilename: "./../report/index.html",
      }),
    ],
  },
  // 省略
};

上記のようにする事で bundle サイズが削減できるのは、まず、webapck でコンテキスト(require('./locale/' + name + '.json')のような式で require を参照する)を利用すると、コンパイル時には name が分からないので、webpack は推論してすべてのファイルをモジュールとしてバンドルに含めるような挙動になる。その推論された情報を上書きする事ができるのが ContextReplacementPlugin で、これにより moment/locale/{name}の name が/ja|en/にマッチするファイルのみが bundle されるようになる(以下、公式からの引用)。

Context refers to a require with an expression such as require('./locale/' + name + '.json'). When encountering such an expression, webpack infers the directory ('./locale/') and a regular expression (/^._.json(コンテキストとは、のような式でを参照することです。このような式に遭遇した場合、は
ディレクトリと正規表現/)
.Sincethenameisnotknownatcompiletime,webpackincludeseveryfileasmoduleinthebundle.(コンテキストとは、require(′./locale/′+name+′.json′)のような式でrequireを参照することです。このような式に遭遇した場合、webpackはディレクトリ(′./locale/′)と正規表現(/..json/)を推測します。コンパイル時には名前は分からないので、webpack はすべてのファイルをモジュールとしてバンドルに含めます。)

ちなみに上記の実装については、webpack の公式にも以下の画像のように書かれている。

次に lodash の方だが、これは import の仕方を変える事で簡単に bundle サイズを削減できる。具体的には以下のように変更する。

import { snakeCase } from "lodash";
// ↓
import snakeCase from "lodash/snakeCase ";

これで lodash の全モジュールが bundle されるのではなく、利用している snakeCase のモジュールだけが bundle されるようになり、サイズを削減できる。

ここまでの対応で、その before・after をまとめると以下のようになる。元々 590.27KB だったものが、284.32KB(約半分)にまで削減できている事が分かる。

ちなみに、bundle サイズ削減後のレポートは以下。

※他にも bundle サイズの削減のために調整できそうな部分はあるが、記事の長さの都合上割愛している。また、今回は以下で webpack 単体で使う際の方法に関して触れる都合上、あえて BundleAnalyzerPlugin の設定を行ったが、Vue CLI(vue-cli-service)を使っているのであれば、特に追加設定をせずとも「おまけ」に書いているような方法で bundle サイズの確認ができる。

※上記では Vue だったが、React・Angular も cli ツールがあり、その中では webpack が隠蔽された形で動いているので同様の事はできる。

webpack だとどうなるか?

Vue CLI など他のツールに隠蔽された webpack ではなく、自分で webpack の設定をしている場合にも webpack-bundle-analyzer はもちろん利用でき、その場合は以下のように plugins に webpack-bundle-analyzer を設定すればいい。

// webpack.config.js
// 省略
const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer");

module.exports = {
  entry: "./src/client/index.js",
  mode: "production",
  // 省略
  plugins: [
    // 省略
    new BundleAnalyzerPlugin({
      analyzerMode: "static",
      reportFilename: "./../report/index.html",
    }),
  ],
};

②HTML/CSS/Javascript を縮小する(minify)

Vue CLI の webpack の設定については、Inspecting the Project's Webpack Configに書かれているように、以下のコマンドで確認する事ができ、確認すると mode=production の場合、デフォルトで HTML/CSS/Javascript の minify に必要な設定が全てそろっている事が確認できる。

[study@localhost intro-vuecli]$ npx vue-cli-service inspect --mode production > output.prod.js
// output.prod.js
{
	// 省略
	optimization: {
		// 省略
		minimizer: [
			/* config.optimization.minimizer('terser') */
			new TerserPlugin({
				terserOptions: {
					compress: {
						arrows: false,
						collapse_vars: false,
						comparisons: false,
						computed_props: false,
						hoist_funs: false,
						hoist_props: false,
						hoist_vars: false,
						inline: false,
						loops: false,
						negate_iife: false,
						properties: false,
						reduce_funcs: false,
						reduce_vars: false,
						switches: false,
						toplevel: false,
						typeofs: false,
						booleans: true,
						if_return: true,
						sequences: true,
						unused: true,
						conditionals: true,
						dead_code: true,
						evaluate: true
					},
					mangle: {
						safari10: true
					}
				},
				parallel: true,
				extractComments: false
			}),
			/* config.optimization.minimizer('css') */
			new CssMinimizerPlugin({
				parallel: true,
				minimizerOptions: {
					preset: [
						'default',
						{
							mergeLonghand: false,
							cssDeclarationSorter: false
						}
					]
				}
			})
		]
	},
    // 省略
	plugins: [
		// 省略
		/* config.plugin('html') */
		new HtmlWebpackPlugin({
			title: 'hello-vuecli',
			scriptLoading: 'defer',
			templateParameters() {
				/* omitted long function */
			},
			template: '/home/study/workspace/intro-vuecli/public/index.html'
		}),
// 省略
};

という事で、npx vue-cli-service build とビルドを行えば、minify は自動的に行われるので、Vue CLI を利用しているのであれば開発者は特に minify について意識する事はないだろう。

webpack

Vue CLI など他のツールに隠蔽された webpack ではなく、自分で webpack の設定をしている場合についても、上記の Vue CLI が暗黙的にやっていた事を真似すれば同じ事ができる。

Javascript については、webpack で mode=production に設定すると、デフォルトで minify してくれるので(TerserPlugin というのがそれ)、特に追加の設定は不要(以下、webpack   Modeからの引用)(自分でカスタマイズするのであればもちろん設定は必要)。

Sets process.env.NODE_ENV on DefinePlugin to value production. Enables deterministic mangled names for modules and chunks, FlagDependencyUsagePlugin, FlagIncludedChunksPlugin, ModuleConcatenationPlugin, NoEmitOnErrorsPlugin and TerserPlugin.

HTML/CSS については、Vue CLI の設定のように、HTML ならHtmlWebpackPluginなどを、CSS ならCssMinimizerPluginなどを利用してそれぞれ minify する必要がある。

※補足として、Javascript の minify については、以前はuglify-jsを webpack で利用できるようにしたプラグインUglifyJS Webpack Pluginが使われていたが、今は非推奨になりterserの webpack プラグインterser-webpack-pluginが使われる。terser-webpack-plugin の設定オプションは公式に書かれているが、その中でも compress については terser の GitHubCompress optionsを見るのがよいだろう。

※ちなみに、webpack のデフォルト設定についてはwebpack default options (source code)に書かれている。また、その中に terser-webpack-plugin が含まれている事も確認できる

③gzip による圧縮する

最後に見ていくものは、静的コンテンツを圧縮してブラウザに転送されるファイルサイズを小さくする事で転送量を減らすアプローチ。今回利用する方法は gzip だが、これはファイルの圧縮方法の 1 つ。

今回は Express で静的コンテンツを配信する場合において、gzip 化する方法についてみていきたいと思う。具体的には、Express の middleware にcompressionを登録する、という方法になる。実装としては以下のようになる。

// 省略
import compression from "compression";

const app = express();
// 省略

app.use(compression({ level: 1 }));
app.use(express.static("static"));
// 省略

app.get("*", (req, res) => {
  res.sendFile(appRoot.resolve("static/index.html"));
});

// 省略
app.listen(3000, () => console.log("listening on port 3000!"));

compression のオプションについては公式に書かれているので詳細はそちらを参照して頂きたいが、上記の設定について少し補足する。

・level: 1
gzip の圧縮レベルを 1 にして速度優先の設定にしている(1 ~ 9 までの圧縮レベルがあり、レベルが上がるほど時間もリソースも必要になるのでバランスに注意する必要がある。ちなみに、圧縮レベルで 9 などを利用するパターンとしては、サーバーログを保管しておく必要がある時に少し時間をかけてでも圧縮したい時などが挙げられる。)。

上記のように実装した上で、圧縮の効果を確認してみると、以下のように gzip で圧縮されてサイズが小さくなっている事が確認できる。

・圧縮前

・圧縮後

※上記は Chrome のネットワーク設定で「Slow 3G」にしてして画面を読み込んだ際のネットワークの状態を見たもの。ちなみに、animate.min.css は CDN から取得しているものなので、compression ミドルウェアで gzip 化されていない。

※chunk-vendors.0b8fe69d.js などが gzip で圧縮されている事は以下のように Response Header を見ればわかる。

※今回は gzip 形式で圧縮を行うが、他にも圧縮の方法はいくつかあり、ブラウザごとに対応しているものが違う。ブラウザがどの方法に対応しているか?はAccept-Encodingというリクエストヘッダで確認でき、圧縮方法としてはディレクティブに書かれているようなものがある。Chrome だと以下のように gzip・deflate・br の 3 つに対応しているようである。

※ちなみに、Express を単に Web サーバーとして立てるなら特に気にする事はないが、Web サーバーでもあり API サーバーでもあるという構成にしたいのであれば、以下のように Express のミドルウェアの登録順序には注意が必要(app.useの順番という事)。理由は Express のミドルウェアは上から順番に実行されるので、もし静的コンテンツの配信を一番上に持ってくると、'*'で全てのパスが一致するので API を実行するができなくなる(以下、公式からの引用)。

Middleware functions are executed sequentially, therefore the order of middleware inclusion is important.(ミドルウェアの機能は順次実行されるため、ミドルウェアを組み込む順番は重要です。)

// 省略
import express, { Router } from 'express';

const app = express();
const router = Router();
// 省略

// ここで以下のような処理を書くと、GET:/api/v1/hogeが GET:'*' に合致するのでAPIがコールできなくなってしまう
// app.get('*', (req, res) => {
// 	res.sendFile(appRoot.resolve('static/index.html'));
// });

app.use('/api/v1', router);
// 省略

// router(API)をmiddlewareに登録した後に以下のようにmiddleware関数を呼び出すパスを登録する
app.get('*', (req, res) => {
	res.sendFile(appRoot.resolve('static/index.html'));
});

router.get('/hoge', (req, res) => {
	try {
		...
	} catch (error) {
		...
	}
});
// 省略

※Express で配信する静的コンテンツを最初から gzip 化する(webpack のcompression-webpack-pluginなどを利用して)という考え方もある。しかしその場合、ブラウザの Accept-Encoding によっては gzip が不可な事もあり、その場合には解凍して配信するという事が必要になる。不可な場合を想定する場合、明示的に上段に Nginx のようなリバースプロキシやCDNを置いて、ブラウザの Accept-Encoding を見て解凍するなどの処理を組み込む必要が出てくるので、暗黙的に圧縮できる時には圧縮を行うような実装・構成にする方がいい、という考え方もある。

※compression ライブラリの説明に出てくる zlib.Z_DEFAULT_COMPRESSION の値はConstantsに書かれている

・参考:SPA のロード時間を短縮するためにやったこと その2 - webpack で gzip 圧縮をかけたファイルを配信する

まとめ

今回はフロントエンドのパフォーマンス改善として、ブラウザでの読み込み速度を向上させる方法 3 つについてみてきた。昔に比べネットワークで転送できる量自体が増えているので、ある程度のサイズでも快適に動作するが、はやりサイズが小さい方がパフォーマンスとしては良くなるので、今回見た来たような方法で改善するのがいいと思った。

※上記の gzip での圧縮の章では、Express のServing static files in Expressを利用して Express が Web サーバーも兼ねて静的コンテンツ(フロントエンド)を配信するパターンについてみてきたが、今後、インフラ構成を変えたパターン(Nginx が Web サーバーの場合や、本番を仮想的に再現して AWS 上での構築の場合)など他のパターンもやってみたいと思う。

おまけ

vue-cli-serviceの機能で bundle サイズを確認する

以下のように npx vue-cli-service build に --report というオプションを付ける事で report.html が出力される。出力されるレポートは BundleAnalyzerPlugin のレポートと全く同じ。

[study@localhost intro-vuecli]$ npx vue-cli-service build --report

[study@localhost intro-vuecli]$ tree dist/
dist/
├── _redirects
├── css
│   ├── app.dbcdd916.css
│   └── chunk-vendors.0833f3cd.css
├── favicon.ico
├── index.html
├── js
│   ├── app-legacy.4f8dfcd6.js
│   ├── app-legacy.4f8dfcd6.js.map
│   ├── app.089bffe9.js
│   ├── app.089bffe9.js.map
│   ├── chunk-vendors-legacy.c073859a.js
│   ├── chunk-vendors-legacy.c073859a.js.map
│   ├── chunk-vendors.0b8fe69d.js
│   └── chunk-vendors.0b8fe69d.js.map
├── legacy-report.html // <- これが出力されたreport
└── report.html // <- これが出力されたreport

・参考:vue-cli-service build

《この公式ブロガーの記事一覧》


執筆者プロフィール:Katayama Yuta
SaaS ERPパッケージベンダーにて開発を2年経験。 SHIFTでは、GUIテストの自動化やUnitテストの実装などテスト関係の案件に従事したり、DevOpsの一環でCICD導入支援をする案件にも従事。 昨年に開発部門へ異動し、再び開発エンジニアに。座学で読み物を読むより、色々手を動かして試したり学んだりするのが好きなタイプ。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/

みんなにも読んでほしいですか?

オススメした記事はフォロワーのタイムラインに表示されます!