eye.jpg

ベーシック アドベントカレンダー 11日目です。

ファーストビューのレンダリング速度を向上させる…つまり、First Contentful Paint (FCP) の速度をあげることでユーザがページを閲覧するときの体感速度をぐっと上げることができます。また FCP は Google の PageSpeed Insights など計測ツールでも重要な一つの指標になっています。

そこで今回は FCP を改善する取り組みを簡単に紹介しつつ、Rails アプリケーションに適用する方法を模索した結果を共有します。

クリティカル レンダリング パス

Critical Rendering Path とは、ブラウザがページを描画する際に HTML や CSS, JS をピクセルに変換していくステップのことです。この Critical Rendering Path を最適化することで、描画速度を改善することができます。

ブラウザがページを表示するためには DOM ツリーと CSSOM ツリー(CSS Object Model)を作る必要があります。HTML と CSS をいかに早くブラウザに読み込ませるかが重要です。CSSOM についてはこちらの記事がわかりやすいので興味があれば見てみてください。

CSS は通常ではレンダリングブロックする対象となります。つまり CSSOM ツリーの構築が完了するまで描画が行われないということを意味しています。試しにレスポンスが異常に遅い CSS ファイルを作成して試してみます。

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" type="text/css" href="./slow.css.php" media="all">
</head>
<body>
<h1>CSS loading test.</h1>
</body>
</html>
<?php
header("Content-type: text/css; charset=utf-8");
sleep(3);
?>
h1 {
  font-size: 16px;
}

PHP でわざと 3 秒スリープして CSS を出力するようにしてみます。

adv09-1.gif

アクセスしてから 3 秒たってようやくページが表示されています。

また、このページを Chrome DevTool の Performance タブで解析してみます。

adv09-2.png

画像にあるように slow.css.php のダウンロードが完了されてから FP / FCP ( First Paing / First Contentful Painge ) になっているのが分かります。

次は CSS がレンダリングブロックになっているのを防ぐために </body> の直前に移動してみます。

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
</head>
<body>
<h1>CSS loading test.</h1>
<link rel="stylesheet" type="text/css" href="./slow.css.php" media="all">
</body>
</html>

adv09-3.gif

こうすると最初に DOM ツリーの構築が終わり、その分の描画がされます。当然 CSS のスタイルは当たっていないので <h1> のフォントサイズはデフォルトのまま大きいです。その後 CSS リソースが見つかり CSSOM ツリーを構築してスタイルを適用するので、このような描画になります。

adv09-4.png

DevTool で見ると slow.css.php のダウンロードが完了する前に FP / FCP になっています。FCP の速度を改善するという意味ではこれで良いのですが、ユーザ体験からすると表示されたページのレイアウトがガタガタするのは好ましくありません。

そこで、ページを開いたときのファーストビューあたりに必要な CSS だけは同期的に読ませて、それ以外の CSS を非同期で読み込ませることで FCP 速度を向上させるようにすれば良いということになります。

CSS を非同期で読み込む方法

基本的に <link rel="stylesheet"> で CSS リソースを読み込むのは同期的になります。 そこで非同期で読み込むための preload という仕様が提案されていて Chrome や Safari であれば対応はされていますが Firefox ではまだされていません。

<link rel="preload" href="path/to/mystylesheet.css" as="style">

また loadCSS という preload を実現するライブラリがあります。しかし、このライブラリを使わなくてももっと簡単に CSS リソースを非同期で読み込む方法が loadCSS を開発しているチームが記事で紹介しています。

<link rel="stylesheet" href="/path/to/my.css" media="print" onload="this.media='all'">

実現方法は至って簡単で media="print" の場合、ブラウザはスクリーンに必要のない CSS と判断してリソースの読み込みをスキップします。その後、 onload イベントが発火したタイミングで改めて必要なリソースをさせることで非同期を実現しています。正直 preload の実装シェアが広がるまでの一時的なハックなので、採用するかどうかはちゃんと判断したほうが良いと思いますが…。

ファーストビューに必要なスタイルを探す

CSS リソースの非同期読み込みができたので次はファーストビューに必要な CSS を探します。これを実現するためのライブラリは ciriticalpenthouse などいくつかあります。今回は penthouse を利用してみます。これは puppeteer(ヘッドレス Chrome)を使って指定 URL へアクセスし、指定 width / height の描画に必要な CSS を抽出してくれます。

const penthouse = require('penthouse')
const fs = require('fs')

penthouse({
  url: 'https://example.com',
  css: 'assets/application.css'
})
.then(criticalCss => {
  fs.writeFileSync('outfile.css', criticalCss);
})

この抽出した CSS を <style> タグでインライン展開することで、最初の見た目はスタイルが適用された状態になり、残りのスタイルは非同期で読み込むことで FCP を向上させることができます。

試しに Twitter Bootstrap を使ったサンプルページで、通常の CSS リソースを読み込むパターンと、 penthouse でファーストビューに必要なスタイルを <style> で展開したものとの FCP 差分をチェックしてみます。チェックするには今回は Google PageSpeed Insights を利用しました。

