🕌

Astro+React on Vercelで簡単なフォームのABテストを実装する

に公開

株式会社MyVision フロントエンドエンジニアのsrkwです。
今回、Next.js製ホームページのフォーム部分を高速化するためにAstroを採用したので、事例紹介をしたいと思います。

大元の課題と解決方針

弊社のシステムのフロントエンド(以下FE)部分は基本全てNext.jsで構築されていますが、数年かけて機能が継ぎ足された結果、登録フォームの挙動がモッサリしてきてしまいました。
調査した結果、クライアントに返却されるバンドルサイズが大きいことがボトルネックの一つであることがわかったので、フォーム部分のみを別アプリケーションに切り出すことにしました。

別アプリケーションに切り出すにあたり、社外のツールを使った既存のフォームのABテストの仕組みについても内製化し、コントローラブルなシステム領域を広げることにも挑戦しました。

【宣伝】「コントローラブルなシステム領域を広げる」について

MyVision社では、一般的な企業では既存の外部ツールを使う状況でも、そこで得られるデータの収集と、データをもとにした徹底的な業務改善のために、内製化を選ぶケースが多々あります。
あらゆるツールを内製化してゆく都合上、内部のエンジニアが関われる開発案件の種類も非常に多岐にわたります。
徹底的な内製化で企業の成長に貢献したいエンジニアのご応募をお待ちしております!

求人一覧はこちらをご覧ください

これにあたり、FEフレームワークについては

  • バンドルサイズの小ささ
  • React対応
  • SSG/ISR対応

の3点を理由として、Astroを採用しました。

Astro

https://astro.build/

Astroとは、SSGデフォルト・軽量バンドル・高速ビルドを特徴とするフレームワークです。
Astroが何で、どう速くて、という記事は世の中にたくさんあるので、当記事では割愛します。

技術スタック

弊社のFEリポジトリの基本的な技術スタックは以下の通りです。

  • Next.js
  • React
  • ChakraUI
  • GraphQL
  • ApolloClient
  • zod
  • react-hook-form

今回の最優先の要件がパフォーマンス面であることを踏まえて、以下のような技術選定を行いました。

  • Next.js -> Astro
  • React
  • ChakraUI -> tailwind CSS
    • ChakraUIなどのランタイムCSSのオーバーヘッドコストを懸念
    • サンプルが一定流通していてLLMにとって書きやすく、ゼロランタイムCSSライブラリであるtailwind CSSを採用した
  • nanostores
    • Astroのアイランドアーキテクチャにおける、グローバルステートの共有・管理のために採用
    • 非常に軽量で、今回のユースケースを満たすには十分と判断
    • 公式Docでも推奨されている
  • zod -> 非採用
    • react-hook-formのフォームのスキーマ定義・リゾルバ定義に使っていたが、バンドルサイズが大きかったので非採用
  • apollo client -> 非採用
    • APIサーバーとの通信の抽象化に利用していたが、バンドルサイズが大きかったので非採用
  • react-hook-form
    • バンドルサイズが十分軽量であったため、フォームの状態管理用に継続採用

加えて、登録フォームのABテスト機構については Vercel のRoutingMiddleware と EdgeConfig を使って実現しました。

UIの実装方針

今回Astroを採用しているものの、Astroの各種APIに依存したコードを大量に残すとAstroについて詳しくないメンバーにとってのメンテコストが大きくなるので、ルーティング関連のコード以外は基本的にReactで実装しました。

