# JavaScriptファイルの最適化

ブラウザーのタスクで最も遅くなる原因 (opens new window)がJavaScriptと言われています。JavaScriptファイルを最適化することで、ブラウザーへの負荷を減らし、パフォーマンスを改善させることができます。

近年のフロントエンド開発では、WebpackやRollup、Turbopackなどのバンドラーツールで開発することが主流となっています。バンドラーツールは、複数あるJavaScriptファイルを一つにまとめたり、JavaScriptファイル内でCSSや画像を読み込む機能などを提供し、効率的なフロントエンド開発を実現しています。しかし、最終的な成果物であるバンドルファイルは、アプリケーションが大きくなるにつれ、肥大化する傾向にあります。この肥大化したバンドルファイルをそのままブラウザーに読み込ませると、ダウンロードからパース、コンパイル、実行までの処理が遅れてしまいます。メインスレッドで処理するタスクが長くなると、他のタスクが待機状態となってしまいます。そのため、ユーザーへの反応が遅くなったり、FCP、LCP、TTIのパフォーマンスが低下する可能性が発生します。

基本的な戦略として、JavaScriptファイルを分割し、サイズを極力削減して、必要なタイミングでダウンロードすることが重要となります。そうすることで、処理に時間がかかるタスクを分割し、ブラウザーへのコストを減らすことができます。

JavaScriptタスクの最適化

出典: Optimize long tasks (opens new window)

この章では、JavaScriptファイルの削減方法と最適化の方法を見ていきたいと思います。

# JavaScriptのサイズを削減する

# Tree Shaking

Tree Shaking (opens new window)とは、ビルド時に不要なコードを削除する機能です。Webpack (opens new window)Rollup (opens new window)などのバンドラーツールで提供されている機能で、余分なコードをなるべく排除してバンドルサイズを極力小さくすることを目的としています。

# Tree Shakingの仕組み

Tree Shakingは、ESModulesのimport/export構文で書かれたファイル、もしくはCommonJS (opens new window)に対して動作します。基本的な仕組みは、JavaScriptファイルから使われていないモジュールを検知して、自動的に削除するというものです。具体的にTree Shakingがどのように動作するのかWebpack5を例に見てみましょう。

はじめに、modules.jsというモジュールを用意してみましょう。module1module2という関数をエクスポートしています。

export const module1 = () => {
  console.log('module1')
}

export const module2 = () => {
  console.log('module2')
}

Webpackのエントリーファイルでは、このモジュールをインポートしてmodule1のみを使用します。

import { module1 } from './modules'

const main = () => {
  module1()
}

main()

これをWebpackでビルドしてみましょう。バンドルファイルはmain.js4.26KiBになっていることが分かります。



 







$ webpack --config webpack.config.js

asset main.js 4.26 KiB [compared for emit] (name: main)
runtime modules 670 bytes 3 modules
cacheable modules 197 bytes
  ./src/index.js 80 bytes [built] [code generated]
  ./src/modules.js 117 bytes [built] [code generated]
webpack 5.88.0 compiled successfully in 49 ms
✨  Done in 0.58s.

バンドルファイルのmain.jsの中身を見ると、module1と同時に使われていないmodule2も含まれていることが分かります。









 

/***/ "./src/modules.js":
/*!************************!*\
  !*** ./src/modules.js ***!
  \************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   " +
  "module1: () => (/* binding */ module1),\n/* harmony export */   " +
  "module2: () => (/* binding */ module2)\n/* harmony export */ });\nconst module1 = () => {\n  console.log('module1')\n}\n\nconst module2 = () => {\n  console.log('module2')\n}\n\n\n//# sourceURL=webpack://footer/./src/modules.js?");

module2はアプリケーション上では使われていないので、本来は必要のないコードとなります。このコードを削除できれば、バンドルファイルの削減ができそうです。 では、Tree Shakingを使用して、この使われていないmodule2を削除してみましょう。Webpack5では、developmentモードでTree Shakingを有効にする場合、webpack.config.jsoptimization.usedExports (opens new window)を設定します。

TIP

productionモードでは自動的に有効になりますが、検証のためdevelopmentモードにしています。














 
 
 


