【コアウェブバイタル】CriticalCSS の導入による FCP の改善

CriticalCSS の導入により、Core Web Vitals の指標である LCP のスコア改善に挑みます。

こんにちは。アウモ株式会社でインターンをしておりますイワミ(@B_Sardine)と申します。aumo では主に記事メディアを中心とした開発をしています。
前回の記事のに引き続き今回は第三弾ということで、CriticalCSS の導入について述べていこうと思います。

Critical CSS とは?

前回のCLS についての話でも触れましたが、基本的にCLS 以外の Core Web Vitals において、ユーザーがサイトを訪れた際スクロールせずに見える範囲である Above the fold におけるパフォーマンスは非常に重要です。いかに早くこの領域を描画することができるかが UX 及びパフォーマンススコアに関わってきます。これを妨げる要素として、レンダリングブロックがあります。
レンダリングブロックとは、<head> タグ内で外部の CSS や JavaScript を指定すると、その読み込みが終わるまでレンダリングが行われないというものです。

これの対策としては、Critical CSS を導入しました。

Critical CSSとは…

  • Above the fold を描画するのに必要な CSS 要素のみを抜き出し、<head>でインライン化して読み込み
  • それ以外の CSS は非同期読み込み

という手法です。
レンダリングブロックを避けるためには <head> で style を読まないのが一番ですが、そうすると Above the fold にスタイルが当たらないまま描画されてしまいます(JavaScript は <body> の最後で読み込むなどありますが、CSS ではそれができません)。そこで、この Above the fold の部分に必要な CSS のみを抽出し、それだけを <head> で読み込もうというのが CriticalCSS です。利点としては以下のようなものがあります。

  • <head> で読み込む CSS を最小限にできるため、レンダリングブロックを減らせる
  • インラインで埋め込むことで外部 CSS を初期描画時にリクエストして取得する必要がなくなる

実装

今回は、Google のエンジニアリングマネージャーでもある Addy Osmani 氏の addyosmani/critical を使用しました。

流れとしては以下です。

  1. gulpfile などで critical を読み込み
  2. Above the fold を検出すための html・CSS を入力する
  3. Above the fold で必要な CriticalCSS が出力される
  4. CriticalCSS をインラインで読み込む

critical の使用例

addyosmani/critical の README に記載されている Usage は以下のようになります。

const critical = require('critical');

critical.generate({
  // Inline the generated critical-path CSS
  // - true generates HTML
  // - false generates CSS
  inline: true,

  // Your base directory
  base: 'dist/',

  // HTML source
  html: '<html>...</html>',

  // HTML source file
  src: 'index.html',

  // Your CSS Files (optional)
  css: ['dist/styles/main.css'],

  // Viewport width
  width: 1300,

  // Viewport height
  height: 900,

  // Output results to file
  target: {
    css: 'critical.css',
    html: 'index-critical.html',
    uncritical: 'uncritical.css',
  },

  // Extract inlined styles from referenced stylesheets
  extract: true,

  // ignore CSS rules
  ignore: {
    atrule: ['@font-face'],
    rule: [/some-regexp/],
    decl: (node, value) => /big-image\.png/.test(value),
  },
});

この例では、インラインで埋め込みたい対象のhtml と CritiacalCSS を作成する元となる CSS を入力し、そのまま埋め込まれた状態の html である index-critical.html や critical css, uncritical cssを出力しています。

width と height は、CriticalCSS を抽出する際に対象とする Above the fold のサイズを指定しています。

aumo での使用例 – 生成 –

aumo での実装において一点、例とは違うつまずいたポイントとして、Critical の基本使用例では 静的な HTML などを入力とするように書かれている点です。
aumo の記事サイトは Ruby on Rails で作成しているのですが、SSR なのでページ全体の静的な HTML などが存在しません。そこで、開発時のlocalhost に対してのリンクを入力として与え、オプションとして userAgent を与えることで、PC, SP の HTML を取得、それを CriticalCSS に引数として入れて生成を行いました。

# build critical css function
critical_css_build = (device) ->

    # check base url
    base_url_check = (cb_1,cb_2) ->
        request "http://[::1]:#{RAILS_PORT}", (err, res, body) ->
            if err == null
                ipv6_flag = true
                cb_1(ipv6_flag,cb_2)
            else
                ipv6_flag = false
                cb_1(ipv6_flag,cb_2)

    # set base url
    base_url_set = (ipv6_flag,cb_2) ->
        if ipv6_flag
            base_url =  "http://[::1]:#{RAILS_PORT}"
            cb_2(base_url)
        else
            base_url = "http://127.0.0.1:#{RAILS_PORT}"
            cb_2(base_url)

    # generate critical css
    generate_critical_css = (base_url) ->

        # set general params
        params = {
            base: "#{ASSETS_DIR}",
            css: ["stylesheets/style-#{device}.css"],
            minify: true,
        }
        # set device params
        if device == 'sp'
            params.width     = 400
            params.height    = 800
            params.userAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
        else if device == 'pc'
            params.width     = 1920
            params.height    = 1080

        # for root
        params.src = base_url
        params.target  = {css: "stylesheets/critical-root-#{device}.css"}
        params.target  = {uncritical: "stylesheets/uncritical-root-#{device}.css"}
        critical.generate params

        # for article pages
        params.src = base_url + "/articles/575454"
        params.target  = {css: "stylesheets/critical-#{device}.css"}
        params.target  = {uncritical: "stylesheets/uncritical-#{device}.css"}
        critical.generate params

    # main stream
    base_url_check(base_url_set,generate_critical_css)