Astro依存のコードは以下の要素にしか使いませんでした。

  • ルーティングファイル
    • astroはfile-based routingを採用しているため、 pages/ 配下に配置した .astro ファイルがURL構造を示す
    • 基本的には .astro ファイルの中ではSSG/ISRのためのフロントマターの記述と、Reactコンポーネントの呼び出しのみを行う
    src/pages/landing-pages/[id].astro
    ---
    // データ取得処理
    const { id } = Astro.params
    
    if (!id) {
      return Astro.rewrite('/404')
    }
    
    const lpFetchResult = await fetchGql(getLpQuery, { id })
    const lpData = lpFetchResult?.getLp
    
    if (!lpData) {
      return Astro.rewrite('/404')
    }
    
    // データ整形処理
    const lp = formatLp(lpData)
    ---
    
    // astroで定義されたmetaデータなどを提供する共通コンポーネント
    <CommonLayout title={lp.title}>
      // React製のLP表示用コンポーネント
      <LandingPage landingPage={ld} client:load />
    </CommonLayout>
    
  • クライアントルーティング処理
    • Astroの効率的なクライアントルーティングを利用するために、ページ遷移にはAstroのAPIを利用
    src/components/layout/CommonLayout.astro
    ---
    import { ClientRouter } from 'astro:transitions'
    
    ---
    <html lang="ja">
      <head>
        // Astro ClientRouterの読み込み
        // fallback="swap"でデフォルトの遷移アニメーションを無効化している
        <ClientRouter fallback="swap" />
    
    src/components/pages/form.tsx
    const onSubmit = useCallback(async (data) => {
      const response = await postForm(data)
      // Next.jsのrouter/replace 相当
      navigate(urls.thanks(), { history: 'replace' })
    
  • astro:assetsから提供される<Image>コンポーネント
    • WEB標準のImage描画時のベストプラクティスを利用するために採用

zodを剥がしてスキーマ・リゾルバを内製化

react-hook-formにおけるzodの役割は以下の2点だと認識しています。

  • スキーマの型定義の生成
  • バリデーション機能の提供

型定義とバリデーションを1つのオブジェクトで定義できるzodのメリットは大きいですが、今回はzodのバンドルサイズを削減してパフォーマンスを向上したく、zodの機能をpureTSで実装することにしました。

pureTSのスキーマ定義とresolverを使ってreact-hook-formのuseFormを呼び出す方法は以下の通りです。

// スキーマ定義
type FormSchema = {
  name: string
  age: number
}

// 名前のバリデーション
export const validateName = (value: string): FieldError | null => {
  if (!value || value.trim().length === 0) {
    return {
      type: 'required',
      message: 'お名前を入力してください',
    }
  }
  return null
}

// 年齢のバリデーション
export const validateAge = (value: string): FieldError | null => {
  if (!value || value.trim().length === 0) {
    return {
      type: 'required',
      message: '年齢を入力してください',
    }
  }
  return null
}

// リゾルバ定義
// useForm内部では、errorsが空ならvalidと判定される
// ref: https://github.com/react-hook-form/react-hook-form/blob/23c699a07aba2a5f9ad64aa7a0bb047273fc43e7/src/logic/createFormControl.ts#L190-L192
const resolver: Resolver<FormSchema> = async (values: FormSchema) => {
  const errors: FieldErrors<FormSchema> = {}

  const nameError = validateName(values.name)
  if (nameError) errors.name = nameError

  const ageError = validateAge(values.age)
  if (ageError) errors.age = ageError

  return Object.keys(errors).length === 0
    ? { values, errors: {} }
    : {
        values: {},
        errors,
      }
}

const form = useForm<FormSchema>({
  mode: 'onChange',
  resolver
})

フォームのスキーマがよほど複雑でない限り、割とシンプルな方法でzodの代替を内製することができます。
一方でスキーマのフィールドのバリデーション追加漏れなどは発生しうるので、この点はユニットテストなどで担保できるとよいでしょう。

ApolloClientを剥がして内製化

社内の他のFEリポジトリではApolloClient+graphql-codegenを使って通信処理の抽象化・型補完を行っているのですが、今回のプロジェクトではバンドルサイズ軽量化のためにApolloClientの採用を見送ったため、VanillaTS製のヘルパー関数とgraphql-codegenを使って、できるだけ開発コストを小さく保ちながらGraphQLサーバーと通信する仕組みを実装しました。

詳しい仕組みは少し長くなってしまったので、別記事に分けました。
気になる方はこちらをご覧ください。

https://zenn.dev/my_vision/articles/f32bbad26125bc

Vercel EdgeConfig/RoutingMiddlewareを使ってABテスト

Vercel上にデプロイしたAstroのビルド済みパスを使って、ABテストを実装します。
ABテストの振り分け内容自体は社内ツール経由でEdgeConfigに保存し、振り分けはRoutingMiddleware内で行うようにしました。

Vercel RoutingMiddleware: Vercel上のアプリケーションへのリクエストを補足できるミドルウェア

https://vercel.com/docs/routing-middleware

Vercel EdgeConfig: RoutingMiddlewareを含むEdgeランタイムから高速に参照されるKVS

https://vercel.com/docs/edge-config

ざっくりの構成図は以下の通りです。

前提として、図の右部にある通り、社内ツール経由で作成されたFormテーブルのレコードはDBに永続化され、EdgeMiddlewareからの参照用にEdgeConfigにキャッシュデータを格納しておきます。

例として、以下のようなデータがあると仮定します。

  • DB
    • { id: "id-a" }
    • { id: "id-b" }
  • EdgeConfig
    • { "splitTests": { "patterns": ["id-a", "id-b"] }

①永続化されたFormのレコードを元にフォームのページをSSG

Astro上で、永続化されたFormのレコードを元にフォームのページをSSGしておきます。
例として、このときビルドしたパスを /forms/id-a /forms/id-b とします。

②リクエストをRoutingMiddlewareで捕捉~⑤上書きされたレスポンスを返却

RoutingMiddlewareでリクエストを捕捉したいパスを、プロジェクトルートに配置された middleware.ts にconfigとして定義します。
リクエスト捕捉後、EdgeConfigに保存された情報{ "splitTests": { "patterns": ["id-a", "id-b"] }を使って返却するパスを決定し、レスポンスをrewriteして返す、という流れになります。

middleware.ts
import { get } from '@vercel/edge-config'
import { rewrite } from '@vercel/functions'

export const config = {
  // ②ここで指定したパスにマッチするパスへのリクエストをmiddlewareが捕捉する
  matcher: ['/form/split'],
}

const middleware = async (request: Request) => {
  const splitTestConfig = await get("splitTests")
  // ③要素をランダムに取得して、返却すべき要素を決定
  const splitTestResult = splitTestConfig.patterns[
    Math.floor(Math.random() * splitTestResult.patterns.length)
  ]

  // ④作成したパスを使ってレスポンスを上書き
  const response = rewrite(
    new URL('/forms/' + splitTestResult, request.url)
  )

  // ABテスト結果をcookieに含めて、同じクライアントには同じ結果を返したい場合は、
  // `response` オブジェクトのheadersに`Set-Cookie`をappendするなどをやるとよい

  // ⑤上書きされたレスポンスを返却
  return response
}

export default middleware

これにより、 /forms/split にアクセスされた場合、ユーザーから見るとURLはアクセスした /forms/split だが、返却される中身はABテスト状に出し分けられている、という状態になります。

まとめ

上記の取り組みを通して、Astro+Reactで構成された軽量なフォームの実装と、フォームのABテストの仕組みを構築することができました。
今後似たようなことをする方の参考になれば幸いです。

MyVision技術ブログ

Discussion