新卒1年目のエンジニアがQiitaの速度改善をした話


Increments × cyma (Ateam Inc.) Advent Calendar 2020 の1日目は、
Increments株式会社 プロダクト開発グループ Qiita開発チームの花田(@ohakutsu)が担当します!

はじめに

僕は2020年の新卒エンジニアとしてエイチームに入社し、今年の6月にIncrementsに配属されました。
配属後はQiitaの機能開発や改善を行い、現在もQiitaの開発を行っています。

配属後4ヶ月程度の頃、プロダクトマネージャーと話をした際に、

PM「最近はどう?仕事は楽しい?」
僕「つまらなくはないですが、最近新しいことがなくてワクワクすることがないんですよね〜」
PM「あー。じゃあ、花田くんにQiitaの速度改善をお願いしようかな」
僕「あ、やります」

となり、Qiitaの速度改善を託されました。

この記事は、新卒なりにQiitaの速度改善をしたときのことを書いていきます。

速度改善でやったこと

とりあえずLightHouseをやって改善してみたけど...

はじめ、速度改善と言っても何をすればいいのかわかりませんでした。

とりあえずLightHouseで計測をして、「改善できる項目」をとりあえずやってみましたが(プロダクトマネージャーがほとんどやってくれました)、大きな変化を感じることはできませんでした。

速度改善について知る

速度改善がよくわからない状態だったので、超速! Webページ速度改善ガイド ──使いやすさは「速さ」から始まるを読んだりして知識をつけました。

土日にこの本をひたすら読んで、Qiitaのページとにらめっこしていたのを覚えています。

速度改善を始める

速度改善には、リクエスト数を減らす、画像などのリソースの圧縮、ファイルサイズの減量などがあると思いますが、僕が最も重要だと思っているのは、

  • クリティカルレンダリングパスの最適化

です。

クリティカルレンダリングパスとは、HTML, CSS, JavaScriptファイルなどのリクエストからレンダリングされるまでの処理のことです。

リクエストからレンダリングまでには、

  • HTMLドキュメントのロードと評価
  • サブリソースのロードと評価
  • レンダリングツリーの構築と描画

があり、それぞれを最適化していくことで、ユーザーがページを表示するまでの時間が短くなっていきます。

ちなみにQiitaのクリティカルレンダリングパスを見ると以下のようになっています。(Chromeの開発者ツール > Performance から見ることができます)

ただ、クリティカルレンダリングパスを最適化すればいいんだなと思っても、どこを改善していけばいいのかがわかりません。

そこで、どこまでの表示を改善するかを先に決めておく必要があります。
わかりにくいので、Qiitaでの具体例を出しつつ説明をします。
ユーザーがページをリクエストしてからページが完全に表示されるまでにはいくつかのステップがあります。

  • First Paint
    • ページに何かしらが表示され初めたタイミング
    • 上の開発者ツールの画像だとFPとなっている部分
    • QiitaのFirst Paintは↓
  • First Contentful Paint
    • ページにコンテンツが表示され始めたタイミング
    • 上の開発者ツールの画像だとFCPとなっている部分
    • QiitaのFirst Contentful Paintは↓
  • First Meaningful Paint
    • ページがユーザーにとって意味のある表示になったタイミング
    • ユーザーにとって意味のある表示はサービスによって異なるので、適宜決める必要がある
    • Qiitaだとユーザーは記事を読むために訪れるので、記事の表示が終わったタイミング
    • QiitaのFirst Meaningful Paintは↓
  • Time To Interactive
    • ページの表示が終わり、ユーザーの入力に対して応答ができるタイミング
    • Qiitaの場合だと、LGTMボタンやストックボタン、目次が使えるようになったタイミング
    • QiitaのTime To Interactiveは↓

これらのステップのうちのどこまでの速度を改善するかを決めておくことで、改善前後の比較ができるのと、見当違いの箇所を見ることを防げます。

今回、僕は見る範囲をリクエスト開始からTime To Interactiveまでに絞り、速度改善に立ち向かいました。

ちなみに、Time To Interactiveまでの時間がQiitaの場合DOMContentLoadedとほぼ同じなので、DOMContentLoadedの時間を見ると800ms程度かかっています。(DOMContentLoadedは最初のHTMLの評価が終わったタイミング)

今回の速度改善のゴールは、DOMContentLoadedの時間を短くするとします。

ボトルネックな部分の発見

速度改善をするにあたって、何に時間がかかっているのかを知る必要があります。

僕はまず、開発者ツールのPerformanceでリクエスト開始からTime To Interactiveまでの範囲のクリティカルレンダリングパスを見ました。

これを見ると、明らかに初めのHTMLファイル(画像の▼ Networkタブ内のb7a001d26bd98...のやつ)の時間が500ms程度と長いです。また、何回か計測したところ、300ms〜900ms程度かかっていることがわかりました。

