<-- mermaid -->

reCAPTCHA v3をReact Hooksで実装する

こんにちは。 NewsPicks Web Product Unit の芥川(@aku11i)です。
NewsPicks の新Webフロントエンド基盤でNext.js・TypeScriptを使用した開発を行なっています。

今回、プロジェクトに reCAPTCHA (v3) を導入しました。
読み込みタイミングなどパフォーマンスに気を遣った実装をすることができましたので紹介したいと思います!

実装方針

reCAPTCHA を導入するにあたり、次のような方針を立ててみました。

読み込みタイミングを制御する

ページの初期レンダリングのタイミングでは reCAPTCHA が不要なパターンもあります。
そのため必要なタイミングで非同期で読み込めるようにし、他のリソースへの影響を抑えるようにします。

外部ライブラリを使用しない

今回は自前で実装してもシンプルなコードを維持できる見込みがありましたので、他のライブラリは使用しない方向で進めてみます。

調べた結果を載せておくと、 React で reCAPTCHA を扱うライブラリとしては react-google-recaptcha-v3 などがありました。

React Hooks で実装する

基本処理はカスタムフックで実装し、コンポーネントからお手軽に呼び出せるようにします。

使い方

先に想定する使い方を記載します。
React コンポーネントから次のように呼び出したいです。

export const Component: VoidFunctionComponent = () => {
  const recaptcha = useReCaptcha();

  useMounted(() => {
    recaptcha.load();
  });

  const handleClick = () => {
    recaptcha.execute({ action: "click" }).then((token) => {
      // TODO token を用いた処理
    });
  };
};

useMounted はコンポーネントのマウント時に一度だけ呼び出されるコールバック関数を想定しています。
コンポーネントがマウントされたタイミングで reCAPTCHA の読み込みを開始します。(ここについては useReCaptcha の中に隠蔽されていても良いかも知れません。)

事前準備

reCAPTCHA の型定義として @types/grecaptcha をインストールします。

www.npmjs.com

npm install --save-dev @types/grecaptcha

こちらの型定義ですが、名前空間が ReCaptchaV2 となっています。
v3 非対応かと思いましたが、コミット履歴をみたところ v3 に対応する PR がマージされていたのでこのまま使用して大丈夫そうです。

実装

reCAPTCHA を使用するためのカスタムフックの全体コードを載せておきます。ファイル名は useReCaptcha.ts とします。
実装は TypeScript で行っています。

import { useCallback } from "react";

const ID = "google-recaptcha-v3"; // script タグのIDとして使用する任意の文字列
const SITE_KEY = ""; // reCAPTCHA の site key

export type UseReCaptcha = {
  /**
   * reCAPTCHA の読み込みを非同期で行う
   */
  load: () => void;

  /**
   * reCAPTCHA の読み込みが完了するのを待つ
   */
  ready: () => Promise<ReCaptchaV2.ReCaptcha>;

  /**
   * reCAPTCHA を実行する
   */
  execute: (action: ReCaptchaV2.Action) => Promise<string>;
};

export function useReCaptcha(): UseReCaptcha {
  const load = useCallback(() => {
    if (document.getElementById(ID)) {
      // 既に読み込みが開始されている場合は何もしない
      return;
    }

    const head = document.getElementsByTagName("head")[0];
    const script = document.createElement("script");
    script.async = true;
    script.type = "text/javascript";
    script.src = `https://www.google.com/recaptcha/api.js?render=${SITEKEY}`;
    script.id = ID;
    head.appendChild(script);
  }, []);

  const ready = useCallback(() => {
    return new Promise<ReCaptchaV2.ReCaptcha>((resolve) => {
      load();

      // reCAPTCHA がまだ読み込まれていない場合に ready 関数だけ事前に用意しておく
      // `window.___grecaptcha_cfg.fns` にコールバック関数を push しておくと reCAPTCHA が ready 状態になった時に呼び出してくれる
      // See: https://developers.google.com/recaptcha/docs/loading#loading_recaptcha_asynchronously
      if (typeof window.grecaptcha?.ready === "undefined") {
        // @ts-ignore
        window.grecaptcha = window.grecaptcha || {};
        window.grecaptcha.ready = (cb: () => void) => {
          // @ts-ignore
          window.___grecaptcha_cfg ??= {};
          // @ts-ignore
          window.___grecaptcha_cfg.fns ??= [];
          // @ts-ignore
          window.___grecaptcha_cfg.fns.push(cb);
        };
      }

      window.grecaptcha.ready(() => resolve(window.grecaptcha));
    });
  }, [load]);

  const execute = useCallback(
    async (action: ReCaptchaV2.Action): Promise<string> => {
      const grecaptcha = await ready();
      return grecaptcha.execute(SITEKEY, action);
    },
    [ready]
  );

  return { load, ready, execute };
}

