JavaScript
webpack

静的サイトをとにかく高速化する話

目的

CSSフレームワークを使用した静的サイトの読み込みを高速化してみました。
この記事はその際に効果のあった手法を共有するためのものです。

重量級のCSSフレームワークとjavascriptライブラリの恩恵を受けつつ、いかに読み込み速度を犠牲にしないかという点に重点を置いて高速化を行いました。

軽量なCSSフレームワークやライブラリを選択すると、効果は薄くなります。また、動的サイトでは適用できない / しにくい手法も含まれますが、皆様の環境で適用可能なものをつまみ食いしてもらえたらと思います。

前提とする環境

サイトは以下の環境でバンドルを行います。

  • macOS 10.13.3
  • node.js 8.11.3
  • webpack 4.12.2
  • gulp 3.9.1

使用するCSSフレームワーク、jsライブラリとWebフォント

  • Bootstrap 4.1.2
  • Font Awesome 5.1.1
  • ( jquery 3.3.1 )

Bootstrapはjqueryに依存するためセットになります。
node.jsやwebpack自体の解説はこの記事では取り扱いません。

結果だけ先に紹介

ファイル容量(KB) 結合のみ minify + デッドコード削除 + gzip
javascript 1257.8 240.0 75.7
css 173.2 29.1 7.1

自分のポートフォリオサイトをサンプルに、どのくらいの容量削減ができるのかを確認してみました。

jsおよびCSSは、サイトの表示に必要な要素を1ファイルにバンドルした状態です。
画像ファイルはjpegの圧縮率などによって最終的なサイズが大幅に変化するので、jsとCSSのサイズ変化のみを取り上げました。

Bootstrap + Font Awesome のような重量級フレームワークを使用しても、十分に実用的な容量まで削減できました。これならスマホ+3G回線での表示も心配ありません。

手法

適用しやすさを順に手法を並べると、以下のようになります。

  • 遅延する
  • 圧縮する
  • キャッシュする
  • まとめて削る

遅延する

サイト上にあるほとんどのリソースは、実際には後から読み込んでも問題なく動作します。
まず最小限の構成でサイトを表示させ、重いファイルは後から読み込みます。

javascriptの遅延読み込み

html上のscriptタグは、async / defer を指定することによって遅延読み込みができます。
この記事にasync / defer の詳細な解説があります。
scriptタグに async / deferを付けた場合のタイミング

javascriptの遅延読み込みで問題が発生するのは、以下の二点の場合です。

  • jQuery + pluginのような、スクリプトの読み込み順が指定されている場合
  • DOMの構築や挿入をjavascriptが行う場合

deferではjsファイルの読み込みは非同期で行われますが、実行順はscriptタグの記述順になります。読み込み順が問題になる場合はdeferを活用しましょう。

CSSの遅延読み込み

CSSの遅延読み込みはインラインCSSと併用して初めて効果があります。インラインCSSを追加せずにCSSの遅延読み込みを行うと、レイアウトが盛大に崩れてユーザー体験が損なわれます。インラインCSSの生成は「まとめて削る」の「レンダリングブロックCSSをインラインに変換、遅延読み込みを行う」で合わせて解説します。

画像の遅延読み込み

画像の遅延読み込みは、レイアウトが破綻しない範囲で積極的に適用するべきです。
現状では、画像の遅延読み込みにはlazysizesを利用しましょう。

  • responsive image対応
  • 他のjsライブラリへの依存がなく、単独で動作する
  • 軽量(minify後6.5KB)
  • html側の記述だけで設定が完了し、jsファイルを読み込むだけで動作する

と非常に優秀なlazyloaderです。

圧縮する

リソースは圧縮すればするほど、回線とクライアント側のメモリ、CPU資源を節約できます。

gzip圧縮

gzip圧縮はテキストベースのファイルの圧縮に有効です。できる限り利用しましょう。

.htaccessが書き換え可能な環境の場合

こちらの記事に詳細な解説があるのでご紹介します。
.htaccess の書き方(スピードアップ編)

Amazon S3でホスティングを行う場合

gulp-awspublishなどのgulpプラグインでS3にデプロイする場合、awspublish.gzip関数でアップロードするファイルを事前にgzip済みにできます。

javascriptの圧縮

webpack4では、新たに実行時にmodeが指定できるようになりました。
公式ドキュメント
modeを未指定のまま実行すると、警告が表示された上でデフォルト値のproductionモードとして実行されます。

webpack4のproductionモードでは、デフォルトでoptimization.minimizeオプションがtrueになり、UglifyjsWebpackPluginによるjavascriptの圧縮が行われます。
公式ドキュメント
mode指定をしていれば、webpack.config.jsには特に何も記述しなくても圧縮が行われます。
標準の設定で、バンドルするjsファイルのライセンスコメントは保持されたまま出力されます。

以下のようにminimizeオプションを指定していると、圧縮の有無がmode指定にかかわらず固定されてしまいます。プロジェクト上どうしても必要だという場合以外は指定をしない方が良いです。