module.exports = {
    mode: "development",
    entry: "./src/index.js",
    output: {
        path: path.resolve(__dirname, "./build"),
        filename: "main.js",
    },
    resolve: {
        extensions: [".jsx", ".js"],
    },
    stats: {
        usedExports: true
    },
    optimization: {
        usedExports: true,
    },
}

これを実行すると、次のような結果になります。



 





 



$ webpack --config webpack.config.js

asset main.js 3.72 KiB [emitted] (name: main)
runtime modules 396 bytes 2 modules
cacheable modules 197 bytes
  ./src/index.js 80 bytes [built] [code generated]
    [no exports used]
  ./src/modules.js 117 bytes [built] [code generated]
    [only some exports used: module1]
webpack 5.88.0 compiled successfully in 45 ms
✨  Done in 0.74s.

main.js3.72 KiBに減少したことが分かります。また、[only some exports used: module1]となっていることから、module1だけが使われていることが分かります。

modeproductionにすると自動的にTree Shakingとminifyが有効になり、次のようなバンドルファイルが生成されます。

(() => {
  "use strict";
  console.log("module1");
})();

Productionビルドではminifyも動作するため、モジュールの部分が取り除かれて、最小限のコードに最適化されます。Tree Shakingで必要のないモジュールが排除され、minifyでmodule1だけの処理がバンドルファイルに生成されるようになります。

# Tree Shakingされないとき

しかし、インポートに含まれていなくてもTree Shakingされないケースがあります。例えば、modules.jsに次のようなmodule3という変数を追加してみましょう。









 

export const module1 = () => {
  console.log('module1')
}

export const module2 = () => {
  console.log('module2')
}

export const module3 = window.alert("alert!")

この状態で、Productionモードのビルドを実行すると、次のようなバンドルファイルが生成されます。

(() => {
  "use strict";
  window.alert("alert!"), console.log("module1");
})();

index.jsでは、module1しかインポートしていないのにmodule3の処理が含まれてしまっています。これは、SideEffects (opens new window)によるもので、Webpackが静的解析で読み込んでいるときに、副作用の可能性があるコードを削除せずにそのまま残すため、バンドルに含まれてしまうというものです。

副作用のあるコードとは、インポートされたときに何かしらの処理が発生する可能性のあるものを言います。上記のコードでは、modules.jsを読み込んだ段階でwindow.alertが実行されています。このような処理を副作用といいます。Webpackはこの副作用の処理が、アプリケーションに必要か必要でないか判断できないので、安全性のために残すようになっています。

export const module3 = window.alert("alert!")

この副作用は、Barrel Filesというファイル構成でも発生します。Barrel Filesとはフォルダのファイルをindex.jsからまとめてexportしているファイルを指します。例えば、次のようなフォルダ構成を見てみましょう。

src/barrel/
├── index.js
├── moduleA.js
├── moduleB.js
└── moduleC.js

index.jsでは、各モジュールをまとめてexportしています。

export * from './moduleA'
export * from './moduleB'
export * from './moduleC'

モジュールを使用する場合は次のように書くことができます。

import { moduleA } from './barrel'

const main = () => {
  moduleA()
}

Barrel Filesは外部に公開するモジュールと内部だけで使用するモジュールを分けたり、使用する側がインポートするモジュール名を統一できるなどのメリットがあります。LodashなどのNPMライブラリでも採用されているフォルダ構成です。このBarrel Filesを使うときに、Tree Shakingが動作せず、バンドルサイズが増加してしまうケースがあります。

例えば、先ほどのsrc/barrel/で副作用のあるコードが含まれていたとしましょう。

// moduleA.js

export const moduleA = () => {
  return 'moduleA'
}

// 副作用のコード
console.log('moduleA')
// moduleB.js

export const moduleB = () => {
  return 'moduleB'
}

// 副作用のコード
console.log('moduleB')
// moduleC.js

export const moduleC = () => {
  return 'moduleC'
}

// 副作用のコード
console.log('moduleC')

そして、index.jsmoduleAだけをインポートして使用します。

import { moduleA } from './barrel'

export const main = () => {
  console.log('main!')
  moduleA()
}

main()

このコードをビルドすると、次のようにインポートしていないmoduleBmoduleCが含まれてしまっています。

(() => {
  "use strict";
  console.log("moduleA"), console.log("moduleB"), console.log("moduleC");
})();