adv09-5.jpg

結果はご覧の通り最適化している方の FCP 速度が上がっているのと、実際にブラウザで描画が開始されるタイミングが早くなっているのが分かります。このやり方を使えばページの FCP 改善はいけそうです。しかし実際に開発をするアプリケーションは静的で単純な HTML というわけではないと思うので、Rails アプリケーションでどのように運用をしていくのかを模索したやり方を紹介します。

Rails で CSS を抽出する

僕が考えた処理の流れは以下のような感じです。

  • 設定ファイルに CSS 抽出したい対象の URL を書く
  • 対象 URL が依存している CSS ファイルパスを manifest を元に抽出する
  • Assets Precompile された CSS ファイルを penthouse へ読み込ませる
  • penthouse で抽出された CSS を Rails.cache へ入れる
  • stylesheet_link_tag を拡張したヘルパーを用意する
  • もし抽出された CSS があれば、それを <style> で展開する

production ready な作り込みはできていませんが、だいたいこんな流れでいけると思います。以下はコードです。(多少決め打ちなところとエラーハンドリングしていないところは目をつぶってください)

最初に設定 YAML ファイルです。ここでは抽出したい URL をリストアップします。

defaults: &defaults
  targets:
    - /articles

development:
  <<: *defaults
  base_url: http://localhost:3000

次に CSS を抽出する rake タスクです。この rake タスクは assets:precompile が実行されたあとに、自動で実行されるように設定をしています。また assets:precompile をすると manifest ファイルが生成されます。その manifest ファイルを Sprockets::ManifestUtils.find_directory_manifest で探します。

そこにはコンパイルされた CSS のファイルパスが記載されています。それを利用して node コマンドをシステムコールで実行し、標準出力の結果を受け取り Rails.cache させるという流れになります。

namespace :extract_critical_path_css do
  desc 'Extract InlineCSS from Assets Precompiled CSS file'
  task generate: :environment do
    require 'open3'
    assets_path = Rails.root.join('public/assets')
    manifest = Sprockets::ManifestUtils.find_directory_manifest(assets_path)
    json = JSON.parse(File.read(manifest))
    css_file_path = json['assets']['application.css']

    config = YAML.load_file('./config/extract_critical_path_css.yml')['development']
    config['targets'].each do |url|
      options = {
        url: config['base_url'] + url,
        css: 'public/assets/' + css_file_path
      }
      out, err, ps = Open3.capture3(
        'node',
        './lib/extract_critical_path_css/index.js',
        JSON.generate(options)
      )
      Rails.cache.write(url, out, namespace: 'critical_path_css')
    end
  end
end

Rake::Task['assets:precompile'].enhance { Rake::Task['critical_path_css_extract:generate'].invoke }

penthouse を実行している node ファイルはこちら。

const penthouse = require('penthouse')
const options = JSON.parse(process.argv[2])
penthouse(options).then(css => {
  process.stdout.write(css)
})

当然 penthouse npm パッケージが必要になるので事前に npm プロジェクトを作る必要があります。

mkdir lib/extract_critical_path_css
npm init
npm i -D penthouse

次に stylesheet_link_tag を拡張したヘルパーを定義します。

module ApplicationHelper
  def stylesheet_link_tag_with_critical(*sources)
    return stylesheet_link_tag(*sources) unless Rails.cache.exist?(request.path, namespace: 'critical_path_css')
    css = Rails.cache.read(request.path, namespace: 'critical_path_css')
    style_tag = content_tag(:style, css, {type: 'text/css'}, false)

    sources[1][:onload] = 'this.media="all"'
    sources[1][:media] = 'print'
    style_tag + stylesheet_link_tag(*sources)
  end
end

もしキャッシュがなければそのまま stylesheet_link_tag へ移譲。あれば、content_tag を使って <style> タグを生成し、さらに stylesheet_link_tag で作成される <link rel="stylesheet"> を非同期で読み込むための処理を加えます。これで指定したページのファーストビューに必要な CSS を抽出して適用することができるようになりました。

Rails.cache のストアを Redis などにすれば、デプロイ時の rake タスク実行で格納することができるようになると思います。development 環境では memory_store ではなく file_store にしないと rake タスク終了後に破棄されてしまうので動作確認をする場合は注意してください。

今回の Rails における CSS 抽出は mudbugmedia/critical-path-css-rails のアイデアを参考にしました。また、僕は見送りましたが、ページを閲覧したときに Resque などの worker に CSS 抽出処理を投げるというアイデアもあり、これはこれで面白そうでした。(Resque とか管理する要素は増やしたくなかった…)

FCP の改善は今回紹介した CSS 以外にも JS のレンダリングブロックを解消したり、画像など必要なリソースを先読みしたり、逆に遅延読み込みさせたりなど、複数の要素で成り立っているので、プロダクション環境ではなかなかシンプルにはいかないかもしれませんが、今回のようなやり方で1歩ずつ改善に進められればと考えています。がんばるぞ〜。

Photo by Mr TT on Unsplash