Knockout.jsとreCAPTCHAを連携させる


reCAPTCHAが認証が失敗したときに人かどうか確認する画像も表示しないことがあり、原因がよくわかっていなかったので調べてみた。
すると、JavaScriptのコンソールにエラーメッセージが出ていた。

Uncaught (in promise) timeout

とりあえずこれでググると、以下のissueがヒットした。

コメントの中に、react-google-recaptchaでも起きているという話があったので、ここでようやく「ははぁ、これはコンポーネントのレンダリングが終わる前にreCAPTCHAが何かしようとしておかしくなるのだな」とあたりが付いた。

そして、reCAPTCHAのページを見ると、自分で制御して明示的にレンダリングすることもできることがわかった。

ということで、それらを念頭に置いて直していくことにした。

knockout.jsでreCAPTCHAと連携するViewModelを定義する

カスタムバインディングを定義する

どうするのがいいのかな〜と悩みながら適当にググったら、参考になりそうなgistを見つけた。

これを参考に、knockout.jsのカスタムバインディングを作った。

そのgistのURLはこちら。
https://gist.github.com/patorash/ccfef86bb04c03f81ead161659554ccc

ko.bindingHandlers.recaptcha = {
    init: function(element, valueAccessor, allBindings, viewModel, bindingContext) {
        var propWriters = allBindings()['_ko_property_writers'];
        var value = valueAccessor();
        document.addEventListener('createCaptcha', function(event, theme) {
            var site_key = document.querySelector('.g-recaptcha').dataset.sitekey
            var callback = allBindings.get('recaptchaCallback') || function () {
                if (!value) {
                    if (ko.isObservable(value)) {
                        value = true
                    } else {
                        propWriters.recaptcha(true)
                    }
                }
            };
            window.widgetId = grecaptcha.render('recaptcha', {
                sitekey: site_key,
                theme: theme,
                callback: callback,
                'expired-callback': function() {
                    grecaptcha.reset(widgetId);
                    if (ko.isObservable(value)) {
                        value = false
                    } else {
                        propWriters.recaptcha(false)
                    }
                }
            })
        });
    }
};
ko.expressionRewriting._twoWayBindings['recaptcha'] = true;

これを使う。

Knockout componentの修正

まだCoffeeScriptを使っているのでCoffeeScript表記である。

2019-02-15 追記

componentLoadedメソッドでcreateCaptchaイベントを発火していますが、変数grecaptchaが存在する前(つまりreCAPTCHAのコードがロードし終わる前)に発火してエラーになるケースがあったので、setIntervalgrecaptchaができるまで待つようにしました。

# ko.bindingHandlers.recaptchaを使っています。
class InquiryForm
  constructor: ->
    @name = ''
    @email = ''
    @subject = ''
    @message = ''
    @recaptcha_verified = false
    ko.track(this)

  componentLoaded: ->
    timer_id = setInterval( ->
      if grecaptcha?
        clearInterval(timer_id)
        document.dispatchEvent(new Event('createCaptcha'))
    , 100)

  enableSubmit: ->
    !_.isBlank(@name) and
    !_.isBlank(@email) and
    !_.isBlank(@subject) and
    !_.isBlank(@message) and
    @recaptcha_verified

ko.components.register 'inquiry-form', {
  viewModel: -> new InquiryForm()
  template: { element: 'ko-normal-template' }
}

ちなみにko-normal-templateはコンポーネントタグの中をそのまま出すだけのやつ。

template id="ko-normal-template"
  /! ko template: { nodes: $componentTemplateNodes }
  /! /ko

HTML側の修正

自分がやってるのがRailsプロジェクトなのでslim表記、かつ、gem recaptchaを使っているので、そのヘルパーメソッドを使う。
reCAPTCHAのRECAPTCHA_SITE_KEY、RECAPTCHA_SECRET_KEYは.envに定義している。

inquiry-form
  form data-bind="recaptcha: recaptcha_verified, template: { afterRender: componentLoaded }"
    / 他のバインディングしている要素は省略
    = recaptcha_tags render: 'explicit'
    #recaptcha
    button type="submit" data-bind="enable: enableSubmit()"

肝はafterRender: componentLoadedで、コンポーネントのレンダリングが終わったのを確認後にcreateCaptchaイベントを発火している。
createCaptchaイベントはカスタムバインディングの方でイベントリスナーを追加しているから、そこでreCAPTCHAのrenderが呼ばれて、レンダリングが行われる。
reCAPTCHAにチェックが入って認証が成功すれば、InquiryForm@recaptcha_verifiedがtrueになるという仕組み。

今後の課題

afterRenderを使ってイベント発火を行わなければならないのが微妙にイケてないのだけれど、他にいい方法が思いつかなかった。なにかよい方法があれば教えていただきたい。