このようにBarrel Filesのファイル数が多ければ多いほど、意図しないJavaScriptファイルが含まれてバンドルサイズが増加してしまいます。Next.jsでも過去に同様のissue (opens new window)が上がっているようです。

# 副作用を回避するには

このような副作用によるバンドルサイズの増加は回避することができます。Webpackでは、sideEffects (opens new window)というオプションが用意されており、これをpackage.jsonに設定することで副作用がないということを明示的にWebpackに教えることができます。

{
  "name": "your-project",
  "sideEffects": false
}

sideEffectsfalseに設定して、再度、先ほどのBarrel Filesをビルドしてみましょう。すると、バンドルファイルにはmoduleAのみが含まれるようになります。

(() => {
  "use strict";
  console.log("moduleA"), console.log("main!");
})();

NPMライブラリにもsideEffectsが設定されていれば、同様にTree Shakingが可能となります。例えば、Lodash (opens new window)Chakra UI (opens new window)などでも設定されています。

また、sideEffects以外でも、Barrel Filesのモジュールを直接インポートすることで回避することができます。先ほどの例だと、moduleAsrc/barrelからインポートしていましたが、src/barrel/からではなく、直接src/barrel/moduleAからインポートするようにします。

// import { moduleA } from './barrel'
import { moduleA } from './barrel/moduleA' // 直接インポートする

export const main = () => {
  console.log('main!')
  moduleA()
}

main()

このように書くことで、Tree Shakingの影響範囲を明示的にすることができます。そのため、moduleBmoduleCなどの不要なファイルのインポートを防ぐことができます。すでに大量のBarrel Filesがあり、手作業で修正するのが大変な場合や自動的に適用したい場合は、Babelのbabel-plugin-transform-imports (opens new window)を使用すれば、自動的にBarrel Filesのimportをモジュールファイルごとに書き換えてくれます。

プラグインの設定でBarrel Filesの対象モジュールを記載すると、トランスパイル時に直接インポートする形式に書き換えてくれます。

{
  "plugins": [
    ["transform-imports", {
      "react-bootstrap": {
        "transform": "react-bootstrap/lib/${member}",
        "preventFullImport": true
      },
      "lodash": {
        "transform": "lodash/${member}",
        "preventFullImport": true
      }
    }]
  ]
}

トランスパイル前:

import { Row, Grid as MyGrid } from 'react-bootstrap';
import { merge } from 'lodash';

トランスパイル後:

// moduleごとのimportに書き換える
import Row from 'react-bootstrap/lib/Row';
import MyGrid from 'react-bootstrap/lib/Grid';
import merge from 'lodash/merge';

また、Next.js13でもModularize Imports (opens new window)というオプションがあり、babel-plugin-transform-importsと同様の処理をしてくれます。

// next.config.js

module.exports = {
  modularizeImports: {
    'react-bootstrap': {
      transform: 'react-bootstrap/{{member}}',
    },
    lodash: {
      transform: 'lodash/{{member}}',
    },
  },
}

Tree Shakingを使うことである程度のファイル削減が期待できるでしょう。特に、Barrel Filesで意図せずファイルを読み込んでいた場合は、sideEffectオプションか直接インポートすることで、モジュールの最適化をすることができます。

# CommonJSではなくES Modulesを使う

Webpackでは、CommonJSとES Modulesの二つのモジュールフォーマットを扱うことができますが、アプリケーションのコードはES Modulesで書いた方がファイル量を削減することができます。

例えば、次のようなCommonJSをビルドしてみましょう。

 







const modules = require('./modules')

const main = () => {
  modules.module1()
}

main()

ビルドした結果は次のようになります。生成されたファイルは、618 bytesになりました。



 





$ webpack --config webpack.config.js

asset main.js 618 bytes [compared for emit] [minimized] (name: main)
runtime modules 670 bytes 3 modules
cacheable modules 206 bytes
  ./src/index.js 89 bytes [built] [code generated]
  ./src/modules.js 117 bytes [built] [code generated]

では、今度はES Modulesで書いてみましょう。

 







import { module1 } from './modules';

const main = () => {
  module1()
};

main();

このファイルをビルドすると、次のような結果になります。



 




$ webpack --config webpack.config.js

asset main.js 69 bytes [emitted] [minimized] (name: main)
orphan modules 166 bytes [orphan] 1 module
./src/index.js + 1 modules 247 bytes [built] [code generated]
webpack 5.88.0 compiled successfully in 575 ms

