Hello Tech

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

Next.jsからSPAに移行し、Next.jsに戻した話

グローバルなレストラン予約サービス、AutoReserveの開発をしているjavascripterです。

今回は、ハローでのautoreserve.comのアーキテクチャの変遷についてお話しします。

概要

AutoReserveは最初Next.jsで構築され、その後SPAに移行し、最終的に再びNext.jsに戻るという珍しい技術選択をしています。

この記事では、各アーキテクチャの移行の背景と、大規模アプリケーションの段階的移行の実践について解説します。

AutoReserveについて

AutoReserveは世界中のレストランの予約が可能なグローバルサービスです。iOS・Android・Webの3プラットフォームで展開しており、各アプリ100ページ近くある大規模なアプリケーションです。

モバイルアプリはReact Native (Expo)で開発しており、当初のWeb版はNext.jsで構築していました。その後、開発効率化のためSPAに移行し、最終的にNext.jsに戻すという変遷を辿っています。

React Router製のSPAへの移行

立ち上げ期のAutoreserveでは、専業のフロントエンドのエンジニアが自分一人であったため、開発効率の最大化が重要な課題でした。

初期はNext.js製アプリとして開発していたWeb版ですが、モバイルアプリとのコード共有を実現するため、早期にReact Router製のSPAアプリへと移行することを決断しました。以下に、この技術選択の背景と実装について説明します。

SPA移行の背景

Next.jsで開発をする上で、主に直面した問題は下記です。

  • プラットフォームごとにコードを書くことの開発・メンテナンスコスト
  • 既存ネイティブアプリはSSRに必要な対応(ユーザ間での状態の分離、CSSによるレスポンシブ対応)がされておらず、単純な書き換えが困難

採用した解決策

限られたリソースで3プラットフォームを同時開発するため、以下のような技術スタックとアーキテクチャを採用しました。

※ 詳細については過去の記事を参照ください。

  • React Router製のSPAアプリケーションへの移行
  • react-native-webを活用し、ネイティブアプリのコードを最大限再利用
  • SEO対策としてHeadless Chromeによるダイナミックレンダリングを採用

スケーリングの課題

SPAへの移行とReact Native Webの採用により、コードの大部分を共通化することができ、3プラットフォームの同時開発を行うことができるようになりました。

しかし、Web版が成長しユーザー獲得の主要チャネルとなるにつれ、以下の課題が顕在化しました:

  1. パフォーマンスの問題

    • Dynamic Renderingによる1秒以上のオーバーヘッドがある
    • react-native-webによってバンドルサイズが増大している
    • Googleのクローラーバジェット不足によるインデックスの限界がきている
  2. 技術的制約

    • レスポンシブ対応が困難
    • SSR非対応などWebとして最適なコードにできない

Next.jsへの再移行と、Webファーストへの進化

レストランメディアという性質上、AutoReserveではページ全体での検索トラフィックが成長の鍵となります。 Webプラットフォームが成長するにつれて、SEOの重要性が増している中、スケール上の課題に直面しました。

グローバル展開にともなって、「国 x その国のレストラン数 x 対応している言語数」のページが存在するため、クローラが回りきらなくなるという問題です。

膨大に存在するページをGoogleに認識してもらうためには、クロールバジェット(Googleが各サイトのクロールに割り当てる時間)内に、より多くのページをクロールしてもらう必要があります。 ページスピードを上げる必要がありますが、SPA + Dynamic Renderingでは技術的制約により高速化には限度があります。

そこで、開発効率の向上として採用したモバイルアプリとのコード共有という制約を外し、Webに最適化されたアーキテクチャへの移行を決断しました。以下に、この移行プロジェクトの詳細を説明します。

移行の目標

以下の3点を主要な目標として設定し、アーキテクチャの再設計を行いました。

  • SEO最適化とパフォーマンスの大幅な改善
  • レスポンシブ対応などの技術的負債・制約の解消
  • 今後のサービスのスケールに柔軟に対応できる基盤の整備

Next.jsの選択の理由

以前使用し辞めた経緯のあるNext.jsですが、既存コード資産の再利用という制約を除いた上で最適なフレームワークを検討した結果、再度採用することとしました。理由は下記です:

  • SEO最適化のデファクトスタンダードであり、実績がある
  • SSR/SSG/RSCなど多様な最適化手法の選択肢があり、より最適な設計が判明した時に、フレームワークが制約となる可能性が少ない

段階的移行戦略

100画面ほどある大規模アプリケーションの移行は簡単ではなく、技術基盤を段階的に移行するためのアプローチが必要でした。

開発を止めることなく、かつリスクを最小限に抑えながら移行を実現するため、以下のような戦略を採用しました。

1. Next.js内でのReact Routerアプリの統合

AutoReserveではmonorepoを採用しており、元々は下記の構造になっていました:

apps/
  web/         - 既存Web版アプリ
  ar_native/   - 既存React Nativeアプリ
packages/
  ar_app/      - 共通開発基盤

ウェブ、ネイティブそれぞれのアプリケーションから、機能開発を行う共通パッケージをimportして表示しているという構造です。

今回の再設計を行う上での主要アイディアはapps/webという独立したReact Routerのアプリケーションは、<App />をレンダリングする単一のコンポーネントを表示するライブラリとみなすことができる、という点です。

この観点に従って、apps/webはpackages/legacy_webに移動しました。

apps/
  ar_web/     - 新規Next.jsアプリ (catch allルートでpackages/legacy_webをレンダリング)
  native/     - 既存React Nativeアプリ
packages/
  ar_app/     - 共通基盤(apps/native, packages/legacy_webから使用)
  legacy_web/ - 既存SPAアプリ(apps/webから移動)

2. モーダルページの統合

厄介な点として、AutoReserveでは、ログイン・登録のモーダルなど、他のページの上に表示されるモーダルのページが多数存在します。

旧アプリのうち、モーダルとして表示されるページは、catch allのrouteではなく、Parallel RoutesとIntercepting Routesを使い、個別に定義したページからpackages/legacy_webを表示することで、新アプリのページの上から旧アプリのモーダルを表示できるようにしています。

これにより、新規ページ単体をロードしたときは、旧アプリは一切読み込まれることがないため、パフォーマンスへの悪影響を避けることができます。

参考:

app/
  restaurants/
    [slug]/
      page.tsx - 新ページ
  [[...path]]/
    page.tsx - 旧ページを(全画面で)表示するcatch allページ
  @modal/
    (.)sign_in/
      page.tsx - 旧ページをモーダルとして表示するためのページ

トップページ(Next.js App Dir)の上にログインモーダル(React Router)を同時に表示している例

まとめると、下記のようになります:

  • 新規ページ:Next.js App Routerをフルに活用し、最適化
  • 既存ページ:catch-all routeでlegacy_webをレンダリング
    • next/dynamicで動的な読み込みを行う
  • 新規ページと既存モーダルの共存
    • 新規ページの上に旧アプリのモーダルを動的に読み込む

ルーティングの技術的詳細

React RouterとNext.jsを同時に使用する上で、最も技術的な困難が伴ったのがルーティング周りの状態の共有・同期です。複数のルーティングライブラリが同時に混在することで、以下のような課題に直面しました。

1. クロスフレームワークの遷移

// Next.jsのページからReact Routerのページへの遷移
// catch-all routeにマッチするため自然に動作
import { useRouter } from 'next/navigation'
useRouter().push('/legacy_web_page')

// React RouterのページからNext.jsのページへの遷移
// React RouterのuseNavigateでは404エラーとなる
import { useNavigate } from 'react-router'
const navigate = useNavigate();
navigate('/new-nextjs-page'); // 404

2. 履歴管理の不整合

ブラウザバックでの遷移時、Next.jsは状態を更新するが、React Routerは独自に内部のhistory状態を持っているため、検知できないという問題がありました。

採用した解決策

クロスフレームワークの遷移

新規ページへの遷移はReact Router内でもNext.js側のuseRouter()を使用するように修正しています。

履歴管理の不整合

React Routerのhistory管理のライブラリhistoryをforkしコードを書き換え、Next.js側のページ遷移時に状態が同期されるようにパッチを当てることで解決しました。

具体的には、history.pushState, history.replaceStateなど、Next.js側のSPAでのページ遷移により呼び出される関数をmonkey patchし React Router側の内部のhistory状態を更新し再同期すると言う方法をとっています。

その他

遷移周りでは、React Router側のページ操作によりNext.js側のUIを更新することも必要です。

  • 旧アプリからのログイン後、Next.js側のページをログイン状態にする
  • モーダルで操作を行った後にモーダルを閉じたら、元のページのUIを更新する

このようなケースでは、React RouterのページがunmountされたタイミングでNext.jsのrouter.refresh()を呼び出すようにしています。

Next.js以外に採用した主要ライブラリ

余談にはなりますが、再設計にあたって技術選定も1から行ったため、採用に至ったライブラリも紹介します。

  • react-aria-components
    • Adobeで使用されているUIライブラリ
    • アプリケーション全体でのベースのコンポーネントとして採用
    • 再設計にあたってのアクセシビリティの改善も行いました
  • stylex
    • Facebook/Instagramで使用実績のあるCSS in JSライブラリ
    • Atomic CSSの使用によるパフォーマンス・型安全なコードによるメンテのしやすさから採用
    • Next.jsへの対応が不十分だったため、筆者がPostCSS Pluginを開発し、現在公式のプラグインとなっています
  • @internationalized/date
    • 日付操作を行うライブラリ
    • グローバルサービスでタイムゾーン対応が重要であるため、タイムゾーン対応が充実しているこのライブラリを採用

移行の成果

4ヶ月に及ぶ移行プロジェクトを経て、当初の目標を大きく上回る成果を得ることができました。特筆すべきは、既存機能を維持しながら、パフォーマンスとSEOの両面で大幅な改善を実現できた点です。以下に具体的な成果をまとめます。

技術面での改善

  • Next.jsとReact Routerのシームレスな統合によるスムーズなページ遷移の実現
  • レガシーコードの分離により、新規ページへ技術負債を持ち込まず、SEO上の悪影響をゼロにする
  • 単一のデプロイフロー維持による運用効率の向上

パフォーマンスの向上

  • Core Web VitalsのLCPが平均1.7倍高速化
  • キャッシュを切った状態でのクローラに対してのページロード時間が2.8倍ほど高速化

学んだこと

この移行プロジェクトを通じて、技術選択とアーキテクチャ移行に関する重要な教訓を得ることができました。

アーキテクチャ選択の本質

1. コンテキストの重要性
  • 技術選択に「正解」はなく、チームの規模やプロダクトのフェーズによって最適解は変化する
  • 立ち上げ期の効率重視から、スケールフェーズでの性能重視へと要件は変化する
2. 段階的アプローチの有効性
  • 大規模な移行は、一度にすべてを書き換えるのではなく、段階的に進めることが重要
  • 既存機能を維持しながら、パフォーマンスを改善できる移行戦略の設計が鍵
3. 技術的な挑戦の価値
  • コアライブラリのforkなど、一見ハイリスクな選択肢でも、十分な検証と明確な方針・技術力があれば有効
  • 「できない」と思われる制約も、視点を変えることでエレガントな解決策を見出せる

