ファイルサイズを減らす

ページロードを早くすることにおいてファイルサイズを小さくすることは重要です。 特にJavaScriptのようなパースやコンパイルといった処理を必要するスクリプトのファイルサイズを減らすことは、 そのまま処理コストにも影響します。

次の記事でもあるように同じサイズのJavaScriptと画像は同じコストではありません。

しかしながら、ファイルサイズを小さくするにはアプリケーションによってさまざまなパターンが考えられます。 ここではいくつかのアプローチについて見ていきます。

Bundleを分析する

これは単純ですがWebpackなどでbundleしたJavaScriptファイルを分析するというアプローチです。 ここで見つける問題としては意図せずに入ってしまっているライブラリ、想像より大きなライブラリなど含まれていないかです。

特定のライブラリのサイズを見るにはpackage-sizebundlephobiaなどを使い、そのライブラリの依存を含めてサイズを見ます。 そのライブラリ自体のコード量ではなく、必ずbundleしてminifyしたgzipのサイズなどで比較します。 なぜなら、ライブラリ自体のサイズにはコメントなど圧縮すると大きくサイズが変わるものや、ライブラリのコードは少なくても依存してるライブラリのサイズが大きいという問題があるためです。

bundlephobia scan

bundlephobia.com/scanpacakge.jsonに書かれたライブラリのサイズを一覧

すでにアプリケーションのコードをWebpackでbundleしている場合はwebpack-bundle-analyzerを使うとサイズがわかりやすく可視化できます。

webpack-bundle-analyzer.png

webpack-bundle-analyzerでモジュールのサイズを可視化した例

webpack-bundle-analyzerなどは、環境変数などで普段のビルド + bundle-analyzerの結果を出力できるようにwebpackの設定ファイルを作成しておくとよいです。

例) BUILD_STATS=1 webpack のように環境変数でwebpack-bundle-analyzerを有効化する

const path = require('path');
const webpack = require('webpack');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const BUILD_STATS = !!process.env.BUILD_STATS;
module.exports = {
    // ...省略...
    plugins: [].concat(
      BUILD_STATS
       ? [
        new BundleAnalyzerPlugin({
          analyzerMode: 'static',
          reportFilename: path.join(__dirname, './build/stats/app.html'),
          defaultSizes: 'gzip',
          openAnalyzer: false,
          generateStatsFile: true,
          statsFilename: path.join(__dirname, './build/stats/app.json'),
          statsOptions: null,
          logLevel: 'info'
        })
      ]
      : []
      )
    )
    .concat([
      new webpack.ContextReplacementPlugin(/moment[\\/]locale$/, /^\.\/(ja)$/),
      new LodashModuleReplacementPlugin({ shorthands: true })
    ])

}

webpack-bundle-analyzerを使った分析とファイルサイズの削減については、次の記事を参照するとよいでしょう。 コードをminifyするといった基本的なことからmomentのようなよくある大きなライブラリの扱い方に、より高度なファイルサイズの削減方法などについて書かれています。

より小さなライブラリに変更してサイズをへらすことも重要ですが、まずは使ってないライブラリや不用意に入ってしまっているものを取り除くことから始めるのがよいです。

例) よくある問題

アプリケーションのサイズ(特にJavaScript)はライブラリによって大きく増減します。 そのため、突発的なファイルサイズの増加を防ぐためにもbundlesizeのようなファイルサイズチェックをPRごとに行ったり、size-pluginで現在のファイルサイズをビルド時に可視化するといった工夫も必要になります。

ファイルサイズの変化をどのように検知するかはいろいろなトレードオフがあるので、チームにあったものを選択するのがいいと思います。

たとえば、あるチームではPRごとにwebpack-bundle-analyzerの結果をグラフとして見られるようにして、ファイルサイズの変化を見ていました。

stats-graph

もちろん合成モニタリングサービスを使った計測も併用します。 しかし、外部からの監視は実際にデプロイするまでわからないため、このようなPRやコミットなど複数のレイヤーでチェックを併用します。 併用して自動チェックできるレイヤーを増やすことで問題に早く気づくことができるようになります。

初期表示に必要ないものを遅延ロードする

もっと単純で効果があるアプローチは、必要がないものは読み込まないことです。 注意しないと初期表示に不要なJavaScriptやCSSを含んでしまう場面も多いと思います。

これは特にサードパーティスクリプトの読み込みなどが該当しやすいです。 JavaScriptならasync属性を付けて遅延ロードさせ、loadCSSなどを使って遅延ロードさせるという手法が利用できます。

- <script src="https://example.com/script.js" />,
+ <script src="https://example.com/script.js" async defer />,

