Hello Tech

AutoReserve 等を開発する株式会社ハローのテックブログです。スタートアップの最前線から本質的な価値を届けるための技術を紹介します。

Page Speed Insightsの改善 「CLSで満点を取る」

大変ご無沙汰しております、今年度もあと少しです。この記事ではAutoReserveでのCLS改善についてお話します。 Web高速化シリーズ第2弾となります、第1弾「メンバーを巻き込み、分析基盤を整える」も併せてご覧ください。

CLSはきちんと改善を行えば必ず満点の25点を取ることができる、Page Speed Insightsの中でもある意味特別な指標です。CLS以外の指標は「~するまでの”時間”」を示しますが、CLSだけは「1画面のなかでコンテンツがずれた”総量”」を示しているためです。秒数を削るのはある程度限界がありますが、「コンテンツがずれないようにする」のはほぼ完璧に対応することができます。

Page Speed Insightsが登場してすぐの頃はCLSは5点しか持っていませんでした。

5点にしてはUXへの影響が大きいなあと思っていたら、バージョン8で15点、現在のバージョン10で25点になりました。「コンテンツが表示される速度」から「適切にストレスなくコンテンツに触れられるか」に重要な観点がシフトしていったように思います。

CLSとは端的に「ロードが始まった時と終わった時でどれだけコンテンツがズレたか」を表しています。

0はずれなかった、0.5は画面の半分ずれた、1は画面1個分ずれたということです。

What is a good CLS score?

コンテンツのズレを画面の10%以内に抑えればGoodとされていますが、基本的に0にしたいところです。

1. なぜCLSが悪くなる?

CLSが悪くなる要因は以下のような例です。

  • サイズ指定なしでimg・video・iframeタグを使う
  • 動的にリストを取得し表示する
  • 動的に広告を取得し表示する
  • 非同期にCSSをロードしてしまい、コンテンツが表示された後にスタイルが当たる

単純に動的なコンテンツを表示した場合は問題ありませんが、「動的なコンテンツが初めからあるコンテンツに影響を与えた」場合に問題が発生します。以下の例を示します。

Bad CLS Sample

画面最上部にある「Breaking News!!」の文字がロード完了後には一段下がっています。また「Read More」ボタンが「Landscape Image」によって画面最下部に追いやられてしまっています。

これは「BIG SALE」広告と「Landscape Image」画像の領域があらかじめ予約されていなかったことによって発生します。

2. CLSの算出

実際のところCLSの算出はかなり複雑でしたので、要点をかい摘みます。

  • セッションウインドウ: 1秒間に発生するズレの合計
  • スコアは「距離計数」と「インパクト係数」の積となる
    • 距離計数: コンテンツのズレの原因を作った要素(=ロード後に表示される要素)
    • インパクト係数: ズレることになった要素の大きさ(=ロード前からある要素)
  • 1回のロードで1秒以上間が空いて複数回のズレが発生した場合は、そのうちの最大スコアが採用される
  • 例えば距離計数 0.15、インパクト係数0.8の場合、0.15 * 0.8 = 0.12 がスコアになる

正直なところCLSは減らすのではなく0にすること=ズレをなくすことが目標なので、細かな算出ロジックはあまり重要ではないかなと思っています。

CLSをゼロにするには

「動的に表示するコンテンツの領域をあらかじめ予約する」ことです。

例えば画像の場合はwidth, heightを指定しておくことで、データのロード中も領域を確保することができます。iframeや動画の場合も同様です。レコメンドウィジェットなどjavascriptタグを埋め込んで表示するコンテンツも、その領域の大きさをcssですれば解決します。

共通して言えるのは「表示される・されない可能性があるもの」「コンテンツのサイズを予測できないもの」を徹底的に排除することが大切です。

またSPAアプリケーションにおいては、クライアントサイドでコンテンツをロードすることがよくあるため、画像等以外にも細やかな配慮が必要です。事実AutoReserveでも以前までロード中の配慮をしておらず、CLSは悪いスコアでした。CLS改善前は以下のようなロード体験でした。

CLS改善前

上部の画像表示エリアのサイズがロード完了後と異なる点、コンテンツの表示領域が確保されておらずロゴ等のフッターが表示されている。この2点がCLSに悪い影響を与えていました。

この時のPage Speed Insightsのスコアは以下の通りでした。

当時のCLSは0.574だったので、25点のうち0点のスコアでした。React Native Webを使った実装をしているのでmodule的なハンデがあるとはいえ3点はよろしくない…