ファイルサイズは大幅に減って、69 bytesになりました。このように、CommonJSと比べるとES Modulesの方がファイル量を削減することができます。なぜこれほど違いが出るのでしょうか。

CommonJSがES Modulesよりもコード量が多くなる理由は、モジュールの依存解決の違いによります。ES Modulesは静的な解析ができるため、ビルド時に最適化されたバンドルファイルを生成することができます。Webpackでは、TerserWebpackPlugin (opens new window)を使うことで、余分なコードやモジュールを削減することができます。例えば、先ほどのバンドルファイルは次のようになっています。

(() => {
  "use strict";
  console.log("module1");
})();

import { module1 } from './modules';の処理がそのままコード化されています。このようなモジュールの最適化を行うことでファイル量を極力小さくすることができます。

一方、CommonJSは動的にモジュールを読み込むため、ランタイムの実行時に依存関係を解決します。そのため、Webpackでビルドするときでは、モジュール同士の最適化ができないため、冗長なコードになってしまいます。例えば、先ほどのバンドルファイルは次のようなコードになっています。

(() => {
  var e = {
      241: (e, o, r) => {
        "use strict";
        r.r(o), r.d(o, { module1: () => t, module2: () => l });
        const t = () => {
            console.log("module1");
          },
          l = () => {
            console.log("module2");
          };
      },
    },
    o = {};
  function r(t) {
    var l = o[t];
    if (void 0 !== l) return l.exports;
    var n = (o[t] = { exports: {} });
    return e[t](n, n.exports, r), n.exports;
  }
  (r.d = (e, o) => {
    for (var t in o)
      r.o(o, t) &&
        !r.o(e, t) &&
        Object.defineProperty(e, t, { enumerable: !0, get: o[t] });
  }),
    (r.o = (e, o) => Object.prototype.hasOwnProperty.call(e, o)),
    (r.r = (e) => {
      "undefined" != typeof Symbol &&
        Symbol.toStringTag &&
        Object.defineProperty(e, Symbol.toStringTag, { value: "Module" }),
        Object.defineProperty(e, "__esModule", { value: !0 });
    }),
    r(241).module1();
})();

Webpackによるminifyが行われてはいますが、ES Modulesと比べると冗長なコードになっていることが分かります。次に、optimization.minimize: falseにしてminifyしないコードを見てみましょう。













 
 
 


const path = require("path");

module.exports = {
  mode: "production",
  entry: "./src/index.js",
  output: {
    path: path.resolve(__dirname, "./build"),
    filename: "main.js",
  },
  resolve: {
    extensions: [".jsx", ".js"],
  },
  optimization: {
    minimize: false
  },
};

バンドルファイルはminifyされずに次のようなコードになります。

/******/ (() => { // webpackBootstrap
/******/ 	var __webpack_modules__ = ({

/***/ 241:
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   module1: () => (/* binding */ module1),
/* harmony export */   module2: () => (/* binding */ module2)
/* harmony export */ });
const module1 = () => {
  console.log('module1')
}

const module2 = () => {
  console.log('module2')
}


/***/ })

/******/ 	});
/************************************************************************/
/******/ 	// The module cache
/******/ 	var __webpack_module_cache__ = {};
/******/
/******/ 	// The require function
/******/ 	function __webpack_require__(moduleId) {
/******/ 		// Check if module is in cache
/******/ 		var cachedModule = __webpack_module_cache__[moduleId];
/******/ 		if (cachedModule !== undefined) {
/******/ 			return cachedModule.exports;
/******/ 		}
/******/ 		// Create a new module (and put it into the cache)
/******/ 		var module = __webpack_module_cache__[moduleId] = {
/******/ 			// no module.id needed
/******/ 			// no module.loaded needed
/******/ 			exports: {}
/******/ 		};
/******/
/******/ 		// Execute the module function
/******/ 		__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/
/******/ 		// Return the exports of the module
/******/ 		return module.exports;
/******/ 	}
/******/
/************************************************************************/
/******/ 	/* webpack/runtime/define property getters */
/******/ 	(() => {
/******/ 		// define getter functions for harmony exports
/******/ 		__webpack_require__.d = (exports, definition) => {
/******/ 			for(var key in definition) {
/******/ 				if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ 					Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ 				}
/******/ 			}
/******/ 		};
/******/ 	})();
/******/
/******/ 	/* webpack/runtime/hasOwnProperty shorthand */
/******/ 	(() => {
/******/ 		__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ 	})();
/******/
/******/ 	/* webpack/runtime/make namespace object */
/******/ 	(() => {
/******/ 		// define __esModule on exports
/******/ 		__webpack_require__.r = (exports) => {
/******/ 			if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ 				Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ 			}
/******/ 			Object.defineProperty(exports, '__esModule', { value: true });
/******/ 		};
/******/ 	})();
/******/
/************************************************************************/
var __webpack_exports__ = {};
// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk.
(() => {
const modules = __webpack_require__(241)

const main = () => {
  modules.module1()
}

main()

})();

