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

目的

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

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

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

前提とする環境

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

  • macOS 10.14.3
  • node.js 10.15.1
  • webpack 4.30.0
  • gulp 4.0.1

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

  • Bootstrap 4.3.1
  • Font Awesome 5.8.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の遅延読み込みで問題が発生するのは、以下の2点の場合です。

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

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

CSSの遅延読み込み

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

画像の非同期処理

画像の非同期処理は、レイアウトが破綻しない範囲で積極的に適用するべきです。 現状では、画像の非同期処理には2つの手法があります。

imgタグのdecoding属性を追加する

imgタグにdecodingという属性が追加されました。これはimgタグで指定された画像のデコード処理のタイミングをブラウザに指定するものです。 htmlでの記述方法は以下の通りです。

<img src="src.jpg" decoding="async">
                             ^^^^^

指定可能な属性は以下の通りです。

  • sync : 同期的にデコード処理を行う。
  • async : 非同期でデコード処理を行う。
  • auto : デコードのタイミングをブラウザに任せる。

asyncを明示的に指定することによって、画像のデコード処理が画像以外の要素の表示を阻害しなくなります。

より詳細な情報は、Mozillaのドキュメントなどをご参照ください。

lazysizesを利用する

jsライブラリで画像の読み込みタイミングを指定し、遅延読み込みを実現するライブラリです。 同種のライブラリと比較して

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

と非常に優秀なlazyloaderです。

decoding属性とlazysizesの比較

decoding属性の指定と、lazysizesを比較した場合

  • decoding属性はhtmlに値を記述するだけなので導入コストが低い。
  • jsファイルのサイズはlazysizesを導入した分肥大化する。
  • 対応ブラウザの範囲はlazysizesの方が広い。
  • lazysizesでは、スクロール連動の遅延読み込みの指定ができる。decoding属性は処理タイミングを指定するだけなので、すべてのリソースを読み込む。
  • lazysizesでは、読み込み完了後のアニメーション指定ができる。

という違いがあります。

この2つの手法は共存させることが可能ですが、同時に導入することはオススメしません。 両方を同時に導入すると、それぞれのメリットを殺してしまいます。

  • 対応すべきブラウザのバージョン
  • 画像の容量と数
  • 回線の帯域

などを考慮して比較を行い、どちらの手法を導入するかを検討してください。

圧縮する

リソースは圧縮すればするほど、回線とクライアント側のメモリ、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-imagemin

gulp.lastRun()は、前回タスク実行時から変更されたファイルのみを抽出し、gulp.srcとして渡す関数です。画像圧縮処理の時間を短縮するのに有効です。 この関数とgulp-imageminを組み合わせて利用します。

gulp.lastRun()を利用する際、出力先のファイルやディレクトリが削除されてしまうとその恩恵を受けられなくなります。 gulpでdistディレクトリを削除している場合は、

  • 画像フォルダーを削除の対象外に指定する。
  • 画像圧縮タスクの出力先を別のディレクトリに指定し、圧縮完了後にそのディレクトリからdistディレクトリにコピーする。

ことをオススメします。

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で処理をした画像を上書きします。

2018/12/13追記 上記のファイル更新確認スクリプトをモジュールにしました。

ReadMe Card

node.js上のスクリプトから呼び出してご利用いただけます。

キャッシュする

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

gulp-rev

キャッシュの問題はファイル名にハッシュを追加することで解決できます。 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に対応しています。 また、各種タスクランナー、モジュールバンドラー用のプラグインも用意されていますので、ワークフローにあったものを導入してください。


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