👌

Nuxt製WebサービスのLighthouseスコアを28点→55点に上げた話

2021/09/29に公開

オンライン家庭教師マナリンクのトップページのLighthouseスコアを28点前後から55点前後まで上げた話をします。

背景説明

対象読者

  • Nuxt.jsでアプリケーション開発の経験がある方
  • 開発しているサービスのスコアが50点未満の方
  • 実際に運用されていて50点未満のサービスのLighthouseスコアをどうやって上げるのか興味がある方
  • フロントエンドのパフォーマンス改善について網羅的にざっくり知りたい方

環境

  • Nuxt.js 2.15系
  • Lighthouse 8.3系を利用して測定
  • ピボット前の時期も含めると、2年半ほど運用されているNuxtアプリケーション

注意事項

  • 今回の改善は2営業日程度でサクッと実施した内容なので、根本的な解消はできていません
  • 目立った内容のみ記事に記載するので、他の内容も気になるという方はぜひ個別にお声がけしてください
  • Nuxt2系の話です。3系が楽しみですね

スコア改善実施の背景

実は昨年にも、当時5点だった同アプリケーションのスコアを55点前後まで上げたことがあるのですが、結局継続的に開発していると徐々に下がっていくもので、最近は30点も下回りつつある始末でした。
オンライン家庭教師関連のクエリで流入を確保したいマナリンクにとって、SEOに悪影響が出る可能性のあるスコアは回避したい、ということでスコア上昇に取り組むことにしました。

個人的には以前の改善の経験があったので、2営業日くらいあれば50点台には持っていけますと事前に見積もりをして作業時間を確保しました。

28点だったときの問題点

まずは28点だったときのLighthouse測定結果と、そこから読み取れる問題点を示します。

スクリーンショット 2021-09-29 15 46 16

ちなみにlighthouseでスコアを測定するには以下のコマンドを実行します。PageSpeed Insightsを使って比較すると、アクセス制限を掛けているステージング環境で事前に測定することができないため、今回はlighthouseベースで比較することにしました。

lighthouse https://manalink.jp --view

CLSが高い

CLSの高さが目に付きますね。CLSはCumulative Layout Shiftの略で、累積のレイアウトシフトという意味です。その意味の通り、ページ内でレイアウトがズレてしまう箇所のズレ具合の合計値がここの数値になります。

どういうときに起こるかというと、しばしばHTMLの<img>タグにwidth/heightを指定するのが抜けているときに起こります。そうなると、ブラウザが事前に画像のサイズを予測できないために実際に画像をロードしたときにその画像のサイズに応じて画面内でズレが発生するのです。

https://web.dev/i18n/ja/cls/

FCP/LCP/TTI/TBT/Speed Indexのすべてが高い

それ以外の項目も赤色の文字で表示されているように、かなり悪い数値という扱いを受けていますが、自分の認識ですと、これらはCLSのように簡単に原因が(ほぼ)一意に特定できるものではありません。地道に画像やJavaScriptのサイズを減らしていくといった工夫をほどこす必要があります。

ちなみにですが、バックエンドへのアクセスを伴うSSRのページの場合、そのAPIのレスポンスタイムがかなり遅いとこの辺の数値は全体的に悪化します。弊社のトップページの場合はすでにCloudFrontでのキャッシュを部分的に入れることで、APIアクセスタイムの影響を緩和していますが、フロントエンドの対策だけでスコアが全部なんとかなると誤解を招くと問題かなと思ったので補足しました。

改善内容

それではお楽しみの改善内容を説明していきます。

画像の最適化

トップページ内で使われている画像に対して、以下の最適化を実施しました。

  • WebP画像を用意
  • <picture>タグを使って、WebP対応しているブラウザでのみWebPを表示させる

とはいってもそんなに難しいことはなくて、nuxt-imageというパッケージを使えばいいです。

https://image.nuxtjs.org/components/nuxt-picture/

こちらのパッケージの<nuxt-picture></nuxt-picture>タグを利用すれば、全く設定なしでWebP画像を利用できます。これまで通りJPGやPNGをsrcに指定すれば、(おそらくビルド時に)WebP版の画像も用意してくれます。

<nuxt-picture
    loading="lazy"
    width="750"
    height="620"
    class="step-list__figure"
    :src="imageUrls.usageFlowFirst"
    alt="自分にあった先生を検索"
/>