/******/ })()
;

コードを見ると、__webpack_require__で依存関係を解決していることが分かります。__webpack_require__はexportやimportの機能を提供し、動的にモジュールの解決をします。 これはランタイム時に解決されるため、minifyをする段階ではモジュールの関係性を把握することができません。そのため、ビルド時(minify)にモジュールの最適化ができず、結果としてコードが冗長になってしまいます。このようにCommonJSとES Modulesでは、モジュールの依存関係の解決の違いからバンドルファイルの結果が大きく異なります。CommonJSでも、Tree Shakingのプラグイン (opens new window)が用意されていますが、制約 (opens new window)などがあることから、ES Modulesの方が正確にTree Shakingができるでしょう。

サードパーティのNPMパッケージも含めて、アプリケーション全体でES Modulesを使うことでJavaScriptバンドルのサイズを削減することができます。

# JavaScriptを分割する

# Code Splitting

Code Splitting (opens new window)とは、バンドルファイルを細かいファイルに分割して必要なコードだけを抽出する技術です。Code Splittingの目的は、JavaScriptファイルを分割することで、ブラウザの処理スピードを上げることです。通常、バンドラーツールで生成されたJavaScriptファイルは肥大化する傾向にあります。JavaScriptファイルが肥大化すると、リソースのダウンロード時間が増加します。また、メインスレッドでの処理時間に負荷がかかるため、FIDINPTTIにも影響を与えます。CSRでのレンダリングの場合、FCPやLCPにも影響を与えるでしょう。

Webpackなどのバンドラーツールでは、Dynamic Import (opens new window)されたモジュールに対してCode Splittingすることができます。通常、次のようなファイルの場合、一つのバンドルファイルが生成されます。

import join from 'lodash-es/join'

const main = () => {
  console.log(
    join(['Hello', 'World'], ',')
  )
};

main();

しかし、Dynamic Importに書き換えることで、バンドルファイルを分割することができます。lodash-es/joinを次のようにimportに置き換えます。



 
 
 



const main = () => {
  console.log(
    import('lodash-es/join')
      .then((m) => m.default)
      .then((join) => join(['Hello', 'World'], ','))
  )
};

ビルドを実行すると、main.js252.main.jsが出力されたのが分かります。遅延実行したい処理をDynamic Importで読み込むことで、バンドルファイルを分割することができます。



 
 









$ webpack --config webpack.config.js

asset main.js 2.6 KiB [emitted] [minimized] (name: main)
asset 252.main.js 208 bytes [emitted] [minimized]

runtime modules 6.7 KiB 9 modules
cacheable modules 860 bytes
  ./src/index.js 169 bytes [built] [code generated]
    [no exports used]
  ./node_modules/lodash-es/join.js 691 bytes [built] [code generated]
webpack 5.88.0 compiled successfully in 178 ms
✨  Done in 0.84s.

Code Splittingの基本的な戦略は、必要なタイミングで必要なものだけを届けることです。不要なものを極力排除して、JavaScriptファイルの最適化を実現します。Code Splittingが使われるケースとしては、次のようなものが考えられます。

  • ページごと (Route)
  • 遅延読み込み (Lazy Loading)

# ページごと (Route)

通常のSPAの場合、一つのバンドルファイルに全てのルーティングで必要なコードが含まれています。例えば、Reactでは次のようにページごとにコンポーネントを指定することができます。

import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Home from './pages/Home'
import About from './pages/About'
import Contact from './pages/Contact'

const App = () => (
  <Router>
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/about" element={<About />} />
      <Route path="/contact" element={<Contact />} />
    </Routes>
  </Router>
);