webpack.config.js
module.exports = {
  //...
  optimization: {
    minimize: false // or true
  }
};

CSSの圧縮

現在では、ほとんどのCSSプリプロセッサに組み込み、もしくはプラグイン形式で圧縮機能が搭載されています。
この記事ではBootstrap 4を対象に扱っているので、Bootstrapで使用されているSCSSを例にします。

package.json
   "scripts": {
      "sass:release": "node-sass --output-style compressed"

node-sassでは--output-styleオプションで出力形式を指定できます。compressedを指定すれば圧縮された状態でcssファイルが生成されます。

postcssを使用している場合は、cssnanoの使用をお勧めします。
cssnanoはデフォルト設定では、ベンダープレフィックスを削除します。
公式ドキュメント

設定を変更することで、autoprefixerと同じようにベンダープレフィックスを追加することもできます。

postcss.config.js
    plugins: [
        require("cssnano")({
            autoprefixer: { add: true }
        })
    ]

画像の圧縮

画像の圧縮は効果が大きい反面、処理時間も大きくなります。どのようにワークフローに組み込むかは難しい問題になります。

1. gulp-newer + gulp-imagemin

gulp-newerはファイルの更新時間を比較するgulpプラグインです。画像圧縮処理の時間を短縮するのに有効です。
このプラグインとgulp-imageminを組み合わせて利用します。

gulp-newerを利用する際、生成された画像が削除されてしまうとその恩恵を受けられなくなります。
gulpで出力先フォルダを都度削除している場合は、

  • develop中のタスクでは画像フォルダを削除の例外に指定する
  • production用のタスクでは画像フォルダを削除し、最新の状態にする

ことをお勧めします。

2. imagemin + node.jsスクリプト

新規のプロジェクトにわざわざgulpを導入するのはためらわれる、という場合はnode.jsのスクリプトでgulp-newerの代用をすることができます。以下、画像の更新時間の新旧比較を行うスクリプトを抜粋して掲載します。

imageOptim.js
const fs = require("fs");
const path = require("path");
const glob = require("glob");

const srcDir = `${process.cwd()}/src/img`;

/**
 * 対象ファイルリストを取得する
 * @param extentions
 * @returns {*}
 */
const getImageList = extentions => {
    const pattern = `**/*.${extentions}`;
    const filesMatched = glob.sync(pattern, {
        cwd: srcDir,
    });
    return filesMatched;
};

/**
 * ファイルの更新が必要か否かを判定する
 * @param filePath
 * @param srcDir
 * @param targetDir
 * @returns {boolean}
 */
const isNeedsUpdate = (filePath, srcDir, targetDir) => {
    const targetStats = getStats(filePath, targetDir);
    if (!targetStats) {
        return true;
    }

    const srcStats = getStats(filePath, srcDir);
    if (srcStats.mtime > targetStats.mtime) {
        return true;
    }
    return false;
};

/**
 * fileの情報を取得する。fileが存在しない場合はnullを返す。
 * @param filePath:string
 * @param targetDir:string
 * @returns {*}
 */
const getStats = (filePath, targetDir) => {
    try {
        const stats = fs.statSync(targetDir + "/" + filePath);
        return stats;
    } catch (err) {
        return null;
    }
};

/**
 * 更新対象ファイルのリストを取得する
 * @param targetDir 比較、保存対象ディレクトリ
 * @param imgExtension 対象ファイル拡張子 形式はglob
 * @returns {path: Array, fileName: Array} 更新対象ファイルリスト pathはフルパス、filenameはsrcディレクトリからの相対パス
 */
const getUpdateFileList = (targetDir, imgExtension) => {
    const imageList = getImageList(imgExtension);
    const list = {
        path: [],
        fileName: [],
    };

    for (let file of imageList) {
        const update = isNeedsUpdate(file, srcDir, targetDir);

        if (!update) {
            continue;
        }

        list.path.push(srcDir + "/" + file);
        list.fileName.push(file);
    }
    return list;
};

長いコードですが要点は

    const stats = fs.statSync(filePath);

でファイル情報が取得できることと

    if (srcStats.mtime > targetStats.mtime) 

stats.mtimeプロパティで更新時刻が比較できることの二点です。

この処理で画像の更新時間の比較ができますので、出力先ディレクトリに画像がないか古い場合はimageminで処理をした画像を上書きします。

キャッシュする

キャッシュがヒットすればするほど転送容量は減少します。
しかし、キャッシュの有効期間を伸ばすとサイトの更新が反映されないという問題が発生します。

gulp-rev

gulp-rev
gulp-rev-rewrite

キャッシュの問題はファイル名にハッシュを追加することで解決できます。
gulp-revがハッシュの追加、gulp-rev-rewriteがハッシュが追加されたファイルを参照している箇所の書き換えを行います。

まとめて削る

HTTP/2が普及が進み、以前よりもリクエスト数の増加によるパフォーマンス低下は起きにくくなりました。
リクエスト数がボトルネックになりにくくなった結果、CSSやjavascriptファイルの容量自体が読み込みに与える影響が大きくなってきています。
webpackなどのモジュールバンドラーやpurgecssなどの静的解析パッケージは、ファイル容量の削減に利用できます。

  • 依存関係の解消
  • 静的解析
  • デッドコードの削除

重複したコード、使用しないコードを削除することでサイト全体の転送量を縮小できます。

javascriptをTree Shakingで削る

webpack+babelの環境では、設定によってTree Shaking機能が働きデッドコードが除去されます。

この設定例は、以下の環境を前提としています。

  • webpack 4.12.2
  • babel-core 6.26.3
  • babel-loader 7.1.5
  • babel-preset-env 1.7.0

Tree Shakingを有効にするためには、以下の条件が揃っている必要があります。

  • --mode productionでバンドルをする
  • ES Modulesとしてimportする(requireはダメ)
  • babel-preset-envを使用する場合は{'modules': false}のオプションを設定する

より詳しい内容を解説されている記事があるのでご紹介します。
webpackのTree Shakingを理解して不要なコードがバンドルされるのを防ぐ

javascriptのライブラリを使用するなら、ES Modulesに対応したものを選ぶ

定番として使用されているjavascriptのライブラリの中には、ES Modulesに未対応のものもあります。そうしたライブラリはTree Shakingが有効になりません。ライブラリの選択の時点で、ES Modulesに対応しているか否かを確認しましょう。

FontAwesome5を使用する分だけバンドルする

Font Awesome 5ではAPIの提供が行われており、使用するフォントのみをjsファイル内にバンドルすることができます。
使用しないフォントはバンドルされないため、大幅な軽量化になります。
Font Awesome API

import { library, dom } from "@fortawesome/fontawesome-svg-core";
import {
    faArrowAltCircleDown,
    faDraftingCompass,
    faCode,
    faChartLine,
} from "@fortawesome/free-solid-svg-icons";

library.add(faArrowAltCircleDown, faDraftingCompass, faCode, faChartLine);
dom.i2svg();

@fortawesome/fontawesome-svg-coreが関数を、@fortawesome/*-svg-iconsがフォントのsvgデータを含んでいます。
使用する分だけimportを行い、ライブラリへaddします。
dom.i2svg()関数がhtmlの

<i class="fas fa-chart-line">

のような記述を探し、class指定をインラインsvgに変換します。

Font Awesomeの管理工数の増加と、動的サイトへの対応

Font Awesome APIによるバンドルは、javascriptによるimport管理が必要になります。
いままではhtmlのクラス指定だけで完結していたので、Font Awesomeの管理工数が増えますのでご注意ください。
(個人的にはファイル容量の削減は工数増加に見合うだけの恩恵があると思います。)

レンダリングブロックCSSをインラインに変換、遅延読み込みを行う

CSSはレンダリングブロックリソースです。CSSの読み込みが終わるまで画面の描画は行われません。
最初に画面に収まる要素(ファーストビュー)に関係するCSSのみを抽出し、htmlに直接挿入してしまうことでこの遅延が回避できます。

CSSのインライン変換と遅延読み込みにはCriticalを使用します。
Critical
各種タスクランナーやモジュールバンドラー用のプラグインがありますので、ワークフローにあったものを導入してください。

インラインCSSによるhtmlの肥大化

インラインCSSは読み込みの体感速度を向上させますが、htmlの容量を肥大化させます。
どのように適用するかはサイトごとに検討が必要です。
同一の外部CSSファイルを複数回読み込む場合はキャッシュが効きますので、インラインCSSの効果は薄くなり、html肥大化のデメリットが大きくなります。
流入が多いページにのみインラインCSSを適用し、そのほかのページには適用しないという方法が考えられます。

未使用のCSSを削る

CSSフレームワークはあらゆる状況に対応できるCSSクラスを用意してくれますが、その全てを利用することはまずありません。
そのため、大部分のクラスが使用されないままになります。

Purgecssは各種ファイルの静的解析を行い、未使用のCSSを削除します。
node.jsのスクリプトの場合、このような形で実行することで削除済みのCSSを出力します。

purgecss.js
"use strict";
const fs = require("fs");
const Purgecss = require("purgecss");

const distDir = `${process.cwd()}/dist/`;

const purgecss = new Purgecss({
    content: [distDir + "**/*.html", distDir + "**/*.js"],
    css: [distDir + "**/*.css"],
});

const purgecssResult = purgecss.purge();
for (const [index, obj] of Object.entries(purgecssResult)) {
    fs.writeFileSync(obj.file, obj.css);
}

contentオプションで指定されたファイルを解析して、使用される可能性のあるクラスのみを残します。
静的解析はhtmlやjsの他、React, Vue, Nuxt, Wordpressに対応しています。
また、各種タスクランナー、モジュールバンドラー用のプラグインも用意されていますので、ワークフローにあったものを導入してください。


各手法の解説については以上になります。
ありがとうございました。