特定のコンポーネントに紐付いて必要となるスクリプトなども、そのコンポーネントが表示されるまで読み込む必要がないはずです。 これは、広告のスクリプトやSNSボタンなど特定のコンポーネントに紐付くものなどが該当します。

これらのコンポーネントに紐づくスクリプトなどは、動的にロードしてそれが読み終わったタイミングでコンポーネントを更新するといった作りにすることで遅延ロードできます。

次のコードは、動的にスクリプトをロードするUtilと、スクリプトをロードするReactコンポーネントの例です。

ScriptLoaderUtil.ts:

/**
 * <script>タグでjsファイルを動的にロードするUtil
 */
export class ScriptLoaderUtil {
  /**
   * すでに読み込みを開始したURLを保持しておくマップオブジェクト
   */
  private static srcMap: { [src: string]: Promise<void> } = {};

  /**
   * デフォルトのタイムアウト(ミリ秒)
   */
  public static DEFAULT_TIMEOUT = 5 * 1000;

  /**
   * scriptタグを利用して外部ソースの読み込みを行う
   * @param src 読み込むjsのURL
   * @param timeout タイムアウト(ミリ秒)
   */
  public static load({ src, timeout }: { src: string; timeout?: number }): Promise<void> {
    return new Promise((resolve, reject) => {
      // すでに読み込みを開始している場合は2重に読み込まない
      if (this.srcMap[src]) {
        return this.srcMap[src].then(resolve);
      }
      // 新規 src の場合は script タグを利用して読み込みを開始する
      this.srcMap[src] = new Promise((resolve, reject) => {
        let isHandled = false;
        const script = document.createElement('script');
        script.src = src;
        script.type = 'text/javascript';
        script.charset = 'utf-8';
        script.async = true;
        /* tslint:disable */
        const onLoad = () => {
          isHandled = true;
          resolve();
          script.removeEventListener('load', onLoad);
          script.removeEventListener('error', onError);
        };
        const onError = () => {
          isHandled = true;
          reject(new URIError(`The script(${src}) is not accessible.`));
          script.removeEventListener('load', onLoad);
          script.removeEventListener('error', onError);
          delete this.srcMap[src];
        };
        /* tslint:enable */
        script.addEventListener('load', onLoad);
        script.addEventListener('error', onError);
        document.body.appendChild(script);
        // タイムアウト処理
        setTimeout(() => {
          if (isHandled) {
            return;
          }
          script.removeEventListener('load', onLoad);
          script.removeEventListener('error', onError);
          delete this.srcMap[src];
          reject(new Error(`The script(${src}) load is timeout`));
        }, timeout || this.DEFAULT_TIMEOUT);
      });
      return this.srcMap[src].then(resolve, reject);
    });
  }
}

ScriptLoader.tsx:

import * as React from 'react';
import classNames from 'classnames';
import { ScriptLoaderUtil } from './ScriptLoaderUtil';

export type ScriptLoaderProps = {
  src: string;
  className?: string;
  onLoad?: () => void;
  onError?: (error: Error) => void;
};

/**
 * <script> で JS ファイルを読み込むコンポーネント
 * <ScriptLoader src="https://example.com/script.js" onLoad={() => {}} />
 */
export default class ScriptLoader extends React.PureComponent<ScriptLoaderProps> {
  public componentDidMount() {
    ScriptLoaderUtil.load({
      src: this.props.src
    })
      .then(() => {
        this.props.onLoad && this.props.onLoad();
      })
      .catch(error => {
        if (this.props.onError) {
          this.props.onError(error);
        } else {
          console.error(error);
        }
      });
  }

  public render() {
    return <div className={classNames('ScriptLoader', this.props.className)} />;
  }
}

分離と自動チェック

webpackなどではCode Splittingで初期表示に必要ないものをbundleから別のchunkに分割できます。

これによって、初期表示に必要なbundleのファイルサイズを削減できます。

どのように分割するかはアプリケーションに依存しますが、もっとよくあるケースはルーティングによってページ(URL)として別れているコンポーネントを分割することです。 また、同じページ内でも一般ユーザーと管理ユーザーで表示されるものが異なるということがあります。 管理ユーザーでは"一般ユーザーの表示" + "管理ツール"といった構造になっていることも多いです。

このような場合にメインのbundleに一般ユーザーに不要な"管理ツール"のコンポーネントを含めるのは適切ではありません。 そのため、"管理ツール"だけをchunkとして分けたり、Dynamic Importsで動的にロードするといったことができます。

具体的な例として、ニコニコ生放送では視聴者がみる視聴ページと配信者が見る配信ページはほとんど同じJavaScriptで動作しています。 しかし、配信者が見るページには、視聴者見るプレイヤーなどに加えて配信者向けのツール(コンポーネント)が追加されています。

左は視聴ページ、右は配信ページ

