remotipartによるファイルアップロードのコードリーディング

remotipartはjquery-railsの挙動を改変して、jquery.iframe-transport.jsを使ってファイルアップロードを実現しています。コードの流れが複雑なので整理してみました。

## formのsubmitイベントハンドラの登録と処理内容
jquery-railsを読み込むとformのsubmitイベントにハンドラを登録します。（イベントタイプがsubmitではなくsubmit.railsとなっているのはイベントのネームスペースです。 http://api.jquery.com/on/#event-names 参照）

https://github.com/rails/jquery-rails/blob/v3.0.4/vendor/assets/javascripts/jquery_ujs.js#L331-L335

```js:jquery_ujs.js
    $document.delegate(rails.formSubmitSelector, 'submit.rails', function(e) {
      var form = $(this),
        remote = form.data('remote') !== undefined,
        blankRequiredInputs = rails.blankInputs(form, rails.requiredInputSelector),
        nonBlankFileInputs = rails.nonBlankInputs(form, rails.fileInputSelector);
```

対象のセレクタはformとなっています。
https://github.com/rails/jquery-rails/blob/v3.0.4/vendor/assets/javascripts/jquery_ujs.js#L33-L34

```js:jquery_ujs.js
    // Form elements bound by jquery-ujs
    formSubmitSelector: 'form',
```

data-remote属性が指定されていて且つinput[type=file]でファイルが選択されていた場合は'ajax:aborted:file'イベントのハンドラを実行します。
https://github.com/rails/jquery-rails/blob/v3.0.4/vendor/assets/javascripts/jquery_ujs.js#L344-L363

```js:jquery_ujs.js
      if (remote) {
        if (nonBlankFileInputs) {
          // slight timeout so that the submit button gets properly serialized
          // (make it easy for event handler to serialize form without disabled values)
          setTimeout(function(){ rails.disableFormElements(form); }, 13);
          var aborted = rails.fire(form, 'ajax:aborted:file', [nonBlankFileInputs]);

          // re-enable form elements if event bindings return false (canceling normal form submission)
          if (!aborted) { setTimeout(function(){ rails.enableFormElements(form); }, 13); }

          return aborted;
        }

        rails.handleRemote(form);
        return false;

      } else {
        // slight timeout so that the submit button gets properly serialized
        setTimeout(function(){ rails.disableFormElements(form); }, 13);
      }
```

## input[type=file]でファイルが選択されているかのチェック部分

$('input[type=file]')のそれぞれについて!$(this).val()がfalseであるかをチェックしています。$(this).val()はファイル未選択時は空文字列""で、ファイル選択時はパスが設定されます。

https://github.com/rails/jquery-rails/blob/v3.0.4/vendor/assets/javascripts/jquery_ujs.js#L48-L49

```js:jquery_ujs.js
    // Form file input elements
    fileInputSelector: 'input[type=file]',
```

https://github.com/rails/jquery-rails/blob/v3.0.4/vendor/assets/javascripts/jquery_ujs.js#L250-L253

```js:jquery_ujs.js
    // Helper function which checks for non-blank inputs in a form that match the specified CSS selector
    nonBlankInputs: function(form, specifiedSelector) {
      return rails.blankInputs(form, specifiedSelector, true); // true specifies nonBlank
    },
```

https://github.com/rails/jquery-rails/blob/v3.0.4/vendor/assets/javascripts/jquery_ujs.js#L227-L248

```js:jquery_ujs.js
    // Helper function which checks for blank inputs in a form that match the specified CSS selector
    blankInputs: function(form, specifiedSelector, nonBlank) {
      var inputs = $(), input, valueToCheck,
          selector = specifiedSelector || 'input,textarea',
          allInputs = form.find(selector);

      allInputs.each(function() {
        input = $(this);
        valueToCheck = input.is('input[type=checkbox],input[type=radio]') ? input.is(':checked') : input.val();
        // If nonBlank and valueToCheck are both truthy, or nonBlank and valueToCheck are both falsey
        if (!valueToCheck === !nonBlank) {

          // Don't count unchecked required radio if other radio with same name is checked
          if (input.is('input[type=radio]') && allInputs.filter('input[type=radio]:checked[name="' + input.attr('name') + '"]').length) {
            return true; // Skip to next input
          }

          inputs = inputs.add(input);
        }
      });
      return inputs.length ? inputs : false;
    },
```

