📈

良好URLを1%から99%に爆増させたパフォーマンス改善の話

2023/07/03に公開

こんにちは。
株式会社ココナラ フロントエンド開発グループの加藤です。

1年前、ココナラのフロントエンドは表示速度に課題があり、Google Search Consoleのページエクスペリエンスではモバイルの良好URLはたった1%しかありませんでした。
しかしその後パフォーマンス改善に取り組み、良好URL99%まで大幅に改善することができました。(記事の最後に実際のスクリーンショットもあります)
この記事では、そこで行った改善内容を紹介したいと思います。

はじめに

ココナラのフロントエンドは、主にNuxt + Vue.jsで作られています。
そこで nuxtjs/web-vitals を使ってパフォーマンスのログを取り、Grafanaで可視化しています。
grafanaGrafanaで可視化したページごとのLCP

モバイルではLCP(Largest Contentful Paint)が2.5秒を超えているページがほとんどで、良好URLが少ない原因もほとんどがLCPでした。
したがって、主にLCPの改善に取り組みました。

以下がその具体的な改善内容です。

ロジック

まずはロジックの無駄な部分の修正が必要です。
初期表示に不要な処理は後回しにしたり、無駄な直列処理(特にAPIコール)の修正を行ったりしました。

NuxtであればnuxtServerInitなど、全ページで共通の処理は影響が大きいため優先的に確認が必要です。
ヘッダーなどの共通コンポーネントも影響が大きいところです。

画像

画像の属性

サービスにもよると思いますが、画像のダウンロードはボトルネックになりやすいところで、LCP要素は画像であることも多いため、画像の改善も大切です。
まず簡単にできる部分として、画像に適切な属性を付けるようにしました。

imgタグには原則 loading="lazy" decoding="async" を付ける。
ただし画像がLCP要素の場合は付けず、代わりに fetchpriority="high" を付ける。
というルールを決め、主要ページをその通りに修正しました。

<img
  src="/images/1.png"
  alt="LCP画像"
  fetchpriority="high"
>
<img
  src="/images/2.png"
  alt="その他画像"
  loading="lazy"
  decoding="async"
>
  • loading="lazy" :画像を遅延読込させる
  • decoding="async" :画像のデコード処理を非同期化する
  • fetchpriority="high" :読み込みの優先度を高める

画像の圧縮(次世代フォーマット化)

ココナラはユーザーがアップロードする画像が多く、そのダウンロードが一番のボトルネックになっているページが多数ありました。
そこで画像を軽量化するため、Akamaiが提供している Image & Video Manager というサービスを利用して、CDN上で自動的に変換・圧縮させるようにしました。

PCではAVIF、スマートフォンではWebPなど、自動的にユーザーの環境に応じて見た目に影響の少ない範囲で最大限圧縮した画像を表示してくれます。
結果数十%の大幅な圧縮ができ、ダウンロード時間を短縮できました。

バンドルサイズ

バンドルサイズの分析

webpackを使用しているので、Webpack Bundle Analyzerというツールでバンドルを可視化し分析しました。
bundle-analyzerWebpack Bundle Analyzerで可視化したバンドル
大きなプラグインや無駄にインポートされているコンポーネントなど、問題点が分かりやすくなります。
bundle-analyzer-2可視化したバンドルの主要部分の拡大
バンドルの中身を見ていくことで、使わないようにすれば削除できるところやダイナミックインポート化等で減らせるところが分かったので、実際に削減に取り組んでいきます。

プラグインの削減

プラグインの削減のため、

  • サイズが大きい割に一部でしか使っておらず、自前で代替できるようなプラグインは削除
  • 全ページで読み込む必要のないプラグインは、該当箇所でのみ読み込むようにする
  • サーバー側で不要なプラグインは、クライアント側で読み込むようにする

といった対応をしました。

ポリフィルの削減

無駄に古いブラウザをサポートしているとポリフィルのサイズが大きくなるため、ポリフィルの削減のために古いままだったサービスの推奨動作環境や、社内のサポート基準を見直して更新しました。

その後ターゲットブラウザの設定(browserslist)を合わせて更新することで、その設定を元に生成していたポリフィルを削減できました。
ここでIEのサポートも切ったため、IE用のプラグインも削除しました。

ダイナミックインポート化

コンポーネントは通常のインポートだとその場でインポートされますが、条件によって使われない場合があると無駄になります。