解説

上記カスタムフックの解説を関数毎に行なっていきます。

読み込み処理(load)

load では script タグを動的に生成して head に追加しています。
これによって任意のタイミングで reCAPTCHA の読み込みを開始できます。

const head = document.getElementsByTagName("head")[0];
const script = document.createElement("script");
script.async = true;
script.type = "text/javascript";
script.src = `https://www.google.com/recaptcha/api.js?render=${SITEKEY}`;
script.id = ID;
head.appendChild(script);

reCAPTCHA の読み込みタイミングについては注意すべき点があります。
後半に記載しているのでご確認ください。

読み込みを待機する(ready)

ready では reCAPTCHA の準備が完了して execute を実行できるようになるまで Promise で待機しています。

先程の load を呼び忘れている場合、いつまで経っても Promise が解決されない可能性があります。
load は 2 回以上呼び出しても問題ないので、ここで念のため呼び出しを行なっています。

非同期問題とその対策

reCAPTCHA の準備が完了するのを待つために window.grecaptcha.ready を実行しますが、reCAPTCHA のスクリプトは非同期で読み込まれているため、読み込み完了前だと window.grecaptcha はまだ undefined です。
そのタイミングで window.grecaptcha.ready を呼び出そうとするとエラーになってしまいますので対策を行います。

reCAPTCHA にはこの非同期問題の回避手法が用意されています。
公式ドキュメントの「Loading reCAPTCHA asynchronously」が参考になります。

developers.google.com

window.grecaptcha.ready が存在しない場合は reCAPTCHA はまだ読み込まれていません。
この場合は window.___grecaptcha_cfg.fns にコールバック関数を push しておけば、reCAPTCHA が読み込まれた時にコールバックをまとめて解決してくれます。

先程の @types/grecaptcha に window[___grecaptcha_cfg] の型定義がないのでとりあえず @ts-ignore で誤魔化しています。
TODO としてモジュールの型を拡張して解決したいですね。

ちなみに、ドキュメントに記載されているサンプルコードをそのまま使うとバグります。(reCAPTCHA のサポートに報告したいけど窓口が分からず困っています)

action を実行する(execute)

execute で action を実行します。
実行できる状態になるまで ready で待機するようにしています。

これで各関数の解説は完了です。

reCAPTCHA の読み込みタイミングについて

今回の実装では reCAPTCHA の読み込みタイミングを制御できますが、あまり遅いタイミングでは読み込まないようにしましょう。
reCAPTCHA は読み込まれてから execute が実行されるまでのユーザーの行動をチェックして bot かどうかを判断します。
読み込みタイミングが遅いと判断材料が少なくなるため、例えばフォームの送信ボタンが押されてから読み込むようなことは推奨されていません。

// 悪い例です
const handleSubmit = async () => {
  await recaptcha.load();
  const token = await recaptcha.execute();
  // ...
};

このことについては公式ドキュメントにも記載があります

読み込みタイミングを工夫したい場合、例えばフォームの要素にフォーカスが当たった時やダイアログが開かれた時などのイベントをハンドリングするとページの初期表示から遅延させて読み込むことができます。

おわりに

今回はパフォーマンスを意識した reCAPTCHA の実装について紹介しました。
外部スクリプトの読み込みは Web サイトのパフォーマンスを落とす要因の一つです。
使用範囲が限られるものについては今回のように読み込みタイミングを制御することで改善を図ることができるかも知れません。

宣伝

NewsPicks ではユーザーに最適な体験を届けられるように日々開発を行なっています。
興味のあるエンジニアの方は是非選考にご応募ください!

apply.workable.com

Page top