この問題の解消を試みました。

3. CLSを改善するためにスケルトンを実装する

「動的に表示するコンテンツの領域をあらかじめ予約する」というのは、単に空白にしておいてもCLS改善を実現できます。しかしながら単にその場所を空白にしておくと、「何が起きているのか、何が表示されるのか」とユーザーに思われてしまう可能性があります。ロードに時間がかかるコンテンツの場所を空白のままにしておくと、それを見る前に離脱してしまう可能性が高くなります。最近のメジャーなアプリケーションでは、ロード中にスケルトンを表示することでCLSを改善するだけでなくロード中のUXの改善を目指しています。最近は当たり前のように見かけるようになりました。

コンテンツの領域をwidth, heightなどの絶対値を使って確保するよりも、スケルトンで埋めた方が融通が効くと個人的に思っています。

ここでAutoReserveで使っているスケルトンコンポーネントを紹介します。Atomicなコンポーネントを用意し、用途に応じてアレンジできるようにします。AutoReserveでは、Native・Webアプリケーション両方をReact Nativeで実装しているので、ここではReact Nativeを想定した記述を紹介します。Reactで書く場合、いくつかのcomponentをhtmlに、animation styleをcssに書き換えるだけで動作するはずです!

import React, {
  PropsWithChildren,
  forwardRef,
  useMemo,
  useRef,
  useEffect,
} from 'react'

import {
  View,
  StyleProp,
  ViewStyle,
  Animated,
  Easing,
  Platform,
} from 'react-native'

import { Text } from '../Text'

const SkeletonVariant = {
  text: 'text',
  rectangular: 'rectangular',
  rounded: 'rounded',
  circular: 'circular',
} as const

type Props = {
  animation?: boolean
  height?: number | string
  variant?: keyof typeof SkeletonVariant
  width?: number | string
  style?: StyleProp<ViewStyle>
} & PropsWithChildren

export const Skeleton = forwardRef(function ForwardedSkeleton(
  props: Props,
  ref: React.ForwardedRef<View>
) {
  const {
    animation = true,
    variant = SkeletonVariant.text,
    width,
    height,
    children,
    style,
  } = props
  const animationRef = useRef(new Animated.Value(1)).current

  const hasChildren = useMemo(() => children != null, [children])

  const children_ = useMemo(
    () => (variant === SkeletonVariant.text ? <Text>&nbsp;</Text> : children),
    [variant, children]
  )

  useEffect(() => {
    if (!animation) return
    const animated = Animated.loop(
      Animated.sequence([
        Animated.timing(animationRef, {
          toValue: 0,
          duration: 750,
          easing: Easing.inOut(Easing.ease),
          useNativeDriver: false,
        }),
        Animated.timing(animationRef, {
          toValue: 1,
          duration: 750,
          easing: Easing.inOut(Easing.ease),
          useNativeDriver: false,
        }),
      ])
    )
    animated.start()
    return () => animated.stop()
  }, [animationRef, animation])

  const inner = useMemo(() => {
    if (!animation) {
      return <View style={{ opacity: 0 }}>{children_}</View>
    }

    return (
      <Animated.View
        style={[
          {
            height: '100%',
            backgroundColor: '#0000001a',
            opacity: animationRef,
          },
        ]}
      >
        <View style={{ opacity: 0 }}>{children_}</View>
      </Animated.View>
    )
  }, [children_, animation, animationRef])

  return (
    <View
      ref={ref}
      style={[
        {
          backgroundColor: '#f3f3f3',
          height: '1.2em',
          position: 'relative',
          overflow: 'hidden',
        },
        variant === 'text' && {
          marginTop: 0,
          marginBottom: 0,
          transform: [{ scaleY: 0.6 }],
          borderRadius: 4,
          ...(Platform.OS === 'web'
            ? {
                height: 'auto',
              }
            : {
                flex: 1,
              }),
        },
        variant === 'rounded' && {
          borderRadius: 4,
        },
        variant === 'circular' && {
          borderRadius: 100,
        },
        // childrenがある場合はchildrenのサイズに合わせる
        hasChildren && {
          backfaceVisibility: 'hidden',
        },
        hasChildren &&
          width == null && {
            ...(Platform.OS === 'web'
              ? {
                  maxWidth: 'fit-content',
                }
              : {
                  flex: 1,
                }),
          },
        hasChildren &&
          height == null && {
            ...(Platform.OS === 'web'
              ? {
                  height: 'auto',
                }
              : {
                  flex: 1,
                }),
          },
        { width, height },
        style,
      ]}
    >
      {inner}
    </View>
  )
})