この場合、全てのコードが一つのバンドルファイルへと生成されます。そのため、/ページにアクセスしてもAboutContactのコードが含まれることになります。必要のないコードがバンドルファイルに含まれることで、ファイルは肥大化し、ブラウザへの負荷が高まることになります。

Code Splittingを実行すれば、ページごとに必要なJavaScriptファイルを分割することができます。そのため、/ページでは、Homeコンポーネントだけを含めることができます。他のページのコードが含まれない分、JavaScriptの肥大化を防ぎ、効率的なリソース配信をすることができます。

React18では、React.lazy (opens new window)React.Suspense (opens new window)を使うことでCode Splittingを実装することができます。React.lazyは、コンポーネントを遅延読み込みするためのAPIです。次のようにimportをラップすることで、コンポーネントがレンダリングするタイミングで読み込みを実行することができます。内部ではDynamic Importを使用しているので、ビルド時に分割されて出力されます。

import { lazy } from 'react';

const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));

このReact.lazyでラップされたコンポーネントは非同期で読み込まれるため、React.Suspenseでフォールバックを設定する必要があります。次のようにReact.Suspenseで囲むことで、コンポーネントを読み込み中は<Loading />コンポーネントが表示され、読み込みが完了されるとコンポーネントが表示されます。

 


 

<Suspense fallback={<Loading />}>
  <h2>Preview</h2>
  <MarkdownPreview />
</Suspense>

このReact.lazyとReact.Suspenseをページごとに適用することで、コードを分割することができます。先ほどの例だと、次のように実装することができます。





 
 
 




 





 



import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

// React.lazyで遅延読み込み
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Contact = lazy(() => import('./pages/Contact'));

const App = () => (
  <Router>
    {/*React.Suspenseでフォールバックの設定*/}
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/contact" element={<Contact />} />
      </Routes>
    </Suspense>
  </Router>
);

Next.jsでは、デフォルトでページごとに分割する仕組み (opens new window)が提供されています。ビルドするとpages/以下に定義されたファイルごとにバンドルファイルが生成されます。また、viewport上にある<Link>コンポーネントから判断して、他のページのバンドルファイルをバックグラウンドで事前に取得することができます。(Prefetching (opens new window))そのため、ページ遷移で発生するリソース取得による遅延を最小限に抑えることができます。

Next.js Code Splitting

出典: What is Code Splitting? (opens new window)

# 遅延読み込み (Lazy Loading)

ページごとの分割と同様に、バンドルファイルから不要なコードを切り分けることで、初回のページロードを改善させることができます。ここでいう不要なコードとは、初回のローティングでは必要のない、ユーザーが操作をしたタイミングで実行されるコードを指します。例えば、クリックしたときに表示されるモーダル画面や、Date Picker、Emoji Pickerなどが当てはまるでしょう。

Reactの場合、遅延読み込みも同様にReact.lazyとReact.Suspenseで実装することができます。例えば、以下のようにボタンをクリックしたらEmoji Pickerが表示される例を見てみましょう。

import { useState } from "react";
import EmojiPicker from "./EmojiPicker";

export function App() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen((v) => !v)}>Emoji Picker</button>
      {isOpen && <EmojiPicker />}
    </div>
  );
}

このコンポーネントをWebpackでビルドすると、次のようなバンドルファイルが生成されます。<EmojiPicker />コンポーネントもメインのバンドルファイルに含まれるため、498KiBとサイズが大きくなっていることが分かります。

$ webpack --config webpack.config.js

asset main.bundle.js 498 KiB [emitted] [minimized] [big] (name: main) 1 related asset

<EmojiPicker />コンポーネントは、ユーザーがクリックしたタイミングで必要なものなので、必ずしも初回のページロードに必要とは限りません。初回のページロードのパフォーマンスを最適化させるためには、なるべくバンドルファイルを小さくし、ブラウザの処理コストを抑えることが有効です。今回のケースでは、<EmojiPicker />コンポーネントを遅延読み込みし、メインのバンドルファイルから切り離すことで、サイズを小さくすることができます。では、React.lazyとReact.Suspenseで遅延読み込みを実装してみましょう。

import { useState, Suspense, lazy} from 'react';

// React.lazyで読み込む
const EmojiPicker = lazy(() =>
  import(/*webpackChunkName: "emoji-picker"*/"./EmojiPicker")
);