注意点としてはstaticディレクトリ以下に置かれた画像じゃないといけない点です。assets以下に置いてしまうとWebpackの管理下になってしまうので。そのため今回はディレクトリ間の移行作業を実施しました。

(既出)microcmsにアップロードしている画像のWebP対応

マナリンクでは記事など一部のコンテンツ管理にmicrocmsというCMSを利用しています。

https://microcms.io/

このサービスが裏でimgixと連携しているので、WebP対応済み画像を容易に得ることができます。こちらの方法でもWebP対応はしていますが、今回はGit管理したい固定画像をターゲットにした対策だったのでnuxt-imageを使いました。

https://imgix.com/

microcmsの画像を渡すと、内部で勝手にWebP対応済みかつ必要に応じてCropした画像を返すフックを作ったりしていました。これは過去にすでにやったので今回の話ではないのですが補足です。トップページ中に表示されているmicrocms入稿済みの画像などはこれで対策しています。

useWebpImageという独自フック(抜粋)

もっとうまい方法があるような気もしますが・・・

import { computed } from '@nuxtjs/composition-api'

export type UseWebpImageParams = {
  src: string
  // 本物の元画像のサイズ※比率算出用なのでざっくりでOK
  readonly imgHeight: number
  readonly imgWidth: number
  // 実際にリクエストしたい画像のサイズ
  readonly realImgHeight: number
  // imgタグにスタイルを当てる内容
  readonly width: number
  readonly height: number
  // 画像のクオリティ。1-100で指定
  readonly quality: number
}
;export const useWebpImage = (props: UseWebpImageParams) => {
  const src = // 省略

  /**
   * Webp変換やトリミングに対応しているかどうかのフラグ
   */
  const canOptimizeImage = computed(() => {
    return /^https:\/\/images.microcms-assets.io/.test(src) || /imgix.net/.test(src)
  })

  /**
   * これがTrueだと、実際にリクエストする画像のサイズをトリミングできる
   */
  const enableCalculateRealSize = computed(() => {
    return props.imgHeight && props.imgWidth && props.realImgHeight
  })

  const webpPath = computed(() => {
    if (canOptimizeImage.value && enableCalculateRealSize.value) {
      return `${src}?fm=webp&fit=crop&h=${props.realImgHeight}&q=${props.quality} 1x,
        ${src}?fm=webp&fit=crop&h=${props.realImgHeight * 2}&q=${props.quality} 2x,
        ${src}?fm=webp&fit=crop&h=${props.realImgHeight * 3}&q=${props.quality} 3x`
    }
    if (canOptimizeImage.value) {
      return `${src}?fm=webp`
    }
    return src
  })

  const srcPath = computed(() => {
    if (canOptimizeImage.value && enableCalculateRealSize.value) {
      return `${src}?fit=crop&h=${props.realImgHeight}&q=${props.quality}`
    }
    return src
  })

  const imgStyle = computed(() => {
    return {
      width: `${props.width}`,
      height: props.height,
    }
  })

  return {
    canOptimizeImage,
    srcPath,
    webpPath,
    imgStyle,
  }
}

背景画像のWebP対応

WebP対応するときに他の選択肢で考慮したこととして、背景画像のWebP対応があります。

<img>タグを使用する方法は比較的理解しやすいのですが、背景画像のWebP対応をする方法は調べた範囲だとちょっとだけ複雑でした。

自分が一番理解しやすかったのはModernizrを使う方法です。

nuxt-modernizrというパッケージをインストールして、以下のように設定すると、WebP対応しているブラウザでのみhtmlタグに.webpクラスを付与してくれます。

    ['nuxt-modernizr', {
      'feature-detects': ['img/webp'],
      options: ['setClasses'],
    }],

なので、CSSのほうで.webpクラスの子要素として背景画像を使いたい要素を指定すると、モダンブラウザでのみWebPを利用できます。

ただし、これはこれでnuxt-modernizrのJavaScriptが余分にバンドル結果に含まれてしまうので、結局今回は導入しませんでした。

代わりに背景画像を使いたいケースでは、以下の記事を参考に、display: gridを利用してimgタグを使いつつ背景に画像を設定することに成功しました。IEは気にしていません。

https://coliss.com/articles/build-websites/operation/css/less-absolute-positioning-modern-css.html

imgタグにてwidth/height/loadingを指定する

