remotipartはjquery-railsの挙動を改変して、jquery.iframe-transport.jsを使ってファイルアップロードを実現しています。コードの流れが複雑なので整理してみました。
formのsubmitイベントハンドラの登録と処理内容
jquery-railsを読み込むとformのsubmitイベントにハンドラを登録します。(イベントタイプがsubmitではなくsubmit.railsとなっているのはイベントのネームスペースです。 http://api.jquery.com/on/#event-names 参照)
$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
// 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
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()はファイル未選択時は空文字列""で、ファイル選択時はパスが設定されます。
// Form file input elements
fileInputSelector: 'input[type=file]',
// 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
},
// 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)を実行しないようにしています。
$(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を使っています。
- csrfトークンを適宜設定。
- settings.dataからfileのデータを取り除く。JS内のコメントによると、こうしないとformのsubmitでfileのデータがサーバに送られないとのこと。
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])のドキュメントにsettings.beforeSendというパラメータについての説明があります。対応するソースは以下の箇所です。
https://github.com/jquery/jquery/blob/2.0.3/src/ajax.js#L514-L518
// 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()で発行しています。
// 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を返します。
// 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()を呼び出しているだけです。
// 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側ではリクエストを送信しないようにします。
// 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
teardown: function(form) {
form
.unbind('ajax:beforeSend.remotipart')
.removeData('remotipartSubmitted')
}
二重投稿防止のフォーム無効化
なお、20msの数字には意味があって、イベントハンドラをfalseで抜けた後、jquery_ujs.js側では13ms後にrails.enableFormElements(form)を呼び出してフォームを有効化してしまいます。なのでそれより後に$.rails.disableFormElements(form)を実行する必用がある訳です。
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イベントハンドラでフォームは有効に戻されます。
$document.delegate(rails.formSubmitSelector, 'ajax:complete.rails', function(event) {
if (this == event.target) rails.enableFormElements($(this));
});