【UX向上委員会】コード分割で初回ロードをはやくする

2021年6月29日

大きなプロジェクトになるほどバンドラーが生成するメインファイルが大きくなって初回ロードが遅くなってgoogle speed insightの点数が悪いことはないでしょうか?

そんな時はバンドラーでコード分割をしてファーストビューに必要なファイルだけを取得するようにしましょう。

バンドラーとは

モジュールによって分割されたファイルを一つにまとめることができます。GulpやWebpackがそれにあたります。

利用するメリット主には

  • HTTP/1.1では一度に処理できるリクエストの数に限界があるため、リクエストをまとめて回数を減らすことでパフォーマンス向上。
  • ブラウザーはモジュールを使う際にrequireやimportを行う必要がない(ブラウザによってはESmoduleに対応していないため、webpackのようなmoduleバンドラーを使用する必要がある。)

ファイルのバンドル以外にもファイルの圧縮やCSSにブラウザことのprefixを付与したりできます。

この記事では主にwebpackについて解説します。

Webpackとは

用途としてはバンドラーとして複数のファイルを一つにまとめることですが、コンパイルや画像の圧縮などできる事が様々。似たようなものにGulpがあります。

肥大化するバンドルファイル

通常、ReactなどでつくられたSPAをwebpackでバンドルすると全てのファイルが一つにまとめられ、ユーザは初回ロード時に全てのファイルをダウンロードすることになります。

しかし、アプリケーションが肥大化するにつれてバンドルファイルが大きくなり初回ロード時間が長くなってしまいます。

グーグルによると、ファーストビューがレンダリングされるまでに3秒以上かかると53%が閲覧を止めて離脱してしまうらしい。

なぜコード分割

初回ロードのデータを削減することで、ロード時間を短縮できUXを向上させる事ができます。

例えば、SPAでユーザーがログインページに訪れたとします。

そうするとmain.jsがロードされます。しかし、main.jsにはloginページの他にaboutページやcontactページのファイルデータが含まれます。これらのページはユーザーが訪れない可能性があります。

main.jsが100KBあるとして、loginページは2KBだとすると、残り98KBはユーザーが今必要ないデータになります。

そうすると、main.jsを”初回ロード時に必要なファイル”と”ユーザーの要求に応じたファイル”に分けたほうがUXを損なわずにファイルをロードする事ができます。

ここからはコード分割を可能にする機能を紹介します。

まず準備から。

準備

webpackの出力ファイルサイズを視覚化してくれるパッケージをインストール

