Simple File Uploads with Backbone.js
Backbone.js is great for doing single page apps or to tidy up some messy javascript code. But there are a few things that are a bit tricky to do, like uploading files. Sure, some browsers support uploading files via XHR, but some others don’t (I’m looking at you IE). There are a few workarounds for this situation. Here’s a quick explanation on how I achieved file uploads using Backbone.js in a recent project.
Setup
We’ll be using the old iframe trick. It basically consists on posting a form to a hidden iframe and reading back the content once it loaded. You can read a bit more here or here.
Implementing this from scratch could be quite some work. Fortunately, there’s a jQuery plugin to save us some time and headaches.
About the plugin
It adds 2 options to the $.ajax
function: iframe
and files
.
$.ajax('/some/path', {
iframe: true,
files: $("form :file")
});
iframe
expects a boolean, set it to true when you wan’t to upload files.files
expects a jQuery object of one or many file fields.
What it will do is exactly the trick mentioned above. Will set up a new iframe, a new form with the target
attribute set to the name
of the iframe, and then just listen to the load
event on the iframe.
There are a couple of extra things good to know when using this plugin:
- It adds a
X-Requested-With=IFrame
value to the form. That’s useful in case you need to do any special processing in the backend. - If you’re sending along other data with the request, you must need to send it in JSON form, it does not accept an already serialized data string. That’s because it creates a new hidden input field inside the form for each value it submits.
A bit about Backbone
To understand what we’re going to do, we first need to know a few things about how Backbone works.
In this case, we’re interested in the options
argument of model.save
1.
Under the hood, save
calls sync
to communicate with your server, passing along the options
object (with a few modifications). We’re going to take advantage of that.
Submitting
To illustrate this quick example, imagine 2 simple parts of the app.
<form enctype="multipart/form-data">
<label>
File
<input name='file' type='file' />
</label>
<br>
<label>
Name
<input name='name' type='text' />
</label>
<br>
<input type="submit" value="Upload">
</form>
var FormView = Backbone.View.extends({
// some more code here
events: {
'submit form' : 'uploadFile'
},
uploadFile: function(event) {
var values = {};
if(event){ event.preventDefault(); }
_.each(this.$('form').serializeArray(), function(input){
values[ input.name ] = input.value;
})
this.model.save(values, { iframe: true,
files: this.$('form :file'),
data: values });
}
});
When the form is submitted we’re are going to serialize all form’s data, and save it to the model. And we’re gonna add a few extra options (iframe
, files
, and data
), that will get passed to the sync
method, and finally to the $.ajax
call.
As we saw before, iframe: true
means that the request is going to use the iframe transport. The files
option, is set to the form files fields. And finally, we need to specify data
values again. That should be the same data we want to persist on the model, but as explained above, the iframe transport methods needs it to be a JSON object.
Caveats
That’s already working! Quite easy, except for a few quirks we need to be aware of.
Always resolves as success
As the plugin listens to the load
event of an iframe, it can’t know the HTTP status code of the server response. That means, it can’t tell wether the response was a 2xx, 5xx, or any other thing. All calls will be triggering a done()
or success()
callback, never a fail()
one.
You should determine if it was OK or not by looking at the response payload. It’s wise to return a JSON with some message if the response wasn’t successful.
Unknown Response Data Types
Again, because it just listens to a load
event, it does not have access to the HTTP headers of the server response. This makes it harder to use the automatic content-type detection provided by jQuery.
Fortunately, in this case there’s a simpler workaround: Wrap the data in a <textarea>
element with a data-type
attribute specifying the content type.
<textarea data-type="application/json">
{"ok": true, "message": "Thanks so much"}
</textarea>
This is specially important when using IE. It will trigger a download alert if it can’t detect the content type.
Using it with Rails
I’ve used this solution with a Ruby on Rails app, and found a few more things worth noting.
Authenticity Token
If you’re using protect_from_forgery
on your controllers, there’s a good chance that you need an extra tweak to make this work. That is, passing the authenticity_token as a parameter too.
Just modify the uploadFile
function a bit:
uploadFile: function(event) {
var values = {};
var csrf_param = $('meta[name=csrf-param]').attr('content');
var csrf_token = $('meta[name=csrf-token]').attr('content');
var values_with_csrf;
if(event){ event.preventDefault(); }
_.each(this.$('form').serializeArray(), function(input){
values[ input.name ] = input.value;
})
values_with_csrf = _.extend({}, values)
values_with_csrf[csrf_param] = csrf_token
this.model.save(values, { iframe: true,
files: this.$('form :file'),
data: values_with_csrf });
}
It’s kinda ugly, but still better than to remove the CSRF-protection from your controller.
Middleware with textarea wrapping
And finally, to make it easy to wrap the response in a textarea when needed, there’s a nice middleware. It was created by the author of the jquery-fileupload-rails, which uses the iframe transport under the hood.
Go take a look at it. It’s quite handy!
Closing
Uploading files with Backbone via ajax while maintaining compatibility for older browsers can be a thing. Hope this helps somebody!
-
save
can receive 2 or 3 arguments: (attributes
,options
), or (key
,value
,options
). ↩