## remotipartでのファイルアップロード処理

jquery_ujs.jsでformのsubmitイベント時に発行するajax:aborted:fileイベントのハンドラを登録しています。そこでremotipart独自のセットアップを行って、自前で$.rails.handleRemote(form)を実行し、falseを返すことでjquery_ujs.js側では$.rails.handleRemote(form)を実行しないようにしています。

https://github.com/JangoSteve/remotipart/blob/v1.2.1/vendor/assets/javascripts/jquery.remotipart.js#L77-L86

```js:jquery.remotipart.js
  $(document).on('ajax:aborted:file', 'form', function(){
    var form = $(this);

    remotipart.setup(form);

    // Manually call jquery-ujs remote call so that it can setup form and settings as usual,
    // and trigger the `ajax:beforeSend` callback to which remotipart binds functionality.
    $.rails.handleRemote(form);
    return false;
  });
```

remotipart.setup(form)ではajax:beforeSend.remotipartイベントハンドラを登録して、そこで今回のAJAX呼び出しの設定を適宜変更しています。

* AJAXではファイルをアップロードできないので、iframeを使うように設定を変更。
    * remotipartに同梱の[jquery.iframe-transport.js](https://github.com/JangoSteve/remotipart/blob/v1.2.1/vendor/assets/javascripts/jquery.iframe-transport.js)を使っています。
* csrfトークンを適宜設定。
* settings.dataからfileのデータを取り除く。JS内のコメントによると、こうしないとformのsubmitでfileのデータがサーバに送られないとのこと。

https://github.com/JangoSteve/remotipart/blob/v1.2.1/vendor/assets/javascripts/jquery.remotipart.js#L10-L68

```js:jquery.remotipart.js
    setup: function(form) {
      // Preserve form.data('ujs:submit-button') before it gets nulled by $.ajax.handleRemote
      var button = form.data('ujs:submit-button'),
          csrfParam = $('meta[name="csrf-param"]').attr('content'),
          csrfToken = $('meta[name="csrf-token"]').attr('content'),
          csrfInput = form.find('input[name="' + csrfParam + '"]').length;

      form
        // Allow setup part of $.rails.handleRemote to setup remote settings before canceling default remote handler
        // This is required in order to change the remote settings using the form details
        .one('ajax:beforeSend.remotipart', function(e, xhr, settings){
          // Delete the beforeSend bindings, since we're about to re-submit via ajaxSubmit with the beforeSubmit
          // hook that was just setup and triggered via the default `$.rails.handleRemote`
          // delete settings.beforeSend;
          delete settings.beforeSend;

          settings.iframe      = true;
          settings.files       = $($.rails.fileInputSelector, form);
          settings.data        = form.serializeArray();

          // Insert the name/value of the clicked submit button, if any
          if (button)
            settings.data.push(button);

          // jQuery 1.9 serializeArray() contains input:file entries
          // so exclude them from settings.data, otherwise files will not be sent
          settings.files.each(function(i, file){
            for (var j = settings.data.length - 1; j >= 0; j--)
              if (settings.data[j].name == file.name)
                settings.data.splice(j, 1);
          })

          settings.processData = false;

          // Modify some settings to integrate JS request with rails helpers and middleware
          if (settings.dataType === undefined) { settings.dataType = 'script *'; }
          settings.data.push({name: 'remotipart_submitted', value: true});
          if (csrfToken && csrfParam && !csrfInput) {
            settings.data.push({name: csrfParam, value: csrfToken});
          }

          // Allow remotipartSubmit to be cancelled if needed
          if ($.rails.fire(form, 'ajax:remotipartSubmit', [xhr, settings])) {
            // Second verse, same as the first
            $.rails.ajax(settings);
            setTimeout(function(){ $.rails.disableFormElements(form); }, 20);
          }

          //Run cleanup
          remotipart.teardown(form);

          // Cancel the jQuery UJS request
          return false;
        })

        // Keep track that we just set this particular form with Remotipart bindings
        // Note: The `true` value will get over-written with the `settings.dataType` from the `ajax:beforeSend` handler
        .data('remotipartSubmitted', true);
    },
```

## ajax:beforeSendイベントの流れ

[jQuery.ajax(\[settings\])のドキュメント](http://api.jquery.com/jQuery.ajax/)にsettings.beforeSendというパラメータについての説明があります。対応するソースは以下の箇所です。
https://github.com/jquery/jquery/blob/2.0.3/src/ajax.js#L514-L518

```js:ajax.js
		// Allow custom headers/mimetypes and early abort
		if ( s.beforeSend && ( s.beforeSend.call( callbackContext, jqXHR, s ) === false || state === 2 ) ) {
			// Abort if not done already and return
			return jqXHR.abort();
		}
```

handleRemote()の中で、options.beforeSendに関数を設定してrails.ajax(options)を呼び出します。beforeSendに設定した関数の中でajax:beforeSendイベントをrails.fire()で発行しています。

https://github.com/rails/jquery-rails/blob/v3.0.4/vendor/assets/javascripts/jquery_ujs.js#L82-L156

```js:jquery_ujs.js
    // Submits "remote" forms and links with ajax
    handleRemote: function(element) {
      var method, url, data, elCrossDomain, crossDomain, withCredentials, dataType, options;

      if (rails.fire(element, 'ajax:before')) {
        elCrossDomain = element.data('cross-domain');
        crossDomain = elCrossDomain === undefined ? null : elCrossDomain;
        withCredentials = element.data('with-credentials') || null;
        dataType = element.data('type') || ($.ajaxSettings && $.ajaxSettings.dataType);

        if (element.is('form')) {
          method = element.attr('method');
          url = element.attr('action');
          data = element.serializeArray();
          // memoized value from clicked submit button
          var button = element.data('ujs:submit-button');
          if (button) {
            data.push(button);
            element.data('ujs:submit-button', null);
          }
        } else if (element.is(rails.inputChangeSelector)) {
          method = element.data('method');
          url = element.data('url');
          data = element.serialize();
          if (element.data('params')) data = data + "&" + element.data('params');
        } else if (element.is(rails.buttonClickSelector)) {
          method = element.data('method') || 'get';
          url = element.data('url');
          data = element.serialize();
          if (element.data('params')) data = data + "&" + element.data('params');
        } else {
          method = element.data('method');
          url = rails.href(element);
          data = element.data('params') || null;
        }

        options = {
          type: method || 'GET', data: data, dataType: dataType,
          // stopping the "ajax:beforeSend" event will cancel the ajax request
          beforeSend: function(xhr, settings) {
            if (settings.dataType === undefined) {
              xhr.setRequestHeader('accept', '*/*;q=0.5, ' + settings.accepts.script);
            }
            return rails.fire(element, 'ajax:beforeSend', [xhr, settings]);
          },
          success: function(data, status, xhr) {
            element.trigger('ajax:success', [data, status, xhr]);
          },
          complete: function(xhr, status) {
            element.trigger('ajax:complete', [xhr, status]);
          },
          error: function(xhr, status, error) {
            element.trigger('ajax:error', [xhr, status, error]);
          },
          crossDomain: crossDomain
        };

        // There is no withCredentials for IE6-8 when
        // "Enable native XMLHTTP support" is disabled
        if (withCredentials) {
          options.xhrFields = {
            withCredentials: withCredentials
          };
        }

        // Only pass url to `ajax` options if not blank
        if (url) { options.url = url; }

        var jqxhr = rails.ajax(options);
        element.trigger('ajax:send', jqxhr);
        return jqxhr;
      } else {
        return false;
      }
    },
```

rails.fire()の実装は以下のようになっています。trigger()でハンドラを実行してevent.resultがfalseのときはfalseを返します。

https://github.com/rails/jquery-rails/blob/v3.0.4/vendor/assets/javascripts/jquery_ujs.js#L60-L65

```js:jquery_ujs.js
    // Triggers an event on an element and returns false if the event result is false
    fire: function(obj, name, data) {
      var event = $.Event(name);
      obj.trigger(event, data);
      return event.result !== false;
    },
```

rails.ajax()の実装は以下のとおりで、単に$.ajax()を呼び出しているだけです。

https://github.com/rails/jquery-rails/blob/v3.0.4/vendor/assets/javascripts/jquery_ujs.js#L72-L75

```js:jquery_ujs.js
    // Default ajax function, may be overridden with custom function in $.rails.ajax
    ajax: function(options) {
      return $.ajax(options);
    },
```

## イベントハンドラの呼び出しの流れ

イベントハンドラの呼び出しの流れを整理すると以下のようになります。

* formのsubmitイベントでjs:jquery_ujs.jsのイベントハンドラが実行されます。そこで、ajax:aborted:fileイベントを発行します。
* remotipartで登録したajax:aborted:fileイベントが実行され、そこでremotipart.setup(form)を呼び出してajax:beforeSend.remotipartイベントハンドラを登録します。
* remotipartで$.rails.handleRemote(form)を直接呼び出します。その際settings.beforeSendに関数を設定していて、その中でajax:beforeSendイベントをrails.fire()で発行します。
* すると、remotipart.setup(form)内で登録したajax:beforeSend.remotipartイベントハンドラが実行されます。その中で$.rails.ajax(settings);を呼び出してAJAX通信を行います。

## AJAX通信開始後の処理

ajax:beforeSend.remotipartイベントハンドラ内で$.rails.ajax(settings)を呼び出した後は、以下のようになります。

* 20ms後に$.rails.disableFormElements(form);でフォームを無効化して二重投稿を防止します。
* remotipart.teardown(form)を呼び出してクリーンアップを実行します。
* イベントハンドラからはfalseを返して、jquery_ujs側ではリクエストを送信しないようにします。

https://github.com/JangoSteve/remotipart/blob/v1.2.1/vendor/assets/javascripts/jquery.remotipart.js#L51-L63

```js:jquery.remotipart.js
          // Allow remotipartSubmit to be cancelled if needed
          if ($.rails.fire(form, 'ajax:remotipartSubmit', [xhr, settings])) {
            // Second verse, same as the first
            $.rails.ajax(settings);
            setTimeout(function(){ $.rails.disableFormElements(form); }, 20);
          }

          //Run cleanup
          remotipart.teardown(form);

          // Cancel the jQuery UJS request
          return false;
        })
```
remotipart.teardown()の実装は以下のようになっています。
https://github.com/JangoSteve/remotipart/blob/v1.2.1/vendor/assets/javascripts/jquery.remotipart.js#L70-L74

```js:jquery.remotipart.js
    teardown: function(form) {
      form
        .unbind('ajax:beforeSend.remotipart')
        .removeData('remotipartSubmitted')
    }
```

## 二重投稿防止のフォーム無効化

なお、20msの数字には意味があって、イベントハンドラをfalseで抜けた後、jquery_ujs.js側では13ms後にrails.enableFormElements(form)を呼び出してフォームを有効化してしまいます。なのでそれより後に$.rails.disableFormElements(form)を実行する必用がある訳です。

https://github.com/rails/jquery-rails/blob/v3.0.4/vendor/assets/javascripts/jquery_ujs.js#L349-L352

```js:jquery_ujs.js
          var aborted = rails.fire(form, 'ajax:aborted:file', [nonBlankFileInputs]);

          // re-enable form elements if event bindings return false (canceling normal form submission)
          if (!aborted) { setTimeout(function(){ rails.enableFormElements(form); }, 13); }
```

jquery_ujs.jsで登録したajax:completeイベントハンドラでフォームは有効に戻されます。

https://github.com/rails/jquery-rails/blob/v3.0.4/vendor/assets/javascripts/jquery_ujs.js#L382-L384

```js:jquery_ujs.js
    $document.delegate(rails.formSubmitSelector, 'ajax:complete.rails', function(event) {
      if (this == event.target) rails.enableFormElements($(this));
    });
```