さきほどのnuxt-pictureタグの利用シーンを再掲しますが、CLS対策のためにwidth/heightは必ず指定します。および、loadingはファーストビュー以下の画像で積極的にlazyを指定します。以前はLighthouseはloading=lazyを指定しても「オフスクリーン画像の遅延読み込み」項目に引っかかっていた気がするのですが、久々に見るとloading=lazyでも項目に引っかからなくなっていました。

<nuxt-picture
    loading="lazy"
    width="750"
    height="620"
    class="step-list__figure"
    :src="imageUrls.usageFlowFirst"
    alt="自分にあった先生を検索"
/>

CSSでimgが親のpictureタグをはみ出さないようにした

nuxt-pictureを使って、pictureタグ内のimgタグのサイズが大きすぎる場合、親のpictureタグをはみ出る場合が多かったため、グローバルCSSに以下のように指定してしまいました。もっと賢い方法があるかもしれませんが・・・

picture {
  img {
    max-width: 100%;
    height: auto;
  }
}

ここまでで画像に関する工夫の説明を終わります。

JavaScriptの削減

続いて本丸とも言えるJavaScriptの削減です。

現状調査

ビルドコマンドで分析を掛けて、ビルド結果のファイルごとのサイズを調査します。

 npx nuxt build -a

bokashita

ここで昔スコアが5点を叩き出した時代は、Vuetifyで利用しているMaterial Design Iconが全部バンドルに含まれており異常にJSが大きかったり、FirebaseをNuxt Pluginに含んでしまっていたせいで数百KBをゆうに超えてしまったりしていましたが、今回はvendor/app.jsが170KB(gzipped)程度の大きさで収まってはいました。

ちなみに上の画像の左上にある巨大なオレンジのエリアはzengin-dataという銀行のマスタデータです。もちろん特定のコンポーネントからのみimportさせています。

とはいえ170KBでも大きいことに変わりはないので、なんとか100KB付近を目標に削っていきます。

nuxtのPluginを削除していく

これはNuxt2系の特徴ですが、プラグインを利用するとapp.jsにビルドが含まれてしまうので、強制的に全ページのバンドルサイズに影響します。ほかにもStoreも全ページのバンドルに含まれるようですが、影響としては外部のライブラリを使うPluginでよく露呈します。

本記事では詳細の説明を省きますが、数ページでだけしかGraphQLでしか通信していないにも関わらず、Apollo系のプラグインが合計で53KBほども含まれてしまっていたため、その1ページのGraphQLをREST APIに載せ替えてプラグインを全部削除しました。一時期GraphQLにトライしてみたことがあったのですが、結局aspidaで型安全にREST通信するのが便利で移行を進めていなかったのでこの機会に撤退することにしました。

これだけ見ると、C向けに公開していてSEOが重要なページも含まれるモノリシックなNuxtアプリケーションで変にGraphQLを半端に導入するとバンドルサイズ肥大化のデメリットも強いなと思いました。マナリンクの場合、エンドユーザーを集客するメディアとしての側面と、オンライン指導を実施するための宿題機能やチャット機能などのSaaSとしての側面を両方備えたアプリケーションなので、全体に影響するような導入方法はどちらかに悪影響する可能性があったというところです。
こういったアプリケーションでどうしても導入していきたい場合、NuxtのPlugin以外での導入を考えるのが妥当だと思います。

また、別のPluginにて、ぱっと見はそのPluginはとても軽量な内製のコードが書かれているのですが、importで結構重たいライブラリを入れてしまっているのが盲点でした。そこを修正するとちょっとバンドルサイズをへらすことができました。

DevToolsを見て、 Script Evaluation のCall Treeを順繰りにみていくことで、具体的にどのプラグインの処理が時間掛かっているかも見れるので、これでバンドルサイズだけではなく、処理時間の裏付けも取った状態で削除作業を進めていきます。

bokashita2

モダンビルド

Nuxt2ではビルドコマンドで--modernオプションを指定することで、モダンブラウザにはモダンビルドを、レガシーブラウザにはレガシー用のビルドを返してくれるようになります。

    "build:nuxt": "nuxt build --modern=server",

https://nuxtjs.org/docs/configuration-glossary/configuration-modern/

弊社の場合はかなりの微差でしたが、若干のバンドルサイズ軽減が見られました。および、同じくNuxtをガンガン使っていると見られる https://note.com/ さんも、検証してみるとモダンビルドを配信していたので踏襲することにしました。