簡単にpropsをまとめると以下のようになります。

animation boolean 脈を打つようなアニメーション
variant text rectangular | rounded | circular | スケルトンの形
width number string | スケルトンの横幅
height number string | スケルトンの高さ
style StyleProps スケルトンのoverrideスタイル
children ReactNode 「childrenを持たせればそのchildrenのサイズでSkeletonを表現する」みたいに楽しようと思っていたが、そもそもchildrenが動的なコンポーネントだったため今回は運用できず…

たとえば以下のように定義すると

<Skeleton
  width={300}
  height={200}
  variant="recutangular"
/>

このようなスケルトンができます。

4. ローディングUIの実践

上記で紹介したSkeletonコンポーネントを使い、いくつか実践的なローディングUIをご紹介します。

Skeletonコンポーネントのコツさえ分かれば、あとはnode, styleを書くだけです。

タイトルとリスト

AutoReserveでは「おすすめのレストラン」セクションで使っています。

export const ListWithTitle: React.FC = () => {
  return (
    <View>
      <Skeleton
        variant="rectangular"
        style={{
          flexDirection: 'row',
          alignItems: 'center',
          justifyContent: 'space-between',
          width: 300,
          height: 27,
        }}
      />
      <ScrollView
        style={{
          marginTop: 0,
          marginHorizontal: -32,
          marginBottom: -16,
          paddingHorizontal: 16,
        }}
        horizontal
        showsHorizontalScrollIndicator={false}
        contentContainerStyle={{
          padding: 16,
          gap: 16,
        }}
      >
        {Array.from({ length: 3 }).map((_, index) => (
          <View key={index}>
            <Skeleton variant="rounded" width={232} height={236} />
          </View>
        ))}
      </ScrollView>
    </View>
  )
}

Facebook カード

AutoReserveでは使っていませんが、このようなスケルトンもすぐに実装することができます。

export const FacebookCard: React.FC = () => {
  return (
    <View style={{ width: 360, borderWidth: 0.5, borderColor: '#0000001f', borderRadius: 8 }}>
      <View style={{ flexDirection: 'row', gap: 8, marginBottom: 8, padding: 12 }}>
        <Skeleton
          variant="circular"
          width={40}
          height={40}
        />
        <View style={{ width: '100%', justifyContent: 'center' }}>
          <Skeleton
            variant="text"
            height={10}
            width="80%"
            style={{ marginBottom: 8 }}
          />
          <Skeleton
            variant="text"
            height={10}
            width="50%"
          />
        </View>
      </View>
      <Skeleton
        variant="rectangular"
        width="100%"
        height={200}
      />
      <View>
        <View style={{ width: '100%', padding: 12 }}>
          <Skeleton
            variant="text"
            height={10}
            width="80%"
            style={{ marginBottom: 8 }}
          />
          <Skeleton
            variant="text"
            height={10}
            width="50%"
          />
        </View>
      </View>
    </View>
  )
}

この例を応用してAutoReserveが持つ大きく4つのレイアウト、トップページ・国トップ・検索結果・レストランそれぞれのスケルトンを実装していきます。 基本的にSWRを使ってデータをfetchしており、isLoading中にスケルトンコンポーネントをレンダリングするような記述にしています。

const { data, isLoading } = useRestaurant({ slug })

if (isLoading) {
  return <SkeletonRestaurant />
}

// ...

5. ローディングUI実装の成果

スケルトンを使ったローディングUIの実装前後のCLSは以下のようになりました。

CLS改善後

改善前と比較してもいい感じになったということができるでしょう。

対応前に課題だった「上部の画像表示エリアのサイズがロード完了後と異なる」と「コンテンツの表示領域が確保されておらずロゴ等のフッターが表示されている」の両方を解消し、ロード中・後でコンテンツのズレがなくなりました。よく見るとロード中は「予約」や「ジャンル」の項目がありますが、ロード後はそれがありません。CLSの解説ページによると、ロードによってビューポート外に移動したコンテンツはズレとして認識されないケースがあるようです。

まだ課題は山盛りなのですがCLSに関してはほぼ0と言っても差し支え無し、25点満点を獲得できるようになりました…!

6. CLSを改善するために、UI変更の必要が発生しうる

レストランページがある問題を抱えていました。