終わりに

ハローでは、プロダクトの技術的ニーズに向き合い、創造的な解決策を見出せる本物のエンジニアを募集しています。

少しでも気になる方は気軽に javascripter にDMでお声がけください!

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

Reactベストプラクティス2: SWRを正しく使うには

javascripter です。ハローでは、初期メンバーとしてプロダクトのローンチ前からAutoReserve の開発に関わっています。 前回の記事に引き続き、筆者が社内で書いている技術ガイドラインについて紹介します。

はじめに

ハローでは、高品質なコードを維持し、開発チームの技術レベル向上を図るため、チーム横断的に、有用な技術Tips、ベストプラクティス・コーディングガイドラインなど情報をNotion上に集約し、自由にエンジニアが閲覧・編集できるようになっています。

この取り組みの目的は以下の通りです:

  1. コード品質の向上と統一
  2. 開発チームメンバーの技術スキル向上
  3. 「どう」直すかでではなく「なぜ」そう修正すべきかまで理解してる人を増やす
  4. 効率的な開発プロセスの確立

前回の記事については、こちらを参照下さい。

Reactベストプラクティス: react-hooks/exhaustive-depsのエラーを0にする - Hello Tech

今回紹介するドキュメント

今回は、社内技術Tipsの中から、AutoReserveでのSWRライブラリの活用事例と、実装におけるベストプラクティスを紹介します。 AutoReserveは、Web版はReact製のアプリでiOS/Android版はReact Native製のアプリがあり、どちらでもSWRを使用しています。

SWRとは

SWRは、Vercel社が開発したReact用の非同期データ管理ライブラリです。主な特徴は以下の通りです:

  1. データのキャッシュ管理
  2. 自動的な再検証(revalidation)
  3. フォーカス時の再検証
  4. リアルタイムデータ更新

SWRを使用することで、データフェッチのロジックを簡素化し、キャッシュによってアプリケーションの応答性を向上させることができます。

SWRのキャッシュキー管理問題

SWRの強力な機能の一つが、データの自動再検証(revalidation)です。 SWRはkeyベースでキャッシュが管理されており、キーの管理は使用者側に委ねられています。

例えば、記事一覧と記事があり、記事に「いいね」することができたとします。

// data.tsx
export function usePost(postId: number) {
  return useSWR(`/posts/${postId}`)
}

export function usePosts() {
  return useSWR('/posts')
}

export async function likePost(postId: number) {
  return await apiClient.post(`/posts/${postId}/likes`)
}

// `post-show.tsx`
const handleLikeButtonClick = async () => {
  const { data, error } = await likePost(postId)
  if (error) return
  // 記事一覧、記事の両方で「いいね」が返ってきてる場合
  // mutateを呼び出し関連するデータをrevalidateする必要がある
  mutate(`posts/${postId}`, data, { revalidate: false })
  mutate('/posts')
}

上記の「いいね」ボタンのmutate部分の実装からわかる通り、SWRでは表示しているページ・各エンドポイント・操作による変更のデータ依存関係をエンジニアが把握し、適切にmutateを呼び出しデータの再検証を行う必要があります。

AutoReserveにおけるSWRのrevalidation戦略

AutoReserveでは、mutate時にキーを指定して細かい粒度でrevalidationを行うのではなく、富豪的プログラミング的に必要以上にデータが再度fetchされることを許容しています。

具体的には、以下の方針でrevalidation(再検証)を実装しています:

  1. 画面フォーカス・画面遷移時の自動revalidation:ユーザが新しい画面に移動した際に、表示されている全てのデータを自動的に更新します。フォーカス時も同様です。

  2. 同一画面内での手動mutate:画面遷移を伴わない操作(例:「いいね」ボタンの押下)の場合、必要なエンドポイントを手動でmutateします。

言い換えると、ブラウザリフレッシュ時に走るデータfetchと同等のものを、画面遷移・フォーカス時に必ず行う方針というと理解しやすいかもしれません。

アプリ・Web共通のSWRの設定コードのイメージ:

revalidateOnMount: undefined, // デフォルト値。freshなキャッシュがなければmount時にfetch
revalidateOnFocus: true, // フォーカス時にrevalidate
focusThrottleInterval: 0, // throttleせずfocus時に必ずrevalidateさせる

React Native側:

  • swr-react-nativeというライブラリを使用
    • React Navigationでの画面遷移のたび、フォーカスの当たった画面で使用している全てのhooksをrevalidate

細かいmutate操作を行わない理由

以下のメリットが得られるためです。

  • エンジニアが実装時に考慮すべきデータ依存関係が画面内に限定され、コードがシンプルになる
    • 今作っている機能、画面以外の場所でデータがどう使われているかについて一切考える必要がなくなります
      • 大きなプログラムを書く上で大事な特性で、英語でよくLocal Reasoningと呼ばれています
        • ※ Local Reasoning = アプリケーション全体のロジックを追わずに、該当部分のコードを読むだけで影響範囲・挙動が予測できること
  • UIの不整合が発生するリスクが大幅に低減される

富豪的revalidationで最初の「いいね」機能の実装はこのように単純化されます:

const router = useRouter()
const { data, mutate } = usePost(postId)

const handleButtonClick = async () => {
  const { data, error } = await likePost(postId);
  if (error) return
  // 「いいね」操作後、投稿データを再取得(画面内でのみ整合性を保つ)
  mutate(data, { revalidate: false })

  // 画面外のデータのrevalidationは不要:
  // - /postsへの画面遷移、全データが必ず裏でrevalidateされるため
};

典型的な「フォーム編集画面で保存 → 一覧画面への遷移」というパターンでは手動のmutate操作が一切不要になることがわかります。

負荷に関して

バックエンドのリクエスト数は上記の富豪的方針で多少増加しますが、十分許容可能なトレードオフだと考えています:

  • この方針で本番で3-4年程度運用していますが、サーバー負荷は現時点で特に大きな課題とはなっていません
  • 新規ユーザのほとんどは、キャッシュなしの状態でアクセスしてくるため、元々キャッシュが効く場面が限られていました
  • 例外的に重いエンドポイントに関しては、ケースバイケースで特別対応を行なっています(フォーカスによるrefetchを切るなど)
  • フロントエンド側に関しては、stale-while-revalidateのため、基本的にフェッチしすぎによる画面のチラつきは発生しません

SWRを使用する際のベストプラクティス

上記ではAutoReserveでの運用方針を書きましたが、このセクションではより一般的なSWRを使用する上でのベストプラクティスについて記述します。

SWRの根本的な仕組みとして、キャッシュが存在するため、古いデータが先に即時に返ってきた後に、新しいデータが返ってくる可能性があります。 またフォーカスのタイミングなど、任意のタイミングで何度もデータが自動で更新され、変更される可能性があります。 このため、Reactのコードを書くうえで、コードをリアクティブに書く必要がある点に注意する必要があります。

SWRの特性を理解し、適切に使用するために、以下の点に注意してください:

1. SWRから返ってきたデータをuseState()の初期値に入れない

フォームの初期値を動的な値に設定したいケース(プロフィール編集画面など)が該当します。

サーバーのデータに依存するフォームの初期値設定には、単純なuseStateの使用は避けましょう。 useState()の初期値は、初回レンダリング時に値がセットされるため、フォームの初期値がSWRのキャッシュの値で固定されてしまうからです。

React Hook Formを使用している場合は、valuesという動的な値に対応したプロパティを使用してください。

useState()を使用しているUIでは、useFormState()という、初期値が動的になっても対応可能なhooksを使用します。

// ✅ GOOD
import { useFormState } from 'react-hooks-use-form-state'

function ProductEditForm({ productId }: { productId: string }) {
  const { data: product } = useSWR(`/products/${productId}`)
  const [title, setTitle] = useFormState(product?.name ?? '')

  return (
    <View>
      <Text>Title</Text>
      <TextInput value={title} onChangeText={setTitle} />
      <Button>Save</Button>
    </View>
  )
}

useFormStateの実装

react-hooks-use-form-stateに載せていますが、記事中にも貼っておきます。

※ 社内での使用経緯からuseFormState()という名前になっていますが、フォームに限らず、動的な初期値を使用するstate全般で使用しているので、useDynamicState()という名前にする方が無難かもしれません。

useFormState.ts

import React from 'react'
type FormState<S> = { isChanged: false } | { isChanged: true, value: S }
type FormAction<S> = { type: 'SET_STATE', payload: S | ((prevState: S) => S) } | { type: 'RESET' }

const unwrap = <T, A extends unknown[]>(valueOrFunction: T | ((...args: A) => T), ...args: A) =>
  typeof valueOrFunction === 'function' ? (valueOrFunction as (...args: A) => T)(...args) : valueOrFunction

export function useFormState<S>(initialState: S | (() => S)): [S, React.Dispatch<React.SetStateAction<S>>, () => void] {
  const initialStateValue = unwrap(initialState)

  const [state, dispatch] = React.useReducer<React.Reducer<FormState<S>, FormAction<S>>>(
    (state, action) => {
      if (action.type === 'SET_STATE')
        return { isChanged: true, value: unwrap(action.payload, state.isChanged ? state.value : initialStateValue) }
      if (action.type === 'RESET') return { isChanged: false }
    },
    { isChanged: false }
  )

  const setState = React.useCallback(
    (value: React.SetStateAction<S>) => dispatch({ type: 'SET_STATE', payload: value }),
    [dispatch]
  )

  const reset = React.useCallback(() => dispatch({ type: 'RESET' }), [dispatch])

  return [state.isChanged ? state.value : initialStateValue, setState, reset]
}

2. ロード状態の表示にisValidatingを使用しない

isValidatingは、データの有無に関係なく、データのfetchが走るたびにtrueになります。 SWRを使用したコードでは、revalidationによってfetchが任意のタイミングで何度も走りうるということに注意してください。

例:

  • 画面にフォーカスが当たったことによる再検証
  • 別の箇所からのmutateの呼び出しによってデータの再フェッチが走る再検証
  • 別の箇所でのコンポーネントのmountによる再検証

必要以上にロード画面が表示されるとUI上ちらつきが発生するだけでなく、ブラウザバック時のスクロール時の挙動も壊れます。 ブラウザがスクロール位置を復元するためには、ブラウザバック時にキャッシュから値が同期的に読み込まれUI要素が即座に復元される必要があるからです。

isValidatingを使うと、画面のチラつきが生じるため好ましくない:

// ❌ BAD
function ProductDetailsScreen({ productId }: { productId: string }) {
  const { data: product, isValidating } = useSWR(`/products/${productId}`)
   // ❌ BAD:
   // フォーカスが当たったタイミングで、一度表示されていた画面が消えて
   // 再度ロード状態に移行し画面が不必要にちらつく
  if (isValidating) {
    return <Loading />
  }
  return <ProductDetails product={product} />
}

ローディング状態を出すにはdata !== undefinedを使用する:

// ✅ GOOD
function ProductDetailsScreen({ productId }: { productId: string }) {
  const { data: product } = useSWR(`/products/${productId}`)
   // ✅ GOOD: データがすでにある場合にはロード中にならない
  if (product != null) {
    // エラー発生時にもdataはundefinedになるため、
    // エラー時のUIを用意していない場合は代わりにLoadingのUIを出す
    return <Loading />
  }

  // ここでは必ずproductが存在する
  return <ProductDetails product={product} />
}