export function App() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setIsOpen(v => !v)}>Emoji Picker</button>
      {isOpen && (
        // React.Suspenseでフォールバックを設定する
        <Suspense fallback={<p>Loading...</p>}>
          <EmojiPicker />
        </Suspense>
      )}
    </div>
  )
}

importはReact.lazyで読み込みます。React.lazyで読み込んだ<EmojiPicker />はReact.Suspenseでラップして、フォールバックを設定します。これにより、<EmojiPicker />をダウンロード中は、Loading...の文字が表示されるようになります。

このコンポーネントをWebpackでビルドすると、以下のようなファイルが生成されます。



 
 



webpack --config webpack.config.js

asset 921.bundle.js 494 KiB [compared for emit] [minimized] [big] (id hint: vendors)
asset emoji-picker.bundle.js 229 bytes [compared for emit] [minimized] (name: emoji-picker)

asset main.bundle.js 7.16 KiB [emitted] [minimized] (name: main) 1 related asset

メインのバンドルファイルとは別に、2つのバンドルファイルが生成されています。この二つが<EmojiPicker />に関わるバンドルファイルになります。メインのバンドルファイルから切り離されたため、main.bundle.js7.16 KiBに減少していることが分かります。

実際にページを開いてみると、初回のページアクセスではmain.bundle.jsだけがダウンロードされていることが分かります。

Code Splitting

では、Emoji Pickerを開いてみましょう。次の動画では、 ボタンをクリックしたタイミングで、先ほどビルドした<EmojiPicker />のリソースがダウンロードされていることが分かります。

このように、メインのバンドルファイルから切り離して遅延読み込みすることで、初回のページロードを改善することができます。バンドルファイルを小さくすることでブラウザーへの処理コストが抑えられるため、FCPやLCP、TTIなどの改善が期待されます。しかし、ボタンをクリックしたタイミングでリソースのダウンロードを実行すると、Emoji Pickerが表示されるまでタイムラグが発生してしまう可能性があります。スペックの高いPCなどでは気づきにくいですが、スペックの低い端末やネットワーク回線が悪い環境だとユーザー体験を損なう可能性があります。

以下は、低速の3G回線での動作になります。Emoji Pickerが表示されるまで数秒かかっていることが分かります。

クリックしたタイミングでダウンロードが開始されるため、ネットワーク回線が悪い環境では頻繁に発生してしまうケースでしょう。これでは、逆にユーザー体験が悪化してしまいます。 対策としては、リソースのダウンロードの遅延はPrefetch (opens new window)を使うことで回避することができます。Prefetchは、優先度は低いけどあとで使用するリソースに対して設定することができます。PreloadがAbove The Foldのコンテンツなどの優先的なリソースに使用される一方、Prefetchは、あとで使用される可能性のあるリソースをブラウザーがアイドル中にバックグラウンドで取得し、キャッシュすることができます。

<link rel="prefetch" as="script" href="https://example.com/lazy.js">

初回のページ表示が終了したあとにバックグラウンドでリソースの取得を行うので、ブラウザーへの負荷もかかりません。そのため、クリックしたときにダウンロードする場合と比べると、すでにキャッシュされているため大幅にダウンロード時間を削減することができます。

Webpack5では、Prefetchをしたいリソースに対してwebpackPrefetch: trueと書くことで自動的に<link rel="prefetch" />をHTMLに追加してくれます。


 


const EmojiPicker = lazy(() =>
  import(/*webpackPrefetch: true, webpackChunkName: "emoji-picker"*/ "./EmojiPicker")
);

これをビルドしてページを開くと、次のようにPrefetchが追加されていることが分かります。

Prefetch

この状態で、低速の3G回線で試してみましょう。Prefetchしない状態と比べると、キャッシュされているため高速に動いていることが分かります。

Prefetchは非常に有効な手段ですが、Preloadと同様、過剰に使うと逆にパフォーマンスが低下する恐れがあります。今回のケースのように、ボタンが押されたタイミングでリソースをダウンロードする場合は、最初からPrefetchするのではなく、viewport内にボタンが出現したらダウンロードを開始したり、ユーザーがボタンがあるコンテンツに近づいたときにバックグラウンドで取得するなどの対策をすると、より効率的なリソースの最適化ができるでしょう。