Knockout.jsとreCAPTCHAを連携させる

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

```javascript
Uncaught (in promise) timeout
```

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

https://github.com/google/recaptcha/issues/269

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

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

https://developers.google.com/recaptcha/docs/display#explicit_render

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

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

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

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

https://gist.github.com/bh3605/98d495bc05ee5428ed62893c0810449a

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

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

```javascript
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のコードがロードし終わる前）に発火してエラーになるケースがあったので、`setInterval`で`grecaptcha`ができるまで待つようにしました。

```coffeescript
# 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はコンポーネントタグの中をそのまま出すだけのやつ。

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

## HTML側の修正

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

```slim
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を使ってイベント発火を行わなければならないのが微妙にイケてないのだけれど、他にいい方法が思いつかなかった。なにかよい方法があれば教えていただきたい。