ページ最上段にレストランの写真を表示しているのですが、全レストランのうち約半数は画像データを持たないレストランだったのです。CLSを意識する前は単純に「あれば表示、なければエリアをつぶす」ような仕様にしていましたが、ローディングスケルトンを表示するにあたり「画像がなければエリアをつぶす」ような実装を見直す必要がありました。そのエリア分コンテンツがずれてCLSが悪化してしまうためです。画像なしのレストランページは必然的にCLSが0.3となり不合格になってしまいます。

ローディングスケルトンを用意する時点で、その領域は何かしらを表示しなければなりません。当初「no-image」的な画像を提案したのですが、チーム内で意見が分かれました。AutoReserveのレストラン詳細ページでは、画像の有無はメインのAPIのロードが完了するまで判明しないため、ロード中にローディングSkeletonの表示自体を出し分けることはできません。今回の場合「no image」的な画像を表示する以外の選択肢がありませんでした。結果的に以下のようなデザインに落ち着き、レストランページ全てでCLSは満点の25点を獲得できるようになりました。

他のケースとして、ページ上部にバナー広告を表示するケースが挙げられます。

例えば「CPM x円以上の案件がある場合のみ表示」や「属性yのユーザーのみに表示」みたいなケースは改善が必要です。「ロード時にコンテンツの領域を確保しておく」と同時に「ロード後も確保した領域を維持する」必要があるので、「条件に合致しないから何も表示しない」ということはできません。

上記のケースの場合、「CPM x円以上の案件がない場合、自社広告を表示する」や「属性y以外のユーザーには別のコンテンツを用意する」といったフォールバックが必要です。

以上の観点から、CLS改善を進める上で開発者以外の協力が必要なケースが発生する可能性があります。

7. Core Web Vitalsの注意点

ほとんどのプロダクト担当者はSEO改善を目標にCLS改善に着手しているかと思われます。AutoReserveチームでは、Page Speed InsightsとCore Web Vitalsの数値を毎日取得し可視化しています。その中で分かったことが2つあります。

  1. スコアの反映は30日くらい見た方がよい

Core Web Vitals系のスコアは改善即反映というわけではなく、Googleがクロールするまで最大30日間のタイムラグが発生します。CLS改善をプロジェクトとして遂行し、Chrome UX ReportやSearch Consoleの数値で効果測定をする場合は注意が必要です。毎日数値の改善を追いたい場合はこちらの記事を参考にダッシュボードを作ってみてください!

  1. 1つでもスコアが悪いページ(レイアウト)がある場合、それに足を引っ張られる

Search ConsoleはURLの構造からある程度のレイアウトを自動でグルーピングします。

AutoReserveの場合はトップページ・国トップ・検索結果・レストランと大きく4つのレイアウトを持っています。2023年7月の段階で全てのレイアウトのCLS改善を完了し、上記で取り上げたno-imageのレストランページをどうしようかと考えていた最中でした。no-imageのレストランページは、AutoReserveに存在する全URLのうち半分にも満たないボリュームかつ、アクセスが比較的少ないページでした。しかしながら、Chrome UX ReportやSearch Consoleの「ウェブに関する主な指標」は思ったより改善されませんでした。なんでかなあと焦りながら、no-imageのレストラン対応を完了するとCLSの数値は一気に改善しました。確証はないのですが、経験則的に「アクセス数やページ数に関わらず、全てのページきちんと対応しないとCore Web Vitalsに反映されない」という仮説を持っています。

もし「思ったより良くならないなあ」と感じたら、取りこぼしがあるページの存在を疑った方が良いかもしれません。

結び

総括をすると以下のようになります。

  • CLSは2番目に大きい25点を持っているが、対策すれば必ず満点を取れる
  • 画像など動的に表示するコンテンツは、サイズ指定などをして表示領域を予約しておく必要がある
  • 動的コンテンツのローディングはスケルトンを使うのがCLS&UX的にベスト
  • 予約した表示領域は必ず何かを表示する必要がある
  • Core Web Vitalsの結果を良くするためには、全ページ・全レイアウト満遍なく対応する必要がある

次回はWeb高速化シリーズ最終回の予定です。Bundleサイズの削減を中心に、FCPやLCPなどスピード系指標を改善した話をしたいと思っています。

最後までご覧いただきありがとうございました!皆さんのプロダクトのCLSが完璧になることを願っています。

ハローではAutoReserveのページスピードを100点に近づけるために力を貸してくれるエンジニアを募集しています。

採用情報 - 株式会社ハロー