ちなみに、どうしてモダンブラウザかどうかをNuxtのサーバーが理解してくれるかというと、User-Agentヘッダを見ているわけですが、この仕様がちょっと厄介です。

というとCloudFront越しにAWS Fargateにデプロイしているのですが、CloudFrontは特に何も対策しなければUser-AgentをAmazon CloudFrontという文字列にしてしまいます。

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/RequestAndResponseBehaviorCustomOrigin.html#request-custom-user-agent-header

しかもUser-Agentを元にキャッシュするのはおすすめしないと書いてありますので、どうするのがいいかというと、Lambda@Edgeを使います。

https://stackoverflow.com/questions/52964794/aws-cloudfront-forward-user-agent-but-dont-cache-against-it

Lambda@EdgeのViewer RequestとOrigin Requestトリガーを使って、独自ヘッダによってUser-Agentヘッダの内容を継承させます。

弊社ではAWS CDKを使っているので、このへんの実装はサクッとCloudFrontに紐付けできました。

'use strict'

import { CloudFrontRequestHandler } from 'aws-lambda'

const handler: CloudFrontRequestHandler = (event, context, callback) => {
  const request = event.Records[0].cf.request
  const headers = request.headers
  const customUserAgentHeaderName = 'X-My-User-Agent'
  const userAgent = headers['user-agent'][0].value

  headers[customUserAgentHeaderName.toLowerCase()] = [
    {
      key: customUserAgentHeaderName,
      value: userAgent,
    },
  ]

  callback(null, request)
}

exports.handler = handler

このへんでJavaScriptの解説を終わります。

余談ですが、vue-lazy-hydrationも使っているのですがそこまで効果を感じたことがないです。効果があった方は教えてほしいです。

CSS

これまでextractCSS: trueを指定していたのですが、noteさんがインラインCSSにしていたので外しました。その代わりPurge CSSが効かなくなってしまうデメリットがあるのですが、一方で個別のファイルに吐き出されないことでHTTPリクエスト数が減るメリットもあります。

現時点の環境では、なんとなくextractCSS: trueを外すほうが数点だけスコアが高いように見えましたので、以前めちゃくちゃトライ・アンド・エラーしたPurge CSSの設定を泣く泣く削除しました。しかもnuxt-purgecssの依存もはがせるので若干JSの量を少なくする効果もあります!(震え)
どなたかインラインCSSでPurge CSSを利かす方法をご存知でしたら教えて下さい。

CSSの細かい工夫

Vuetifyで描画しているとあるコンポーネントのレンダリングがちょっと遅く、カクついていたのでCLSに該当していました。問題は高さのブレだったので、親コンポーネントのheightをCSSで固定することで、そのコンポーネントによるレイアウトシフトを0にすることができました。こういうパターンのCLS防止もあるということで共有までに。

広告系のJavaScript削除

ここまでやって、改めてLighthouseをステージング環境で測ったところ48点前後で落ち着いていたので、自分が当初見込んでいた50点台には乗りませんでした。

そんなはずはと思いながら指摘事項を見ると、GTM経由でダウンロードされて実行している広告系のスクリプトがいくつか見えました。

社内にて確認したところ、いくつかは現在利用していないということだったので、利用を止めてもらうことにしました。そうすると5点ほど上昇しました。

まとめ

これで晴れて50点台を安定してLighthouseで記録できるようになりました。

当初と比較すると、CLSはほぼ0(せっかくならきっちり0にしたかったがまた今度)、他のスコアも赤色が残っているものの当初と比較すると数値として一回り小さくなったことが分かります。

スクリーンショット 2021-09-29 17 39 12

今後やりたいこと

小手先で対策できるものは以上になりますが、もっと根本から手を付けるなら以下のような手段が挙げられます。これらもタイミングを見て取り組んでいきたいです。

  • Vuetifyの利用をやめる
    • プラグインの軽量化
    • 4000ルールものグローバルCSSの削除
    • CLS対策
  • Nuxt3(2021年10月12日公開予定)
    • https://nuxtjs.org/v3/
    • 全体のバンドルサイズ20%削減
    • おそらく@nuxtjs/composition-apiの利用を止めれるのでその分のバンドルサイズ削減
    • Incremental Static Regeneration対応されるっぽいのでその適用によるTTFB削減
マナリンク Tech Blog

Discussion