ダイナミックインポートにして、使われる場合のみインポートされるようにすることでバンドルサイズを削減できました。
APIとはgRPCで通信していますが、gRPC通信のためのstubクラスのインポートもダイナミックインポート化でサイズを削減できました。

// 通常のインポート
import Modal from '~/components/molecules/Modal'
export default {
  components: {
    Modal
  }
}

// ダイナミックインポート
export default {
  components: {
    Modal: () => import('~/components/molecules/Modal'),
  }
}

ダイナミックインポートの注意点

ダイナミックインポートには注意点があります。
サーバーとクライアントのレンダリング結果が一致しないとエラーになるため、ブラウザの横幅によって変わるフラグやsetTimeout()で時間差で変化する場合など、サーバーとクライアントで結果が変わる出し分けがある箇所に対してはダイナミックインポートを使わないよう注意が必要です。

実際にこれが原因で事故が発生してしまいました。
ダイナミックインポートにした子コンポーネントの中の、さらに孫コンポーネント内の処理といった部分も確認する必要があります。
developmentビルドではエラーにならず、productionビルドではエラーになるという場合もあったので要注意です。

<!-- NG例 -->
<Modal v-if="isPC" />

<div v-if="isPC">
  <Modal />
</div>

<Modal>
  <div v-if="isPC">
    ...
  </div>
</Modal>

<Modal v-if="timeout" />
<script>
export default {
  data() {
    return {
      timeout: false
    }
  },
  created() {
    setTimeout(() => {
      this.timeout = true
    }, 100)
  },
}
</script>

isPC はブラウザの横幅によって変わるフラグです。

バンドルサイズの削減結果

バンドルサイズを1年前と比較した結果がこちらです。
bundle-result
大きなコンポーネントやプラグインが消えていたり、小さくなって順番が変わっていたりします。
この間にも多数の機能がリリースされているため、増えたコンポーネントもありますが、その上でメインのバンドルのapp.jsは約40%削減、プラグイン等のvendors/app.jsは約22%削減することができました。

バンドルサイズの削減にはもう少し前から取り組んでいて、2021年の末ごろから比べた場合は、app.jsは半分以下になっています。

その他

クライアントサイドレンダリング化

Nuxtでサーバーサイドレンダリングをしていますが、LCP要素を優先して不要なものは後回しにするため、初期表示に不要な要素はクライアントサイドでのみレンダリングさせるようにしました。
主にクリックしたら出るモーダルや、遅れて表示されても問題ないバルーンなどです。
特にヘッダー内等の共通部分の無駄なレンダリングを抑えることで、LCPに効果がありました。

<ClientOnly>
  <Modal />
</ClientOnly>

API通信の内部化

ここはインフラの話になりますが、サーバーサイドのサーバー間のAPI通信は、AWSなどのクラウドの内部で通信させるようにすることでパフォーマンスを改善できます。
クライアントサイドの通信はインターネット経由のままにするため、CSRとSSRでAPIのURLを変える対応と併せて行いました。

Node.jsのアップデート

Node.jsが古いと、内部のJavaScript実行エンジンであるV8エンジンも古いままです。
V8エンジンはアップデートでパフォーマンス改善が行われているので、それを取り込むことでサーバーサイドが改善されます。
以前Node.jsをv12からv16へアップデートしたときには、それだけでLCPが5%ほど改善しました。

結果

以上のような改善のリリースを繰り返し、LCPは徐々に改善していきました。
こちらが実際のGoogle Search Consoleのスクリーンショットです。

result-12022年夏(リリース第1弾)
result-22023年春(リリース第2弾)
緑の棒グラフが良好URLの割合です。

リリース時期は大きく2回に分かれており、合計で40回弱のリリースを行いました。
2022年夏に第1弾を実施し、良好URLは1%から50%まで改善しました。
その後機能追加等の影響で30%弱まで低下してしまいましたが、2023年春に第2弾を実施し、99%まで改善することができました。


この記事がパフォーマンスに課題のある方の参考になれば幸いです。
改善してもそのままだとSearch Consoleにはすぐに反映されないため、改善が一段落ついたらSearch Consoleの「修正を検証」ボタンを押すのもお忘れなく。

この記事の内容は、7/11(火)に開催する「レバレジーズ x ココナラ x ニフティ 合同フロントエンド勉強会」でも発表します。
質問等あれば、ぜひこちらにご参加ください。
https://connpass.com/event/285668/

最後に

ココナラではエンジニアを募集しています。
よろしければぜひ以下のページもご覧ください。
https://coconala.co.jp/recruit/engineer/

Discussion