$ npm install --save-dev webpack-bundle-analyzer

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin module.exports = { ...,// plugins: [ new BundleAnalyzerPlugin() ], ..., }

ファイル名をパスやモジュール名で出力

デフォルトだと分割したファイルは[数字].jsで出力されます。

なんのファイルかわかり辛いので、ファイル名をモジュールの名前やパスで出力してくれるようにします。

module.exports = { ...,//  optimization: { chunkIds: 'named' }, ..., }

Dynamic import

動的にファイルを読み込む事ができます。

次の例をみてみましょう。

// import { showTheTime } from "./showTheTime"; const button = document.createElement("button") button.textContent = 'click'; button.addEventListener("click", async () => { const { showTheTime } = await import("./showTheTime"); showTheTime() }) document.body.appendChild(button);

ボタンをクリックするとshowTheTimeというファイルがimportされて時間が表示されるという関数があります(今回はファイルサイズの違いが分かり易いようにshowTheTimeはmoment.jsを使用しています)。

dynamic-importした時はクリック後ファイルがダウンロードされる

ボタンをクリックするとvandors-node_modules_mo...src_showTheTime_js-node...というファイルが取得されたのがわかります。

これはバンドル時dynamic import構文に出くわすとファイルを分割し、「クリックしたら分割したファイルを使用する」というロジックをwebpackが自動で生成しているからです。

通常のimportだとファイルはmain.jsに含まれているので、クリックしてもデータが取得されることはないです。

//通常のimportの場合 main.js ----- Stat size:689.22KB Parsed size:296.79KB Gzipped size: 74.46KB //dynamic importの場合 main.js ----- Stat size:25.78KB Parsed size:10.35KB Gzipped size: 4.03KB

moment.jsという重めのファイルを使用しているのでmain.jsを70.43KB(Gzipped size)減らす事ができました。

Prefetch(webpack v4.6以降)

Prefetchフラグを使うとmain.jsの取得が終わり次第、分割したコードの取得を始めます。

将来のナビゲーションにおそらく必要となるファイルを取得する際に使用されます。

方法は簡単で、importの引数にコメントアウトした webpackPrefetch: trueをパスの前に入れるだけです。

// import { showTheTime } from "./showTheTime"; const button = document.createElement("button") button.textContent = 'click'; button.addEventListener("click", async () => { const { showTheTime } = await import(/* webpackPrefetch: true */ "./showTheTime"); showTheTime() }) document.body.appendChild(button);

prefetchフラグがたてられたファイルは、headタグへリンクとして<link ref="prefetch">のように挿入されます。

prefetchパラメータを使うとヘッドにprefetchするリンクタグが挿入されている headタグにlinkタグが新たに挿入されている

これらの rel=prefetchが付いたlinkタグはブラウザがアイドリング状態の時にキャッシュに保存されます。

prefetchされたデータがprefetch cacheから使われている prefetch cacheから必要なファイルが取得されている

ボタンをクリックするとvandors-node_modules_mo...src_showTheTime_js-node...がキャッシュから取得されているのがわかります。

consoleのDisabed cacheのチェックを外さないとcacheに保存されません!当たり前ですけど1日つまずきました...

React.lazy & React.Suspense

どちらもバージョン16.6からの新機能です。

React.lazyはReactコンポーネントをdynamic importすることができます。

React.suspenseは全ての必要なモジュールがフェッチできるまでレンダーを一時停止します。さらにフェッチしている間にfallbackUIを表示する事ができます

この二つはしばしば一緒に使われます。

次の例をみてみましょう。

これはボタンをクリックすると日付が表示されるアプリです。

📦suspense-and-lazyload ┣ 📂node_modules ┣ 📂src ┃ ┣ 📜App.jsx ┃ ┣ 📜Now.jsx ┃ ┗ 📜index.js ┣ 📜.babelrc ┣ 📜.gitignore ┣ 📜package-lock.json ┣ 📜package.json ┗ 📜webpack.config.js
import React, { lazy, Suspense, useState } from 'react'; const Now = lazy(() => import('./Now')); const App = () => { const [open, setOpen] = useState(false); const handleClick = () => { setOpen(!open); }; return ( <Suspense fallback={<div>Loading...</div>}> <button onClick={handleClick}>Click</button> {open && <Now />} </Suspense> ); }; export default App;

<Now/>がdynamic importされていて、<Suspense/>コンポーネントによってラップされています。

fallbackパラメータに渡された<div>Loading...</div>がコンポーネントのローディング中に表示されます。

ではbuildして生成されたファイルをみてみましょう。

📦suspense-and-lazyload-master ┣ 📂node_modules ┣ 📂dist ┃ ┣ 📜index.html ┃ ┣ 📜main.js ┃ ┣ 📜src_Now_jsx-node_modules_moment_locale_... ┃ ┗ 📜vendors-node_modules_moment_locale_af_js-... ┣ 📂src ┃ ┣ 📜App.jsx ┃ ┣ 📜Now.jsx ┃ ┗ 📜index.js ┣ 📜.babelrc ┣ 📜.gitignore ┣ 📜package-lock.json ┣ 📜package.json ┗ 📜webpack.config.js

dist以下に src_Now_jsx-node_modules_moment_locale_...vendors-node_modules_moment_locale_af_js-...が生成され、コードが分割されているのが確認できます。

サイズを比較してみます。

//通常のimportの場合 main.js ----- Stat size:1.73MB Parsed size:1.86MB Gzipped size:369.49KB //reac.lazyの場合 main.js ----- Stat size:1.07MB Parsed size:1.15MB Gzipped size:269.39KB

100.1KB削減する事ができました。

ちなみに、これらの機能はサーバーサイドレンダリングで使う事ができません。

今後のアップデートで実装される可能はありますが、サーバーサイドで使いたい場合は、 loadable-componentsを使うようにしましょう。

ルートによるコード分割

react-routerとreact.suspneseとreact.lazyを使うことで、ルートレベルでコードを分割することができます。

次の例をみてみましょう

import React, { lazy, Suspense } from 'react'; import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; const About = lazy(() => import('./About')); const Home = lazy(() => import('./Home')); const App = () => { return ( <Router> <Suspense fallback={<p>Loading...</p>}> <Switch> <Route exact path="/" component={Home} /> <Route path="/about" component={About} /> </Switch> </Suspense> </Router> ); }; export default App;

HomeコンポーネントとAboutコンポーネントがdynamic importされていて、”/”と”/about”ルートが<Suspense/>コンポーネントによってラップされています。

二つのコンポーネントがローディングされる間、fallbackに渡した<p>Loading...</p>が表示されます。

ルートごとにファイルが生成されているか確認してみましょう。

📦suspense-and-lazyload-with-react-router ┣ 📂node_modules ┣ 📂dist ┃ ┣ 📜index.html ┃ ┣ 📜main.js ┃ ┣ 📜src_About_jsx.js ┃ ┗ 📜src_Home_jsx.js ┣ 📂src ┃ ┣ 📜About.jsx ┃ ┣ 📜App.jsx ┃ ┣ 📜Home.jsx ┃ ┗ 📜index.js ┣ 📜.babelrc ┣ 📜.gitignore ┣ 📜package-lock.json ┣ 📜package.json ┗ 📜webpack.config.js

dist以下にコンポーネントの相対パスの名前が入ったファイルが生成されているのが確認できました。

ルートによってファイルが取得されているかみてみましょう。

ページ遷移時にそのページに必要なファイルをフェッチする 初回ロード時に、src_Home_jsx.jsが取得されているのが確認できます。これはHomeルートが”/”であるためです。

Aboutページへのリンクをクリックすると、src_About_jsx.jsが正常に取得されています。

まとめ

Googleはページの読み込み速度をランキング要素にしているみたいなので、SEO的にもどんどん分割しようと思いました。

終わり。

リソース