もしくは、SWRのisLoadingプロパティを使用することもできます:

function ProductDetailsScreen({ productId }: { productId: string }) {
  const { data: product, error, isLoading } = useSWR(`/products/${productId}`)
   // ✅ GOOD: データがすでにある場合にはロード中にならない
  if (isLoading) {
    return <Loading />
  }
  // ✅ GOOD: データフェッチ時にエンドポイントがエラーを返した時はisLoadingがfalseになる
  // このため、エラーを明示的にチェックしないとProductDetailsにundefinedが渡るので注意
  if (error != null) {
    return <Error />
  }

  // ✅ GOOD: エラー時の画面を用意していない場合は、下記のようにLoadingを代用すると良いでしょう
  // この場合でも、SWRのグローバルなonErrorコールバックによってAPIエラー時にはToastが出るので
  // エラーが生じたことはUIからわかるようになっています
  // if (isLoading || error != null) {
  //   return <Loading />
  // }

  // ここではproductが必ず存在する
  return <ProductDetails product={product} />

data !== undefinedisLoading の条件は、fetcherがエラーを返した時の扱いに差があることに注意してください。

3. onSuccessコールバックの使用を避ける

onSuccessというプロパティでリクエスト完了時にcallbackを呼ぶことができますが、上記と同様に、設計上リクエストが何度走っても正しくコードが動くようにする必要があるため、onSuccessを使ったコードは正しくないケースが多いです(参考記事)。

onSuccessを使った結果、意図せず複数回データが送られてしまっている例:

// ❌ BAD
function Screen() {
  const [keyword, setKeyword] = useState('')
  const { data } = useSWR(`/search/?q=${keyword)`, fetcher, {
    onSuccess: (data) => {
     // リクエストが実行されるたびに発生するので、フォーカスが当たるたびに何度も送られる
     // また、キャッシュによりフェッチが発生しなかった場合にも呼ばれない
      if (keyword === '') return
      sendAnalytics(keyword, data)
    }
  })
  return <.../>
}

下記のように、onSuccessを使わず、データフェッチのトリガーとなるevent handler側で先に処理する方が良いでしょう:

// ✅ GOOD
function Screen() {
  const [keyword, setKeyword] = useState('')
  const { data } = useSWR(`/search/?q=${keyword)`, fetcher)

  const onSearch = (value) => {
    setKeyword(value)
    if (value !== '') {
      sendAnalytics(value)
    }
  }

  return <.../>
}

また、処理にdataを使用する必要がある場合など、上記のように書けない場合については useEffectを使用し、発火条件となるデータの変更を宣言的に記述する方がより堅牢になるでしょう:

// ✅ GOOD
function Screen() {
  const [keyword, setKeyword] = useState('')
  const { data } = useSWR(`/search/?q=${keyword}`, fetcher)
  const [shouldSendAnalytics, setShouldSendAnalytics] = useState(false)

  const onSearch = (value) => {
    setKeyword(value)
    if (value !== '') {
      setShouldSendAnalytics(true)
    }
  }

  const onSendAnalytics = useEffectEvent(() => {
    sendAnalytics(keyword, data)
  })

  useEffect(() => {
    if (shouldSendAnalytics && data != null) {
      // キーワードを変更し、最初にデータが揃ったタイミングでanalyticsを送信する
      // 1. 同期的にキャッシュにデータがある場合は即時に送る
      // 2. キャッシュにデータがなければ、ロードが完了するまで待機
      // の2種類のケースに対応

      // NOTE: この例では、ロード完了前にキーワードが変化した場合は
      // 最終的にデータのロードが完了したページしか送信しない仕様
      onSendAnalytics()
      setShouldSendAnalytics(false)
    }
  }, [shouldSendAnalytics, data])

  return <SearchComponent onSearch={onSearch} results={data} />
}

まとめ

AutoReserveでは、画面遷移のタイミングでエンドポイントを全て再検証するという富豪的アプローチによってアプリケーションのロジックを単純に保っています。 このアプローチにより、UIの不整合が生じる可能性を大幅に減らし、開発効率とユーザー体験を同時に向上することができました。 また、SWRを使用する上で意識しておくべき注意点を紹介しました。

ハローでは、質の高いコードを高速に書くことに熱意のある、本物のエンジニアを募集しています。私たちと一緒に、世界規模のサービスを作りませんか?

少しでも興味を持たれた方は、ぜひjavascripterまでDMでご連絡ください。

https://hello.ai/recruit/engineer

Reactベストプラクティス: react-hooks/exhaustive-depsのエラーを0にする

javascripter です。ハローでは、プロダクトのローンチ前からAutoReserve の開発に関わっています。 今回は、筆者が社内で書いている技術ガイドラインについて紹介します。

はじめに

ハローでは、高品質なコードを維持し、開発チームの技術レベル向上を図るため、社内で継続的に技術Tipsやガイドラインの整備・蓄積を行っています。

チーム横断的に、有用な技術Tips、ベストプラクティス・コーディングガイドラインなど情報をNotion上に集約し、自由にエンジニアが閲覧・編集できるようになっています。

この取り組みの目的は以下の通りです:

  1. コード品質の向上と統一
  2. 開発チームメンバーの技術スキル向上
    • 「どう」直すかでではなく「なぜ」そう修正すべきかまで理解してる人を増やす
  3. 効率的な開発プロセスの確立
  4. 新メンバーのオンボーディング支援

今回紹介するドキュメント

今回は、その中から「react-hooks/exhaustive-deps」の使用に関するガイドラインについて紹介します。

このルールは、Reactによって使用が推奨されているESLintのルールで useEffectなどのHooksの依存配列に、その中で使用されているすべての変数を含めることを要求するルールです。

開発者には馴染みは深いと思いますが、不要なdepsをうまく消せず、// es-lint-ignore-next-lineを使用し妥協している プロダクトもあるのではないでしょうか?

ハロー内でもこのようなコードが一部存在していたので、どのように修正をすべきか・行なったか下記で説明します。

コードベースでreact-hooks/exhaustive-depsをオフにしてはいけない

既存コードで

// ❌ BAD
useEffect(() => {
  ... a
  ... b
  ... c

  // cを依存関係に入れない
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [a, b])

など、react-hooks/exhaustive-depsのルールをoffにしているコードが散見されます。

このルールはReactの最も基本的なルールの一つであり、オフにしないとコードを書けないケースはプロダクトコードを書くうえで、基本的に存在しません

ルールをoffにすると、将来のコードのリファクタリング時に問題が生じ、変更がしづらくなりバグが発生しやすくるので、オフにしないようにしましょう。

発生する問題:

  • stale closure問題
    • 変更時に含めるべき依存関係を入れ忘れ、値の変更が反映されない
    • 非同期で更新されるprops更新の順序が変わると壊れるなどのrace conditionの発生
    • ルールをオンにしている限り発生しない問題で、バグが発生した場合、特定のレンダリングの順序のみで不具合が発生することになり、原因の特定が非常に難しくなります。
  • Reactのバージョンアップ時などに困る問題
    • useEffectのdepsを空にしたからといって、effectが一度しか走らないという保証はありません。Strict Modeでは(開発環境で)二回走ります。Reactの基本的な前提として、何度余分にrenderしても壊れないコードを書く必要があります。
    • depsを空にして[]を指定して一度しか走らせないようにしているコードは堅牢でなく、render回数に依存したコードとなっておりReactのsemanticsに違反しています。
    • 関連して、一度しかeffectを走らせないためsubscribe後のcleanupの関数が不要に感じられたとしても、基本的に必ずcleanupは書く必要があります
      • 一度しか走らないことを意図したコードでも、開発中にReactのFast Refresh 時にComponentが再レンダリングされるケースが存在します。cleanupのコード を書かないとsubscribeなどの関数が多重に登録されて開発がしづらくなりま す。
    • 正しいdepsを使用しないと、パフォーマンスを大幅にあげる見込みのReact Compilerの有効化が難しくなります

どう書くべきか?

useEffectEventを使うパターン

https://react.dev/learn/separating-events-from-effects

にある通り、公式でuseEffectEventというAPIが提案されています。React 18の時点では使えないので、

[INTERNAL_PATH]/src/modules/useEffectEvent.ts 内にあるuserlandでの実装を使用してください。

実装コード:

import React from 'react'
export function useEffectEvent<T extends (...args: any[]) => any>(fn: T) {
  const ref = React.useRef<T | null>(null)
  React.useLayoutEffect(() => {
    ref.current = fn
  }, [fn])
  return React.useCallback((...args: Parameters<T>): ReturnType<T> => {
    const f = ref.current
    return f!(...args)
  }, [])
}

  1. 一部の値の変更のみに応じてeffectを走らせたい場合:
// ❌ BAD
function Component(props) {
  const pathname = usePathname()
  const searchParams = useSearchParams()
  useEffect(() => {
    sendAnalytics(pathname, searchParams)
     // pathnameの変更時のみ走らせたい
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [pathname])
}

lintのルールをoffにするのではなく、下記のように、useEffectEventを使いdepsを分離することができます。

// ✅ GOOD
function Component(props) {
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const onVisit = useEffectEvent((pathname) => { // depsに含めたい値は引数として受け取る
    // depsに含めたくないsearchParamsはuseEffectEvent内で自由に使える
    sendAnalytics(pathname, searchParams)
   })
  useEffect(() => {
    onVisit(pathname) // pathnameの変更に応じて発火させたいことを明確にするため、引数として渡す
   }, [pathname, onVisit])
   // onVisit自体はuseCallbackと違い安定しているのでdepsに含めても問題ない
}

もう一つ頻出のパターンとして、propsから渡ってくる値が安定していないがuseEffectの依存関係に含めないといけないケースがあります。このケースもuseEffectEventを使って書くことができます。

// ❌ BAD
function Component(props) {
  const { onUpdated } = props
  const pathname = usePathname() // ...
  useEffect(() => {
    onUpdated(pathname)
  }, [pathname, onUpdated])
}

上記の場合、onUpdatedがmemoizeされていない場合effectがrenderのたびに呼び出されて しまいます。

この場合もlintルールをoffにするのではなく、下記のように書きましょう。

// ✅ GOOD
function Component(props) {
  const { onUpdated } = props
  const pathname = usePathname() // ...
  const handleUpdated = useEffectEvent((pathname) => {
    onUpdated(pathname)
  })
  useEffect(() => {
    handleUpdated(pathname)
  }, [pathname, handleUpdated])
}
より細かく依存関係の変更を管理したい場合

上記のuseEffectEventで大半のケースはカバーできますが、より細かく制御する必要があるケースもあるでしょう。この場合も、usePrevious()というhooksを使うとlintルールをオフにせず制御できます。下記に例を挙げます。

  1. effect内でstate/propsを色々使っているが、実際にはその一部の依存関係の変更時のみeffectを走らせたい
// ❌ BAD
function Component(props) {
  const { a, params } = props
  useEffect(() => {
    fn(a, params)
     // aの変更時のみ走らせたい
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [a])
}

この場合、usePrevious()を使い、一つ前のrenderの値と比較することで明示的に依存関係をコード内で示すことができます。別の依存する値が増えた時にも壊れず、リファクタリング時にも不具合の発生を減らせます。

// ✅ GOOD
function Component(props) {
  const { a, params } = props
  const prevA = usePrevious(a)
  useEffect(() => {
    // effect自体は全てのdepsの変更で走るが、ifで監視したい条件を明示的にfilterできる
    if (a !== prevA) {
      fn(a, params)
    }
   }, [a, params])
}
// usePreviousの実装
import { useRef, useEffect } from 'react'
export function usePrevious<T>(value: T, initialValue?: T): T | undefined {
  const ref = useRef(initialValue)
  useEffect(() => {
    ref.current = value
  }, [value])
  return ref.current
}

複数の値の変更を監視したい場合は、変更時に発火させたい変数ごとにusePrevious()を用意し、if内でorを書きましょう。

今後の展望

この記事では、「react-hooks/exhaustive-deps」ルールに関するドキュメントを公開しましたが、社内では他にも以下のようなトピックについてのガイドラインを作成しています:

  • 「フロントエンドSWRライブラリの設計思想」
  • 「変数名・関数名の命名規則」

これらに関しても社外に役立ちそうなドキュメントは精査した上で追加で記事を出せると良いかなと考えています。

まとめ

ハローでは、技術ドキュメントの整備と共有を通じて、高品質なコード開発と効率的な開発プロセスの実現を目指しています。 今回紹介した「react-hooks/exhaustive-deps」ルールは、その一例に過ぎません。

ハローでは最新の技術知見やベストプラクティスを取り入れ、質の高いプロダクトを高速に開発したい本物のエンジニアを募集しています。 少しでも興味を持たれた方は、ぜひjavascripterまでDMでご連絡ください。

https://www.hello.ai/careers

Web高速化2 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点に近づけるために力を貸してくれるエンジニアを募集しています。

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

Web高速化1 メンバーを巻き込み、分析基盤を整える

2023年もあと少しです。スパッと区切りをつけるよりも2024年にバトンを渡すような過ごし方をしたいなと思い、この記事を書き始めました。

2023年はAutoReserveにとって飛躍の年でした。海外レストランの予約開始をはじめとする多くの機能をリリースし、プレスリリースも多く出し、ユーザー数の大台を突破しました。

また飲食店向けに提供しているAutoReserve for Restaurantも今年大きな成長を遂げました。今年だけで30以上もの飲食店さまにご協力いただき導入事例を掲載させていただきました。

しかしその代償もありました。多くの機能をリリースし大きな成長を遂げた筋肉痛として「ページスピードの遅さ」が露見しました。さまざまな機能をスピーディにリリースしながらページスピードを高い水準で維持するのは至難の業です。

夏頃にオーガニック流入を増やすためにSEO周りの改善を始めたところ、ページスピードの遅さが原因の一つであることに気がつきました。Core Web VitalsがSEOの評価に影響し始めたからです(2021年5月より)。

「Webのページスピードを上げよう!」というWeb界隈の流れは2017年くらいから始まったと記憶しています。

俳優 阿部寛のホームページや、デベロッパーフォーラムのdev.toというサイトがいかに速いかを説明するのにPage Speed Insightsが使われて注目されました。

当時はデベロッパーならではの話題であったのが、「何秒遅くなると離脱率がx倍悪化する」というユーザービリティ観点にシフトし、Core Web Vitalsの登場によりSEOに直結するまでになりました。

参考: ページの読み込み時間が 1 秒から 10 秒に増加すると、モバイル サイト訪問者が直帰する確率は123%増加します

www.thinkwithgoogle.com

今や「Webサイトの表示速度はプロダクトチーム、会社として見過ごすことができない指標」となりました。

そこでハローではWeb高速化プロジェクトが立ち上がり、約5ヶ月間集中してサイトを速くする施策をし続けました。

正直なところその道のりは決して順風満帆なものではなく、「もうやれることないよ〜」と何度も壁に当たり、スコアもうなぎ登りになったわけではありませんでした。

それでも、CLSの対策(後述)をしたことでローディングUIが大幅に改善し、サイトのスピードが改善されたことで操作のサクサク感も大幅に増したと自信をもって言えます。

今年1年「ハローとしてどのようにWebの高速化に取り組み、どのような成果が得られたか」を書いていきます。1つの記事でとても収まるものではないので、いくつかの記事に分けて書いていきたいと思います。

まず今回の記事では「チーム全体でページスピードを意識し続ける」ことを念頭に、AutoReserveのページスピードの過去と現在をチームメンバー全員が正しく把握できるデータ基盤を整えた話をします。

エンジニアだけでなくプロダクトオーナーやPM、SEO担当の方などプロダクトに関わる方皆さんに見ていただけたらなと思っております。

Page Speed Insights・Lighthouse・Core Web Vitalsの理解

Web高速化をチームとして追おう!となりいろいろ調べていくと、この3つのワードが出てくるかと思います。

それぞれ何を指しているかを理解すると、今後混乱が防げるかと思うので簡単に説明します。

Lighthouse

Webサイトのページを評価するためのツールです。LighthouseはChrome Extensionを通して使うことができ、Page SpeedだけでなくアクセシビリティやSEOなどの分析をすることができます。

Lighthouseの「パフォーマンス」とPage Speed Insightsのスコアはほぼイコールですが、実行環境が異なります。Lighthouseは実行するPC、Page Speed Insightsは一般的なユーザーの環境を再現しています。

Lighthouseの「パフォーマンス」とPage Speed Insightsのスコアがイコールであると思っていればokです。

Page Speed Insights

Page Speed Insightsは、Webサイトのページスピードを100点満点で表すもっともメジャーな環境です。

Page Speed Insightsには5つの指標FCP, SI, LCP, TBT, CLSがあり、それぞれに配点が振り分けられています。最初の表示がめちゃくちゃ早くても、ロード完了が遅ければそれだけ減点されますし、どれだけ早くてもCLSが最悪なら75点以上獲得できないようになっています。

PSIは現在バージョン10であり、FCP 10点、SI 10点、LCP 25点、TBT 30点、CLS 25点となっています。

各指標に閾値が設定されており、例えばFCP 500ms以下なら10点、1440msなら6点というようにスコアに応じて点数が決定します。

Lighthouse v10における重みづけ

それぞれの指標が何を表しているのかについては、Googleが記事を出しているのでそちらを参照してください。

Lighthouse performance scoring  |  Chrome for Developers

Page Speed Insightsの点数改善は、CLSのみ全く別のアプローチとなっており、それ以外の指標は「表示を早くする」ことに注力すれば改善します。

ドキュメントには「FCP を改善する方法」「LCPを改善する方法」というように、それぞれの指標に対するアプローチが記載されています。

実際に指標と睨めっこしてみると、FCP, SI, LCP, TBTはともに相関関係にあり、「FCPだけを改善する」というよりは「○◯した結果FCPとLCPに良い影響があった」というような経過がありました。よって「この指標を改善するために◯◯をします」というアプローチをするよりも、「改善できるところを洗い出しそれをやった結果、あの指標が改善しました」みたいな流れになるのが自然かと思いました。

次の記事では「CLSを満点にするためにやったこと」、その次の記事では「表示を速くするためにやったこと」をお話しします。

Core Web Vitals

Core Web Vitals は、ページの読み込みパフォーマンス、インタラクティブ性、視覚的安定性に関する実際のユーザー エクスペリエンスを測定する一連の指標です。検索結果でのランキングを上げ、全般的に優れたユーザー エクスペリエンスを提供できるよう、サイト所有者の皆様には、Core Web Vitals を改善することを強くおすすめします。Core Web Vitals は、その他のページ エクスペリエンス要素とともに、Google のコア ランキング システムがランキングを決定する際に考慮する要素です。(Core Web Vitals と Google 検索の検索結果について より)

Core Web VitalsはLCP、CLS、FID(2024年3月からINPになるので、今からやるならINPを意識したほうがよい)の3つの要素から構成されます。Page Speed Insightsの数値を改善することがイコールCore Web Vitalsの結果を改善することにつながると考えて良いです。

しかしながら両者には大きな違いがあります。それは「実際のユーザーの行動データを参照しているかどうか」です。

Core Web Vitalsは実際のユーザーの行動データをもとに算出されます。これはChrome UX Reportを参照すればよくわかります。Core Web Vitalsはユーザーのネットワーク環境・デバイスのスペック等様々な要因が絡みます。それに対しPage Speed Insightsは、実際のユーザー環境の中でもごく一般的な環境を想定した仮想的な環境のもと実行されます。

最近のPage Speed Insightsでは、上部にCore Web Vitalsの指標、その下にPage Speed Insightsの計測結果が表示されるようになりました。

チーム全体でページスピードを意識するために必要な認識

ハローで高速化プロジェクトを進めるにあたり、ロール問わず知っておいた方がよい知識がいくつかあると実感しました。

中でも大切なものをピックアップして残しておきます。

①スタートが30点以下の場合、点数を上げるのは容易なことではない

Lighthouse Scoring Calculator というものがあります。これを使うと、たとえば「FCP 2,145msのときFCPは何点になるのか?」といったことをシミュレーションすることができます。以下は各指標の簡易的な配点表です。

Percentage First Contentful Paint (10) Speed Index (10) Largest Contentful Paint (25) Total Blocking Time (30)
100% 1,000 1,000 1,000 0
90% 1,800 3,387 2,500 200
80% 2,145 4,074 2,938 292
70% 2,434 4,654 3,300 383
60% 2,712 5,215 3,645 483
50% 3,000 5,800 4,000 600
40% 3,319 6,451 4,389 746
30% 3,697 7,228 4,848 941
20% 4,196 8,258 5,447 1,235
10% 5,000 9,933 6,400 1,800
0% 6,000 12,000 8,000 3,000

Largest Contentful Paintの列を例にとると、1,000ms以下で100%の25点、3,300msで70%の17.5点を獲得できることが分かります。80%から40%の間は約350msで区切られていますが、40%未満になるとその間隔が広くなります。

8,000msより遅い場合は1点も獲得できません。高速化プロジェクト発足時、AutoReserveのLCPは20,000msでした。13,600msも短縮して6400msにしないと点数をもらえないことを意味しています。

0点から100点は決して等間隔ではなく、ざっくり以下のように道のりの辛さが分かれると思っています。

めちゃ大変: 0-30 or 40

頑張ればすぐに結果が出る: 40-80

大変: 80-100

私はこの現状を見て「点数を上げてみせます!」とはとても言えず、「今現在あまりにも遅いのでちょっとやそっとの改善では点数が動きません。点数ではなく秒数の改善を見てほしいです、そっちの方が健康的です」と正直に言いました。(ちなみにプロジェクト開始直後のAutoReserveは5~10点くらいでした)

もしページスピード改善プロジェクトを牽引することになったときは、現状把握を正しく行い、現実的な目標を立てることがとても大切です。一歩ずつ進め、ページスピードが着々と改善していくことをチーム全体で共有し実感していくことが、パフォーマンスを意識したプロダクト開発文化の醸成につながると思っています。

②Core Web Vitalsの改善はタイムラグがある

「Page Speed InsightsとCore Web Vitalsは実行環境が異なる」ということを上段で述べましたが、決定的な違いは結果の即時性にあります。

上段で述べたようにPage Speed Insightsはリアルタイムの計測ですが、Core Web Vitalsはユーザーの行動に基づいて集計されます。そのため結果の反映にラグがあります。

Core Web Vitalsの結果を見る方法は2通りあります。1. Chrome UX Report2. Google Search Consoleの2つです。

Chrome UX Reportは月次でのレポーティングのみなのでリアルタイムさは皆無、Google Search ConsoleはCore Web Vitalsに問題ありなURLの数と、グループごと(URLのパターンによって自動で分別される)の数値しか分かりません。

そのためページスピードの改善経過を正しく把握するには、Page Speed Insightsの結果を追い続けるのが一番です。

しかしながらSEOの担当者は、SEOに直接影響しているCore Web Vitalsの経過を知りたいはずです。Core Web Vitalsの経過を高頻度で把握するには、現状ではChrome UX Report APIを使うのが一番です。下記に記載したGoogle Apps ScriptのコードでChrome UX Report APIも扱っているので参考にしてみてください。

③UI変更の必要がありうる

これは特にCLSに関する話ですが、デザインの変更なしではCLSの改善が進まない場合があります。ということはデザイナーやPMを巻き込んだ改善になる可能性が高いです。

CLSについては https://web.dev/articles/cls?hl=ja を参照してください。

例えばファーストビュー(ロード時に一番最初に表示される領域)に、「ある特定のユーザーグループにだけ動的にバナーを出したい」場合です。CLSはロード開始時とロード終了時のUIのズレを数値化したものです。

CLSの改善方法は「動的コンテンツの表示領域をあらかじめ確保しておく」ことです。単純にその領域を空白にしておくだけでも良いですし、より洗練されたユーザー体験を提供ためにローディングスケルトンを用意するのも良いです(次の記事で詳しく触れます)。しかしこの例のような不確定要素には対応できません。

今回のケースで完全にCLSを改善するためには、「バナーそのものを取り除く」か「別のバナーを表示して、バナー領域が使われない可能性をなくす」対応が必要です。

AutoReserveのレストランページも同様の問題を抱えていました。レストランページの最上部にて、画像が存在する場合は表示するが、存在しない場合は表示しないという実装をしていました。これはCLSに大きな影響を与えます。

AutoReserveが持っているレストラン情報において画像があるレストランは約半数でした。画像があるページの方がリッチなページと捉えられることが多いため、画像部分にスケルトンを実装する方向にしました。画像ありページのCLSは死守しようというわけです。

このように、CLSを改善するためにはエンジニアだけでなくデザイナーやPMを巻き込んで進行することでお互い納得のいく選択をすることができると感じました。

④同一URLグループ内のページ全て改善しないとCore Web Vitalsに改善が見られない

上述のレストランページにおける画像スケルトン対応ですが、画像なしのページはCLSが悪いままなので、No Image画像を表示したいと思いました。

しかしこの時点では対応しませんでした。No Image画像を表示することがデザイン的に好ましくないという声が上がったためです。

CLS改善の経過を見守ると、Core Web Vitals上のCLSは思ったほど改善しませんでした。下記は月毎のCLSのOK割合を示したものです。緑色のGoodの割合が増えるほど良いCLSのページが存在することを示します。反映までのタイムラグがあるので気長に待っていましたが、約10%程度しか改善しませんでした。

Core Web VitalsはURLのグループごとで判定されます。今回改善を行ったのはレストランページなので、CLSがGoodとPoorのページが同一グループに分類されます。明確な理由は分かりませんでしたが、同一グループ内でGoodとPoorが混在する場合は悪い方に引っ張られるのかなと感じました。このままではあまり良くないのではないかと話し合い、No Image画像の表示を決定しました。

No Image画像の実装後明らかにCLSが改善したため、「同一URLグループ内のページ全て改善しないとCore Web Vitalsに改善が見られない」という仮説は一定の信憑性が得られたといってよいと思います。

Page Speed InsightsとCore Web Vitalsの数値を可視化する

ここからは実際に分析環境を作っていきます。データを集計するためのコードは下記に記載したので、基本的にぽちぽちしてれば出来上がるはずですのでぜひ試してみてください!

Page Speed Insights API・Google Spread Sheet・Google Apps Script・Looker Studio(旧Google Data Portal)の4つを使い、デイリーで重要ページのPage Speedを可視化していきます。

1. 事前準備

この可視化プロジェクトは無料で完結しますが、Page Speed Insights・Core Web VitalsのAPIを使うためにそれぞれのAPIキーが必要です。以下のリンクを参照し、APIキーを取得してください。

それぞれのAPIキーはGoogle Cloudプロジェクトに紐づくため、必要に応じてプロジェクトを作成する必要があります。クレジットカード等の登録は必要なく、無料で取得可能です。詳しくは下記のドキュメントを参照してください。

2. Page Speed insightsの結果をGoogle Spread Sheetに出力する

「Google Apps Scriptで、Page Speed Insights APIをfetchしその結果をSpread Sheetに入れる」という流れを作ります。基本的に以下をコピペすれば使うことができます。

// type定義が長くなったのでここでは割愛します。詳しくはリポジトリを参照してください。

const PAGE_SPEED_INSIGHTS_API_ROOT =
  'https://www.googleapis.com/pagespeedonline/v5/runPagespeed'
const API_KEY = 'YOUR_PAGESPEED_API_KEY' // https://developers.google.com/speed/docs/insights/v5/get-started?hl=ja#APIKey を参照

各シートのヘッダーを定義しておく
const INITIAL_COLUMN_NAMES = [
  '日付',
  'Performance',
  'FCP score',
  'SI score',
  'LCP score',
  'TBT score',
  'CLS score',
  'FCP time(ms)',
  'SI time(ms)',
  'LCP time(ms)',
  'TBT time(ms)',
  'CLS value',
]

const CORE_WEB_VITALS_API_ROOT = 'https://chromeuxreport.googleapis.com/v1/records:queryRecord' // https://developer.chrome.com/docs/crux/api?hl=ja#crux-api-keyを参照
const CORE_WEB_VITALS_API_KEY = 'YOUR_CORE_WEB_VITALS_API_KEY'

class SpreadSheet {
  private spreadsheet: GoogleAppsScript.Spreadsheet.Spreadsheet

  constructor() {
    this.spreadsheet = SpreadsheetApp.getActiveSpreadsheet()
  }

  get urlListSheet() {
    return this.spreadsheet.getSheetByName('URLs')
  }

  sheetByName(name: string) {
    const sheet = this.spreadsheet.getSheetByName(name)

    if (sheet != null) {
      return sheet
    }

    const newSheet = this.spreadsheet.insertSheet(name)

    // newSheetの一行目に初期値を入れる
    const range = newSheet.getRange(1, 1, 1, INITIAL_COLUMN_NAMES.length)
    range.setValues([INITIAL_COLUMN_NAMES])

    return newSheet
  }
}

const getTargetUrls = () => {
  const spreadsheet = new SpreadSheet()
  const sheet = spreadsheet.urlListSheet

  if (sheet == null) {
    return []
  }

  // シート「URLs」のA,B列の値を取得
  const range = sheet.getRange(1, 1, sheet.getLastRow(), 2)

  return range.getValues().map((v) => ({
    name: v[0] as string,
    url: v[1] as string,
  }))
}

const getPageSpeedResults3Times = (url: string): PageSpeedModel[] => {
  // UrlFetchAppのfetchAllを使い、3回fetchし、その平均値を取得する
  Logger.log(`3 times fetch ${url}`)

  const responses = UrlFetchApp.fetchAll([
    `${PAGE_SPEED_INSIGHTS_API_ROOT}?url=${url}&category=performance&strategy=mobile&key=${API_KEY}`,
    `${PAGE_SPEED_INSIGHTS_API_ROOT}?url=${url}&category=performance&strategy=mobile&key=${API_KEY}`,
    `${PAGE_SPEED_INSIGHTS_API_ROOT}?url=${url}&category=performance&strategy=mobile&key=${API_KEY}`,
  ])

  return responses.map((v) => JSON.parse(v.getContentText()) as PageSpeedModel)
}

const getPageSpeedResult = (url: string): PageSpeedModel => {
  Logger.log(`fetch once ${url}`)
  const response = UrlFetchApp.fetch(
    `${PAGE_SPEED_INSIGHTS_API_ROOT}?url=${url}&category=performance&strategy=mobile&key=${API_KEY}`,
    {}
  )

  const pagespeedResult = JSON.parse(
    response.getContentText()
  ) as PageSpeedModel

  return pagespeedResult
}

const getCoreWebVitals = () => {
  // Core Web Vitalsのレポートはドメインごとに生成されます。対象のwebsiteに書き換えてください
  const origin = 'https://autoreserve.com'

  Logger.log(`fetch ${origin} as core web vitals`)

  const spreadSheet = new SpreadSheet()
  const sheet = spreadSheet.sheetByName('Core Web Vitals')

  const body = {
    origin,
    "formFactor": "PHONE"
  }

  const options = {
    method: 'post' as any,
    contentType: 'application/json',
    payload: JSON.stringify(body)
  }

  // POST APIを実行
  const response = UrlFetchApp.fetch(`${CORE_WEB_VITALS_API_ROOT}?key=${CORE_WEB_VITALS_API_KEY}`, options)
  const corewebVitals = JSON.parse(response.getContentText()) as TCoreWebVitals

  const { metrics } = corewebVitals.record

  const date = new Date()
    .toLocaleDateString('ja-JP', {
      year: 'numeric',
      month: '2-digit',
      day: '2-digit',
    })
    .split('/')
    .join('/')

  Logger.log(metrics.first_contentful_paint)

  const values = {
    fcp: metrics.first_contentful_paint.percentiles.p75,
    fid: metrics.first_input_delay.percentiles.p75,
    inp: metrics.interaction_to_next_paint.percentiles.p75,
    lcp: metrics.largest_contentful_paint.percentiles.p75,
    cls: metrics.cumulative_layout_shift.percentiles.p75,
    ttfb: metrics.experimental_time_to_first_byte.percentiles.p75,
    fcpGreen: metrics.first_contentful_paint.histogram[0].density,
    fcpYellow: metrics.first_contentful_paint.histogram[1].density,
    fcpRed: metrics.first_contentful_paint.histogram[2].density,
    fidGreen: metrics.first_input_delay.histogram[0].density,
    fidYellow: metrics.first_input_delay.histogram[1].density,
    fidRed: metrics.first_input_delay.histogram[2].density,
    inpGreen: metrics.interaction_to_next_paint.histogram[0].density,
    inpYellow: metrics.interaction_to_next_paint.histogram[1].density,
    inpRed: metrics.interaction_to_next_paint.histogram[2].density,
    lcpGreen: metrics.largest_contentful_paint.histogram[0].density,
    lcpYellow: metrics.largest_contentful_paint.histogram[1].density,
    lcpRed: metrics.largest_contentful_paint.histogram[2].density,
    clsGreen: metrics.cumulative_layout_shift.histogram[0].density,
    clsYellow: metrics.cumulative_layout_shift.histogram[1].density,
    clsRed: metrics.cumulative_layout_shift.histogram[2].density,
    ttfbGreen: metrics.experimental_time_to_first_byte.histogram[0].density,
    ttfbYellow: metrics.experimental_time_to_first_byte.histogram[1].density,
    ttfbRed: metrics.experimental_time_to_first_byte.histogram[2].density,
  }

  // valuesをシート最後の行に追加
  sheet.appendRow(
    Object.values({
      date,
      ...values,
    })
  )
}

function batch() {
  const spreadSheet = new SpreadSheet()

  // 検査対象のURL一覧を取得
  const urlList = getTargetUrls()

  urlList.forEach((v, index) => {
    const sheet = spreadSheet.sheetByName(v.name)

    const isAutoReserveUrl = v.url.includes('autoreserve.com')

    const date = new Date()
      .toLocaleDateString('ja-JP', {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
      })
      .split('/')
      .join('/')

    let values: TValue

    // 私たちはベンチマークとするサイトも記載している。3回の平均値を取るのは、私たちのページのみ
    if (isAutoReserveUrl) {
      const pagespeedResults = getPageSpeedResults3Times(v.url)

      const vArray: TValue[] = []
      pagespeedResults.forEach((result) => {
        const { categories, audits } = result.lighthouseResult

        vArray.push({
          performance: (categories.performance.score * 100).toFixed(0),
          fcpScore: (
            audits['first-contentful-paint'].score *
            categories.performance.auditRefs.filter(
              (v) => v.id === 'first-contentful-paint'
            )[0].weight
          ).toFixed(1),
          siScore: (
            audits['speed-index'].score *
            categories.performance.auditRefs.filter(
              (v) => v.id === 'speed-index'
            )[0].weight
          ).toFixed(1),
          lcpScore: (
            audits['largest-contentful-paint'].score *
            categories.performance.auditRefs.filter(
              (v) => v.id === 'largest-contentful-paint'
            )[0].weight
          ).toFixed(1),
          tbtScore: (
            audits['total-blocking-time'].score *
            categories.performance.auditRefs.filter(
              (v) => v.id === 'total-blocking-time'
            )[0].weight
          ).toFixed(1),
          clsScore: (
            audits['cumulative-layout-shift'].score *
            categories.performance.auditRefs.filter(
              (v) => v.id === 'cumulative-layout-shift'
            )[0].weight
          ).toFixed(1),
          fcpTime: audits['first-contentful-paint'].numericValue.toFixed(1),
          siTime: audits['speed-index'].numericValue.toFixed(1),
          lcpTime: audits['largest-contentful-paint'].numericValue.toFixed(1),
          tbtTime: audits['total-blocking-time'].numericValue.toFixed(1),
          clsValue: audits['cumulative-layout-shift'].numericValue.toFixed(1),
        })
      })

      // vArrayのkeyそれぞれの平均値を計算。vArray.lengthで割る
      values = {
        performance: (
          vArray.reduce((acc, cur) => acc + parseInt(cur.performance), 0) /
          vArray.length
        ).toFixed(0),
        fcpScore: (
          vArray.reduce((acc, cur) => acc + parseFloat(cur.fcpScore), 0) /
          vArray.length
        ).toFixed(1),
        siScore: (
          vArray.reduce((acc, cur) => acc + parseFloat(cur.siScore), 0) /
          vArray.length
        ).toFixed(1),
        lcpScore: (
          vArray.reduce((acc, cur) => acc + parseFloat(cur.lcpScore), 0) /
          vArray.length
        ).toFixed(1),
        tbtScore: (
          vArray.reduce((acc, cur) => acc + parseFloat(cur.tbtScore), 0) /
          vArray.length
        ).toFixed(1),
        clsScore: (
          vArray.reduce((acc, cur) => acc + parseFloat(cur.clsScore), 0) /
          vArray.length
        ).toFixed(1),
        fcpTime: (
          vArray.reduce((acc, cur) => acc + parseFloat(cur.fcpTime), 0) /
          vArray.length
        ).toFixed(1),
        siTime: (
          vArray.reduce((acc, cur) => acc + parseFloat(cur.siTime), 0) /
          vArray.length
        ).toFixed(1),
        lcpTime: (
          vArray.reduce((acc, cur) => acc + parseFloat(cur.lcpTime), 0) /
          vArray.length
        ).toFixed(1),
        tbtTime: (
          vArray.reduce((acc, cur) => acc + parseFloat(cur.tbtTime), 0) /
          vArray.length
        ).toFixed(1),
        clsValue: (
          vArray.reduce((acc, cur) => acc + parseFloat(cur.clsValue), 0) /
          vArray.length
        ).toFixed(1),
      }
    } else {
      const pagespeedResult = getPageSpeedResult(v.url)

      const { categories, audits } = pagespeedResult.lighthouseResult

      values = {
        performance: (categories.performance.score * 100).toFixed(0),
        fcpScore: (
          audits['first-contentful-paint'].score *
          categories.performance.auditRefs.filter(
            (v) => v.id === 'first-contentful-paint'
          )[0].weight
        ).toFixed(1),
        siScore: (
          audits['speed-index'].score *
          categories.performance.auditRefs.filter(
            (v) => v.id === 'speed-index'
          )[0].weight
        ).toFixed(1),
        lcpScore: (
          audits['largest-contentful-paint'].score *
          categories.performance.auditRefs.filter(
            (v) => v.id === 'largest-contentful-paint'
          )[0].weight
        ).toFixed(1),
        tbtScore: (
          audits['total-blocking-time'].score *
          categories.performance.auditRefs.filter(
            (v) => v.id === 'total-blocking-time'
          )[0].weight
        ).toFixed(1),
        clsScore: (
          audits['cumulative-layout-shift'].score *
          categories.performance.auditRefs.filter(
            (v) => v.id === 'cumulative-layout-shift'
          )[0].weight
        ).toFixed(1),
        fcpTime: audits['first-contentful-paint'].numericValue.toFixed(1),
        siTime: audits['speed-index'].numericValue.toFixed(1),
        lcpTime: audits['largest-contentful-paint'].numericValue.toFixed(1),
        tbtTime: audits['total-blocking-time'].numericValue.toFixed(1),
        clsValue: audits['cumulative-layout-shift'].numericValue.toFixed(1),
      }

    }

    // valuesをシート最後の行に追加
    sheet.appendRow(
      Object.values({
        date,
        ...values,
      })
    )

    Logger.log(`${v.name} score ${values.performance}`)
    Logger.log(`Done: ${index + 1} / ${urlList.length}`)
  })
}

実装にあたり、Google Apps Scirptのオンラインエディタでコーディングするのはいろいろとやりづらいので、Clasp(TypeScriptをGoogle Apps Scriptにcompileするなどできる)を使って、TypeScriptの恩恵を受けながら実装しました。

Claspを使うとローカルのプロジェクトとGoogle Apps Script, Spread Sheetを紐づけたりできるので、ローカルからpushするだけでリリースが完了します。

Google Apps Scriptにはスケジューリング機能があるため(これがめちゃ便利)、毎日1時に実行するように設定してください。


以下READMEの抜粋です。

準備

npm install -g @google/clasp

Claspの準備

Spreadsheetを作成するGoogleアカウントを選択します

clasp login

このプロジェクトに紐づいたSheetを作成します

clasp create --type sheets

Install deps

yarn

反映

(tscしなくても勝手にトランスパイルしてくれます)

yarn push

データを集めたいURLをSpread Sheetに入力する

  • URLsという名前のシートを作成します
  • A列に名前、B列にURLを入力してください
TOP https://autoreserve.com/ja/
ホーム https://autoreserve.com/ja/jp
検索 https://autoreserve.com/ja/?keyword=%E6%96%B0%E5%AE%BF%E9%A7%85
レストラン詳細 https://autoreserve.com/ja/restaurants/ygJncRkni7pVoBoMWHXg

A列の名前でシートが作成され、そのシートにB列のURLの結果が挿入されるようになります。

Apps Scriptにスケジュールトリガーをつける

画面左のメニューから「トリガー」を選択、トリガー一覧画面を表示し「トリガーを追加」をクリック。

以下のように入力して保存してください。

  • 実行する関数 batch
  • 実行するデプロイを選択 Head
  • イベントのソースを選択 時間主導型
  • 時間ベースのトリガーのタイプを選択 日付ベースのタイマー
  • 時刻を選択 午前0時 ~ 1時

これでスケジュールの設定は完了です。

詳しくはリポジトリを参照してください。https://github.com/hello-ai/pagespeed_aggregator

3. Looker Studioでデータを可視化する

Google Spread Sheetに貯められたデータを可視化するために使います。無料で使うことができます。

下記に私たちが実際に作ったレポートテンプレートを公開しているのでぜひみてみてください(データはサンプルのものを使っています)。

https://lookerstudio.google.com/u/0/reporting/93ddda7c-74b7-4c7a-9024-dd4e2dd00a7a/page/Ud4WD

また、上記「2. Page Speed insightsの結果をGoogle Spread Sheetに出力する」を完了していれば、皆さんのデータをすぐに可視化することも可能です。簡単な手順をご説明します。

①サンプルレポートのPreviewを表示する

下記を開いてください

https://lookerstudio.google.com/u/0/reporting/93ddda7c-74b7-4c7a-9024-dd4e2dd00a7a/page/Ud4WD/preview

②「自分のデータを使用」をクリック

③データソースを自分のSpread Sheetに置き換える

実際に自分が使っているデータに置き換えてグラフやテーブルを表示することができます。私たちはAutoReserveの4ページを対象にしているので、その4ページ(A~Dにあたる)とCore Web Vitalsのシートを選択してデータを置換してください。

④「編集して共有」をクリック

すべてのデータソースを自分たちのものに切り替えたら「編集して共有」をクリックしてください。そうすると、皆さんのデータソースが適用された「Sample Page Speed Report」のレポートが作成されます!「Sample Page Speed Report のコピー」というレポート名になるはずなので任意の名前に変えてください。

これで皆さん専用のPage Speed Reportの完成です。

AutoReserveのチームではこのレポートを見ながら改善の経過を辿り、大きな変化があったところでどのような変更が加えられたかをチェックしています。

「コミットごとにPage Speed InsightsのAPIを叩いて可視化する」ようなことも考えたのですが、それほど頻繁な変化があるわけでもないので日時のレポートにとどめています。

ページスピードに大きな影響を及ぼすJavaScriptのBundle Sizeのチェックはコミットごとに行っています。こちらについては次の次の記事で詳しく触れていきます。


今回は「メンバーを巻き込み、分析基盤を整える」をテーマに、ページスピード周りの解説や、チームメンバー全員が知っておくべき周辺知識、ページスピードレポートの作成まで触れました。皆さんのプロダクトがよりよくより速いものになるための参考になれば幸いです。最後までご覧いただきましてありがとうございました。

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

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

ファイルベースのルーティングによるReact Native開発の未来

javascripter です。ハローでは、プロダクトのローンチ前からAutoReserve の開発に関わっています。

ハローでは、開発効率の最大化のため継続的に新しい技術を取り入れています。 今回は、AutoReserveのReact Native製アプリにExpo Routerという、Next.jsのファイルベースのルーティングに似たルーターのライブラリを導入した事例を紹介します。

作業時点でのExpo Routerの最新版stableがv1だったため、解説もv1についてになります。現在はExpo Routerはv2がリリースされているため、最新版では一部、記事での解説と異なる可能性があります。

AutoReserveアプリの技術スタック

AutoReserveのネイティブアプリはReact Nativeで書かれており、またウェブ版は、Reactで書かれています。 今回はReact Native製のアプリについて詳しく説明します。

AutoReserveで主に使っている技術は

  • React Native
  • Expo(React Nativeのライブラリやクラウド上のマネージドなサービスを提供するライブラリ・フレームワーク)
    • 高品質で継続的にメンテナンスされているReact Nativeのライブラリ集
    • EASというアプリのビルド、証明書の管理からOTAによるコードの即時配信が可能なマネージドサービス
    • Expo Prebuildというコードによるネイティブコードの自動生成の仕組み
      • AutoReserveではネイティブコードの生成・設定から変更、管理までを全てJSのコードで行なっています。
    • などがあります。
    • その他、AutoReserveではExpoのエコシステムを最大限活用し常に最新のReact Nativeのバージョンに追随できるようにしています。
  • react-navigation

です。

react-navigationとは

AutoReserveではルーティングや画面遷移はreact-navigationを使用しています。

react-navigationのルーティングの定義はReact Routerなどに似ており、Reactのコンポーネントを使用して各画面を定義するようになっています。

ルーティング定義の例:

const Stack = createNativeStackNavigator();

function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={HomeScreen} />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

export default App;

Expo Routerとはなにか

Expo RouterはExpoが新しく出したreact-navigationをベースにしたルーティングライブラリで、主な特徴としてルーティングの定義を全てファイルベースで行い、対応するURLが自動で定義されるということが挙げられます。

URLベースのルーティングを用いる利点としては、下記があります。

  • URLベースのルーティング定義のため各画面への遷移を文字列のみで表現できる
    • 画面の定義がディープリンクと1:1で対応します。
    • 全ての画面遷移をURLとして表現できるため、テストなども非常に書きやすいです。通常の遷移もURLベースで行われるため、Deep LinkがSingle Source Truthとして機能し、テストと実際の使用時の乖離も発生しません。
  • 画面に関連したレイアウトもファイルベースで静的に予測可能な形で定義されているため、画面単位での自動でBundle Splittingを行うことができる(Async Routesと呼ばれる機能)
  • 画面遷移を型安全に行える
    • 次期バージョンのExpo Router v2からはファイル構造を元にリンクに自動で型がつくようになります(Typed Routesと呼ばれる機能)。
      • 存在しない画面への遷移などを静的に型検査できるようになります。

Expo Routerの概要

ファイル構造の例を挙げると、

src/
  app/
    index.tsx
    (auth)
      (tabs)
        (home, account)/
          index.tsx
          account.tsx
          users
            [userId].tsx.tsx
          _layout.tsx
        _layout.tsx
    sign_in/
      index.tsx
    _layout.tsx

このような形になり、_layout.tsxファイルでそのディレクトリで使用するレイアウト定義(スタック、タブなど)を行い、account.tsxなどそれ以外のファイル名で画面定義と同時にルーティング定義を行うことができます。

また、(auth)などと括弧の付いたディレクトリは、ディレクトリ名をURLに露出させずレイアウト定義を行いたい場合に使用します。

各画面にはDeep Link用のURLが自動で付加され

/
/account
/users/:userId
/sign_in

といった遷移先が自動で定義されます。

Next.jsのファイルベースのルーティングとよく似ていますが、ネイティブアプリ特有の要件として、同時にレンダリングされる画面が複数あるということが挙げられます。例えば、スタックベースのルーティングでは、前の画面とpushした先の次の画面がスタックされ、閉じた時も前の画面の入力状態・スクロール位置を維持したまま元の画面に戻ることが可能になっています。

画面遷移で複数画面が同時にメモリ上に存在する例

Expo Routerを導入する大きなメリットとして、全ての画面に対してDeep Linkを自動で生成できるということが挙げられます。

例えば、素のReact Navigationではlinkingというオブジェクトを用いて、画面のルーティングと別にDeep Linkを定義する必要があります。

例:

const config = {
  screens: {
    Home: 'home',
    UserShow: 'users/:userId',
    SignIn: 'sign_in'
  },
};

const linking = {
  prefixes: ['https://example.com', 'example://'],
  config,
};

function App() {
  return (
    <NavigationContainer linking={linking} fallback={<Text>Loading...</Text>}>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={HomeScreen} />
        <Stack.Screen name="UserShow" component={UserShowScreen} />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

コンポーネントベースで定義したルーティングとは別にlinkingでそれぞれの画面に対応するURLを定義しないといけません。

この仕組みの問題点は下記のとおりです。

  • 画面を定義するたびにlinkingオブジェクトも同時に更新する必要があるため、メンテナンスが大変
  • 型安全にDeep Linkを定義できない
    • ルーティングの定義が動的になっているため、ディープリンクが正しい画面を指しているかなどを型的に安全に保証することが仕組み上難しい
  • スタックがネストした画面や、タブへの遷移の場合など、linkingオブジェクトが複雑
  • どのような遷移パターンで画面に到達することができるのかぱっと見で把握することが難しい

Expo Routerを用いると、ディレクトリを見るだけでどのような画面が定義されてるかわかるだけでなく、全ての画面にURLが割り振られるようになるため、Deep Linkの定義漏れがなくなります。

またWebと同様に、画面のパラメータをURLのパラメータとして付加することができます。各パラメータは文字列として渡ってくるため、Webアプリケーションとほとんど同様の仕組みになると思うとわかりやすいと思います。

const router = useRouter()
const params = useLocalSearchParams()

const userId = Numeber(params['userId'])

const onPressUserFollowing = (userId: number) => {
  router.push(`/users/${userId}/following`)
}

return <User userId={userId}>

AutoReserveへの導入

AutoReserveのコードベースはかなり大きく、画面は90個近くあります。 このため、Expo Routerの導入もかなり大規模になりました。

画面定義:

PR:

Expo Router導入のPR

移行作業

具体的に移行で必要になった作業は下記の通りです。

  • 各画面のURLの定義を行う
    • app/ディレクトリ以下に、アプリで使用している全ての画面定義のファイルを作成する。
      • 既存のDeep Linkの定義とのURLの互換性を保ったまま、Deep Linkとして定義されていなかった画面定義も全て行う。
  • レイアウトの定義を行う
    • スタックのレイアウト定義、タブの定義、モーダルの定義、各画面のタイトルの定義の移植などに加え、非認証時のログイン画面へのリダイレクトなどもURLベースへ書き換える。
      • (auth)以下のディレクトリ内で非認証状態であれば/sign_inにリダイレクトさせるなど。
  • 各画面への遷移の変更
    • navigationオブジェクトを用いて画面名を指定して遷移していた部分をURLベースに書き換える必要がある。
      • これが一番分量が多く大変。
navigation.navigate('UserShow', { userId })
// --> Expo Routerを導入後
router.push(`/users/${userId}`)

難しかった箇所

タブ内での画面遷移と、タブを非表示にして全画面で遷移する構造をExpo Routerで表現するのが特に難しかった箇所として挙げられます。

例えば、AutoReserveにはホームタブとアカウントタブなどがあり、ホームタブからレストラン詳細画面に遷移した場合は、タブは非表示になりレストラン詳細画面が全画面で表示されます。 一方で、アカウント詳細画面から予約一覧画面に遷移した場合など、タブが表示されたまま、タブ内で表示される画面があります。

タブ内の遷移

まず、一つ目の問題として、現在のタブ内で画面を開くことがExpo Routerでは簡単にできないという問題がありました。

/*
(tabs)/
  (home, account)/
    index.tsx
    account.tsx
    users/
      [userId].tsx
*/
// /(tabs)/(home)タブ内(ホーム画面)にいる場合:
// (tabs)/(home)/users/:userIdに遷移させたい
router.push(`/users/${userId}`)
// 一方、/(tabs)/(account)タブ内(アカウント画面)にいる場合、
// (tabs)/(account)/users/:userIdに遷移させたい
router.push(`/users/${userId}`)

AutoReserveではホーム画面内からも、各ユーザの画面に到達することができ、またアカウント画面からもユーザ画面にたどっていくことができます。

この場合、単純に/users/:userIdに遷移させると、Expo Routerでは最初にマッチしたルートに遷移する仕様なため、常にホームタブ内で開かれてしまいます。

/(tabs)/(home)/users/:userIdなど、括弧を含めたパスを指定することで指定したタブ内で開くことができますが、ユーザ画面はホーム画面、アカウント画面など複数のタブで再利用される画面のため、ハードコーディングすることができない、という問題がありました。

このため、AutoReserveでは、現在いるタブを元に自動でprefixをつけるという仕組みを導入しました。

function useRouter() {
  const segments = useSegments() // ['(tabs)', '(account)', 'account']など表示している画面のパスが配列として返ってくる
  const router = useExpoRouter()
  return {
    push: (href) => {
      // hrefにオブジェクトが渡ってくる場合などの対応は省略
      let prefix = ''
      // ホームタブ内にいる場合は自動でprefixをつける
      if (segments.include('(home)')) {
        prefix = '/(tabs)/(home)`
      } // ...その他のタブの場合
      router.push(`${prefix}/${href}`)
    },
    // ...
  }
}

タブ内全画面の遷移

元々、素のReact Navigationではタブを非表示にし全画面で表示する画面は、タブの外側に別のNavigatorとして定義していました。

function Top() {
  return <Stack.Navigator>
    {/* ... */}
  </Stack.Navigator>
}

function Tabs() {
  return <Tab.Navigator>
      {/* ... */}
  </Tab.Navigator>
}

function App() {
  return <Stack>
    <Stack.Screen component={Top} />
    <Stack.Screen component={Tabs} />
  </Stack>
}

同じ構造をexpo-routerで表現した場合:

(top)/
  restaurants/
    [restaurantId].tsx
(tabs)/
  (home,account)
    users/
      [userId].tsx

expo-routerを用いた場合も同様の構造として表現しようとしたのですが、この構造をexpo-routerで用いた場合、いくつか問題が発生しました。 まず、(top)以下と、/(tabs)/(home)/restaurants/:restaurantIdの両方に同時にルートを定義する方法がない点。(home, account)などとしてhome, accountの両方に同時にルートを定義することはできますが、階層の異なったルートに同時に定義する方法は見当たりませんでした。

このため、この構造で書く場合、top以下とtabs以下の両方に大量に画面の定義を重複して書かないといけないことになります。 また、もう一つ大きな問題として、パフォーマンスの問題がありました。

上記構造ではタブからtopに遷移して戻った場合などにStack全体の再レンダリングが走り、非常に重くなるという問題が発生しました。 Expo RouterのissueやDiscussionなども一通り検索し関連したissueなども見つけましたが、現状、解決されていないようです。 このため、AutoReserveでは別の方法を採用しました。具体的には、下記のように、全ての画面定義をタブ内に定義する構造にしました。

(tabs)/
  (home, account)/
    restaurants/
      [restaurantId].tsx
    users/
      [userId].tsx

全画面の画面表示は、下記の方法で実現しています。

// /(tabs)/_layout.tsx
export default TabsLayout() {
  const params = useGlobalSearchParams()
  return <Tabs
    tabBar={() => {
      // 画面のパラメータに____tabBarHiddenがついている場合はタブを隠す
      if (params.__tabBarHidden) return null
      return <CustomTabBar />
    }}
  />
}

// 画面
router.push({
  pathname: `/restaurants/${restaurantId}`,
  params: {
    __tabBarHidden: '1'
  }
})

実際には、全画面の画面表示から遷移する先は常に全画面として表示する必要があるため、アプリ内に定義したuseRouter()のラッパー内で、全画面の場合のパラメータを引き継ぐなどの処理を追加しています。

まとめと今後の展望

AutoReserveではExpo Routerを導入することで、アプリの遷移をNext.jsのようにファイルベースで定義し、全画面にディープリンクを自動で持たせることができるようになりました。 改修自体は合計300ファイル以上、+7035行 / -5448行にわたる変更で、コミット数も154と比較的大規模なリファクタリングとなりました。

今回の改修は機能開発を止めることなく、エンジニア1人で数週間かけて行いました。 今回のExpo Routerの導入の意図としては、開発上のメリットもありますが、プロダクト面での改善も目的としています。

AutoReserveはアプリ版とウェブ版があり、ウェブとReact Nativeアプリのコード共通化による同時展開 という記事で紹介したとおり、大部分の実装の共通化を行なっています。 アプリのDeep Linkのメンテナンスが大変・ウェブとアプリで同一の機能を実現できていないケースがあるなどの理由から、Universal Link(iOSなどで、ウェブ版にアクセスしたときに自動で対応する画面がアプリで開く機能)の導入をやめたという経緯があります。

Expo Routerを導入することで、アプリの実装がウェブに近づき、全ての画面にURL経由でアクセスできるようになります。これにより、Universal Linkの導入など、アプリ・ウェブでのシームレスな切り替えなどユーザ体験の向上が容易に実現できるようになります。

今後の展望としては

  • アプリとウェブのURLを完全に互換性を持たせ、実際にUniversal Linkを有効にする。
  • ウェブ版は現在React Routerを使用しているが、さらに実装の共通化を進めていき、ウェブ版とアプリ版での機能差をなくす といったことが挙げられます。

また、現時点ではまだ可能性の一つでしかありませんが、今後Expo Routerの機能が充実することによって、アプリとウェブ版を完全統合できるようになるかもしれないと考えています。 ハローでは、高速なプロダクト開発と開発体験の追求を愛する本物のエンジニアを募集しています。 少しでも気になる方は気軽に javascripter にDMでお声がけください!

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

Twilio Flex による多言語コールセンターシステム構築

uiu です。

株式会社ハローでは AutoReserve を運営していますが、サービスの裏側で飲食店やユーザーのサポートのためコールセンターを運用しています。直近では日本語だけでなく英語等多言語でのカスタマーサポートを提供しています。

昨年、カスタマーサポートが利用するコールシステムを、外部のIP電話サービス(MiiTel) から Twilio Flex を使った内製のコールセンターシステムに切り替えました。

Twilio Flex 製システムを運営し始めてから半年以上経つなかで、カスタマイズの知見などが見えてきたので、今回紹介しようと思います。

背景

カスタマーサポートではIP電話サービスを利用していましたが、当時以下のような課題がありました。

  • サポートの品質向上のため、ダッシュボードでの可視化や分析をしたいができることが限られていた
  • 社内CRMとの連携や自動化をしたいがカスタマイズ性や拡張性が限られていた
  • 将来的に、受電時等に日本語だけでなく英語など多言語に対応できるようにしたいが、そのための機能が不足していた

外部IP電話サービスの契約更新タイミングがあったため、そのタイミングでTwilio Flex を使って社内コールセンターシステムを構築することに踏み切りました。

Twilio Flex を選んだのは以下のような理由でした:

  • ダッシュボードでの分析・可視化など、サポート品質改善のために必要な機能がビルトインで揃っている
  • React でプログラマブルに拡張ができ、API連携することにより細かい部分までカスタマイズできる
  • サービス側ですでに Twilio を活用しており、サービスの挙動との連携が比較的簡単だった

Twilio Flex とは

Twilio Flex はコンタクトセンターシステムです。社内では音声での電話通話でのみ利用しています。

Webブラウザ上で使える架電や受電をするIP電話の機能に加えて、リアルタイムにメンバーの稼働状況を可視化するためのダッシュボード機能などが統合されています。

エンジニア目線での最大の特徴は、React を使ってダッシュボードを拡張できて、大部分の要素がプログラマブルにカスタマイズできる、という点です。

Twilio Flex は Programmable Voice, TaskRouter, Twilio Studio といった Twilio が提供している技術要素、API群から構成されており、ほぼすべてがAPI経由でアクセス可能になっています。

ほぼすべてがAPIの形で提供されていて、やろうと思えばどの要素もカスタマイズ可能なので、コールシステム界のAWSといって過言ではないと思います。

カスタマイズに使う React 環境はモダンな開発環境で、Webフロントエンドの現場で React を書いているエンジニアにとっては馴染みやすい開発環境だと思います。

たとえば、Flex Plugin は以下のようなディレクトリ構造の npm package になっており、Plugin 開発時に使う @twilio/flex-ui 等のライブラリも npm package として提供されています。

カスタマイズ

デフォルト設定だけでも Twilio Flex は十分使えるサービスですが、社内でカスタマイズをすることで使い勝手を大幅に改善することができます。

開発してカスタマイズしている内容について紹介します。

プラグインによるCRM連携

社内CRMを連携して Twilio Flex 内に表示するためにプラグインを開発しました。

以下のような機能を実装しています

  • 架電・受電時に、CRMビューに社内CRMを自動的に表示する
  • 通話時に dual channel で録音する
  • 着信時に呼び出し音を再生する
  • 通話時にサービスDBに通話ログを作成する

いくつかピックアップして詳しく説明します。

CRMを自動的に表示する

架電・受電時に作業内容の対応するCRMの画面を自動的に表示する機能を実装しています。

以前は、問い合わせや電話番号に応じてCRMを手で検索する作業が必要でしたが、自動的に表示されることでその手間を削減できるようになりました。

別ドメインで立てている管理画面を iframe で Flex 内に埋め込む仕組みですが、違和感少なく使えています。

左側が Flex のデフォルト画面で、右側がプラグインで表示している社内管理画面です。

着信時に呼び出し音を再生する

デフォルトでは着信時に呼び出し音が鳴らず、ブラウザの別タブで作業していると着信に気づかない可能性があります。

そのため、着信時にピロピロした音を再生するようにしています。以下記事で書かれている方法と同じですが、Flex plugin 内にJSで着信時に発生するイベントにイベントハンドラーを立てることで実装しています。

FlexプラグインTips(着信時に音を鳴らす) - Qiita

Studio Flow によるノーコード拡張

受電時の挙動をノーコードでも拡張できるのは大きなメリットです。

Studio Flow で様々な条件に応じた分岐をノーコードでポチポチするだけで実装できます。

営業時間外でのメッセージ読み上げ、受電時のSlack通知、言語別のルーティングなどを実装しています。

ドキュメントなどではコードを書いて実現されていることも、工夫すれば Studio Flow で簡単に実装できることもありました。

たとえば、営業時間外の判定では現在時刻の時間部分を取り出し、時差を計算した上で営業時刻の範囲と match するかどうか判定することで分岐を実装できます。

Slack の通知も HTTP Request ウィジェットを使い、incoming webhook URLを設定すればコードを書くことなく実装できます。

TaskRouter による多言語コールセンター化

TaskRouter を組み合わせると、電話をスキルに応じてメンバーに振り分け・ルーティングすることができます。

メンバーに言語スキル(例: 日本語 → ja, 英語 → en )を振り、呼び出し元の電話番号等の情報から対応言語を判断することで、自動的に多言語での対応が可能なコールセンターを実現しています。

TaskRouter の全体像はやや理解に時間がかかる面もありますが、以下の記事を参照すればわかるようにはなると思います:

Twilio Flexの始め方(TaskRouter基礎編) - Qiita

設定操作はAWSやGCPのコンソールをポチポチするような感覚に近いです。

開発 tips

ドキュメント

1点、最初困った点を挙げるなら、公式ドキュメントがわかりやすくはないという点です。

TaskRouter など Flex の構成要素を1つ1つ理解しないと全体像が掴めなかったり、Flex plugin を作る際の API の使い方が十分にドキュメント化されてなかったりして、最初の開発作業では戸惑うことが多かったです。

一般的な開発の際には推奨しませんが、Flex での開発の際には Twilioエバンジェリストの方などが書いている Qiita の記事を読むことをおすすめします。時間を大幅に節約できます。

qiita.com

その他

Twilio Flex はバージョンのアップデートを自分たちで管理できるようになっていますが、早く改善を取り込みたいため自動アップデートをONにしています。

開発時には Dev Phone が便利でした。ローカル開発環境のブラウザから電話をかけることができるので、検証・デバッグ時に便利です。電話料金も自前の携帯電話からかけるのと比べて安いです。

導入効果

当初サポートメンバーがスムーズに利用できるかどうか不安でしたが、大きな問題なく日々利用してもらえています。

ダッシュボードでの可視化・リアルタイムでの稼働状況可視化は特にメリットが大きかったです。これまでできなかった細やかな品質向上に取り組むことができていると感じています。サポートメンバーがリモートで働いている環境では特に大きく活用できると思います。

分析画面は以下のように見れます (公式ドキュメントから引用したスクリーンショット)

見ている指標

サポート品質担保・改善のため、見ている指標・メトリクスは多数ありますが、社内で活用している代表的な指標をピックアップすると以下になります:

  • Wrap up Time - 電話対応後のCRMでの処理時間の Agent(メンバー)ごとの平均時間
  • 架電数 - Agent ごとの架電数

たとえば、Wrap up Time が小さすぎる場合は作業が早いですがちゃんと登録作業を行えてない可能性があります。あくまで数字なので、実際の対応内容も合わせてチェックすることで適切なフィードバックができるようにしています。

その他のコールセンター指標やKPIに関しては以下の記事にも書いてあります:

21+ Call Center Metrics to Track | Twilio

まとめ

今回は Twilio Flex を活用して社内でコールセンターシステムを開発運用している話を紹介しました。

フルリモートでコールセンターを運用しているため、対応品質をチェックしたり、しっかり稼働しているかの確認をする上でレポートは必要不可欠な機能です。良い人が正しく評価されるためにも Flex を活用し、細かいデータが出せることは大変ありがたいと感じています。

Web サービスの表側から見えにくい部分だからこそ、サポートは重要だと考えています。今後も品質向上を技術で支援するために、カスタマイズや連携等の開発を進めていきたいと思っています。

また、導入や運用にあたっては Twilio や KDDI Web Communications の皆さんにサポートして頂きました。ありがとうございました。

ハローではサービスを良くするためにあらゆる技術を尽くすエンジニアを募集しています。

hello.ai