CriticalCSS を参照する際の記事は適当に選択しました。また、今回は記事ページとトップページにおいて適用することを考えていたため、それぞれ分けて生成しました。

細かいつまずきとして、CriticalCSS 生成の際の localhost ですが、自分は Rails のサーバーをオプションコマンドなしで起動していました。

bundle exec rails s

これで Chrome などのブラウザを用いて http://localhost:{指定したport}/ でアクセスできると思います。一方、Gulpfile の CriticalCSS 生成部分でも同様に http://localhost:{指定したport}/ を指定していました。これで localhost で立てたサーバーを Gulp が読んでくれ、それをもとに CriticalCSS を生成できるはずでした。しかし、Gulpが一向に localhost を読んでくれません。

実は、Rails サーバーはIPを指定せずに起動した場合のデフォルトの localhost は IPv4 の 127.0.0.1 ではなく、IPv6 の [::1] で起動しているようでした。対してGulp は IPv4 のlocalhost (127.0.0.1)を見ていたのでした。(Rails 5.2.3 を使用)

なので、 Gulp 側での指定先を http://[::1]:{指定したport}/ にするか、 Rails を以下のように起動する必要がありました。

bundle exec rails s -b 0.0.0.0

これで無事、Gulp から Rails サーバーのファイルを読むことができました。しかし、この先自分以外もこのトラブルに引っかかる可能性があったので、CriticalCSS の Task を走らせた際に IPv4 なのか IPv6 なのかを判別するようにしました。

# check base url
base_url_check = (cb_1,cb_2) ->
    request "http://[::1]:#{RAILS_PORT}", (err, res, body) ->
        if err == null
            ipv6_flag = true
            cb_1(ipv6_flag,cb_2)
        else
            ipv6_flag = false
            cb_1(ipv6_flag,cb_2)

IPv6 の localhost である http://[::1]:#{RAILS_PORT} で試しにリクエストを行い、問題ない場合は IPv6 で、エラーが返ってくる場合は IPv4 の 127.0.0.1 で CriticalCSS の Task を行いました。

aumo での使用例 – インライン展開 –

これで critical-sp.css みたいなファイルが生成されました。このまま <head> でこの css を同期読み込みし、その他を非同期で読み込むとしてもいいですが、今回は本来の CriticalCSS に従うべくインライン展開します。

とは言っても、ファイルを展開して <style> に入れる helper を作り、slim 上で呼び出してあげるだけです。

asset_helper.rb

  def gulp_sp_critical_root_asset_inline_style_tag(file)
    if Rails.env.production? or Rails.env.staging1? or Rails.env.qa? or Rails.env.internal?
      path = SP_CRITICSL_ROOT_REV_MANIFEST[file]
      c = File.open(Rails.root.to_s + "/public/#{assets_dir}/stylesheets/#{path}").read
    else
      c = File.open(Rails.root.to_s + "/public/#{assets_dir}/stylesheets/#{file}").read
    end
    content_tag :style, c
  end

_html_head.slim

- if current_page?(root_path)
  = gulp_sp_critical_root_asset_inline_style_tag('critical-root-sp.css')
- elsif @article
  = gulp_sp_critical_asset_inline_style_tag('critical-sp.css')
- else
  = stylesheet_link_tag gulp_sp_asset_path('style-sp.css'), media: 'all', 'data-turbolinks-track' => true

これで無事、以下の画像のように<head> 内で CriticalCSS がインライン展開された状態で送られました。

<head> 内に CriticalCSS をインライン化している様子

これにより、必須な同期読み込み部分は最小限に減らし、かつその部分もインライン化しているためリクエスト数も増やさないことができました。

まとめ

最近は Next.js が実験的に自動的なCriticalCSS の適用をマージしていたり、CSSinJS のライブラリである styled-components ではページにレンダリングされたコンポーネントを追跡し、それらのスタイルだけを自動的に挿入する Automatic critical CSS の記述があるなど、ライブラリ側が行ってくれることが増えてきたようです。また、web.dev でも解説されています。

最近はこの CriticalCSS に関する記事を見かける機会が少しずつ増えてきた気がしているので、読んでいただいた方の参考になれば幸いです。ありがとうございました。