JavaScriptファイル(画像の▼ Networkタブ内のv3-article-bundle-...)も300ms程度かかっていますが、プロダクトマネージャーが既にファイルサイズの減量をしていたのと、Service Workerでキャッシュをしているので、今回は見送りました。

上の画像から、初めにロードするHTMLファイルが遅いことがわかりましたが、サーバのレスポンスが遅いのかダウンロードに時間がかかっているのか、はたまた評価に時間がかかっているのかは現段階ではわかりません。

そこで、何に500ms程度かかっているのかを見ていきます。
上の画像の▼ Networkタブ内のb7a001d26bd98...のやつをクリックすると、↓が出てきます。

これを見ると、リクエストからダウンロード完了までに400ms程度、評価に100ms程度かかっていることがわかります。

今度は、約400msもかかっている network transfer のほうを見ていきます。
Chromeの開発者ツール > Networkから、b7a001d26bd98...のやつを選択して、Timingを見ると、

Waiting(TTFB)Content Downloadに多くの時間がかかっていることがわかります。(400msのうちの150msはどこにいったのかはわかってないです...)
ちなみに、Waiting(TTFB)はリクエストを送ってから初めのレスポンスを受け取るまでの時間です。要因として考えられるのは、サーバでの処理に時間がかかっているなどです。

実際に、Qiitaのサーバサイドで時間がかかっているのかを見たところ、リクエストを受けてからレスポンスをするまでに100ms〜200ms程度かかっていることがわかりました。

ボトルネックになっている部分を見つけ出したので、そこの改善を試みます。

ボトルネックな部分を改善したいけど...

ボトルネックな部分を見つけたのはいいのですが、結論から言うと改善をすることができませんでした。
記事のページに関する部分に

  • N+1のクエリなどがあるのだろうか?
  • 時間のかかる処理が隠れているんだろうか?
  • SSRが問題になっているのだろうか?

と思い調査をしましたが、結局原因がわかりませんでした。

Resource Hintsで事前読み込みをする

ボトルネックな部分を見つけることはできましたが、改善をすることができませんでした。
リクエストからレスポンスが遅く、解決できないのであれば事前読み込みをするしかありません。

そこで、Resource Hintsを使い、事前読み込みをします。
Resource Hintsはブラウザの機能にあるもので、未来に使うリソースを事前に読み込んでおくことができます

Resource Hintsについては、記事を書いているのでぜひご覧ください↓

今回、事前読み込みをするのは、トレンドページに載っている記事のアクセスが多いのと、試験的にやるということで、トレンドページに載っている記事にしました。
また、事前読み込みをするタイミングは、いくつか試した結果、トレンドページの記事へのリンクをホバーしたときに事前読み込みが走るようにしています。

以下の画像で、記事へのリンクをホバーしたときに事前読み込みが走っていることがわかります。

また、Qiita開発チームでは以下のようなカスタムフックを実装し、Resource Hintsに必要な処理を意識しなくて良いようにしています。

useResourceHints.ts
import { useCallback, useState } from 'react'

interface Props {
  rel: 'dns-prefetch' | 'preconnect' | 'prefetch' // prerender は1つのページしか事前レンダリングできないため使わない
  url: string
  resourceType: 'document' | 'image' | 'script' | 'style'
}

export const useResourceHints = ({ rel, url, resourceType }: Props) => {
  const [isFetched, setIsFetched] = useState(false)

  const fetchResource = useCallback(() => {
    if (document && !isFetched) {
      const linkElement = document.createElement('link')
      linkElement.rel = rel
      linkElement.href = url
      linkElement.as = resourceType
      document.head.appendChild(linkElement)

      setIsFetched(true)
    }
  }, [isFetched, rel, resourceType, url])

  return { fetchResource }
}
SampleArticleLink.tsx
import React from 'react'
import { useResourceHints } from './path/to/useResourceHints'

interface Props {
  title: string
  url: string
}

export const SampleArticleLink = ({ title, url }: Props) => {
  const { fetchResource } = useResourceHints({
    rel: 'prefetch',
    url: url,
    resourceType: 'document',
  })

  return (
    <a href={url} onMouseEnter={fetchResource}>
      {title}
    </a>
  )
}

結果

Resource Hintsを使った事前読み込みで、表示速度が速くなったかを見ます。

Before After

ゴールに設定していたDOMContentLoadedを見ると、明らかに速くなっていることがわかります。

Resource Hints 最高!

おわりに

新卒のエンジニアがQiitaの速度改善をした話を書いてきました。
結果としては、すべてのページの速度が速くはなりませんでしたが、ユーザーがよく見るトレンドページからの遷移を速くすることができました。

ここ間違ってるよ、こうするといいよなどがありましたらコメントいただけると嬉しいです。

以上が新卒1年目のエンジニアがQiitaの速度改善をした話でした。

Increments × cyma (Ateam Inc.) Advent Calendar 2020 の2日目は、株式会社エイチーム EC事業本部の@shimabeeがお送りします!!