左は視聴者が見るページ、右は配信者が見るページ

この"配信者向けのツール"は視聴者に不要であるためメインのbundleから外す必要ことでファイルサイズが削減できます。

実際にこの"配信者向けのツール"を、メインのbundleから配信者向けのツールだけのchunk(ファイル)へと分離しました。 これによってメインのbundleが"配信者向けのツール"の分である30kb(gzip)程度削減できました。

"配信者向けのツール"を分離するPR

この変更では、次のようにイメージで利用できるように分離されました。

<script src="main.bundle.js">
if (配信者ページなら) { 
    <script src="配信者向けツールのbundle.js">
}

bundleの再結合の防止

Bundleを分離することはうまくできても、その状態を維持する必要があります。 たとえば、先ほどの"配信者向けのツール"の分離では、ディレクトリも次のように分離していました。 そしてそれぞれに対応するbundleとchunkを出力するようになっています。

src/
├── broadcaster-tool(配信者向けのツールのコード) ---> broadcaster-tool.jsとして出力
└── view(視聴者向けのコード)  ---> pc-watch.jsとして出力

これがうまく分離されるにはview(視聴者向けのコード)broadcaster-tool(配信者向けのツールのコード) の間に依存関係を作らない必要があります。 なぜなら、viewbroadcaster-toolに依存関係があるとwebpackなどはchunkとして分離できなくなるためです。 たとえば、view(視聴者向けのコード)からbroadcaster-tool(配信者向けのツールのコード) に依存があるpc-watch.jsbroadcaster-tool.jsの内容が含まれてしまいます。

これは、JetBrains系のIDEやVSCodeなどJavaScriptのモジュールへのパス補完が簡単できるといつの間にか起きてしまうことがあります。 コンポーネント名を入力すると、自動的にそのコンポーネントをimportしてしまって想定外の依存関係が発生すると言ったことがおきます。

このような意図しない方向の依存を防止するにはdependency-cruiserのようなチェックツールを使うのが簡単です。dependency-cruiserはルールを書いて、モジュールの依存関係のチェックを行えます。

具体的には、次のようなルールを書くとsrc/broadcaster-tool -> src/view への参照を自動的にチェックして、意図しない参照を防止しています。

{
    "forbidden": [
        {
            "name": "broadcaster-tool-does-not-import-view-index",
            "comment": "broadcaster-toolはview/index.tsを参照するとcode splitできなくなる。分割したbroadcaster-toolにview/index.tsから参照するすべてのファイルが含まれてしまうのを避けるため、直接view以下のファイルを参照してください",
            "severity": "error",
            "from": {
                "path": "^src/broadcaster-tool"
            },
            "to": {
                "path": "^src/view"
            }
        }
    ],
    "options": {
        "doNotFollow": "node_modules"
    }
}

類似サービスと比較する

ファイルサイズは大きくても動かなくなるわけではないのため、どこに原因があるかがわかりにくいという問題があります。 そのときの判断材料として、類似するサービスと比較して見る方法があります。 (SpeedCurveなどではCompetitorのURLと一緒に計測して、結果を比較といった方法もできます。)

たとえば、ニコニコ生放送ならばニコニコ動画、Youtube、FRESHLIVEなど類似する機能をもつサービスはいろいろあります。 それらのサイトと比較のファイルサイズを比較して、問題を見つけるというのも1つの手段です。

JavaScriptのサイズはもつ機能や作りによって大きな差はでます。 一方、CSSは画面を構成する要素に依存しまた想定する画面サイズにはそこまで差はでないため、大きな差が出にくいとも考えられます。

ニコニコ生放送の視聴ページのCSSを他のサイト比較してみると、なぜか3倍程度大きいという問題ことがわかりました。 CSSが大きくなっている原因を次のツールなどで調べてみると、いくつかの問題があるということがわかりました。

具体的に見つかった問題は次のとおりです。

  • Base64 Lengthがでかい(Base 64のサイズが大きい)
    • 前者はCSSに画像やfontがBase64でそのまま埋め込まれていた
  • Empty Rulesの数が多い(空のセレクタにコメントだけが残っている問題があった)

これらの問題に対してそれぞれ次のような解決方法を取りました。

  • 画像やfontなどのリソースをBase 64ではなくURLとして指定するようにした
    • fontは実際に使用されるまでダウンロードされなくなった
  • cssnanoのdiscardCommentsルールを適用してコメントを消して、minifyで空セレクタがきえるようにした

CSSはSassやPostCSSなどのさまざまなツールを経由して出力されることが多いため、不用意にサイズが増えてしまっていることがあります。 そのため、Performance budgetsを設定するなど不自然な増え方をした場合に気づく仕組みが必要になるでしょう。

results matching ""

    No results matching ""