📜 ⬆️ ⬇️

My latest file uploader

image
I am a web developer with non-core education and ~ 10 years of experience. I did everything for the web that could only come to my customers and, sometimes, to their superiors. I love this job. But still there are a few things that I do not smile at all. One of them is file uploader. From the very beginning - when it never occurred to anyone to make him an AJAX - and to this day - when he resizes images, loads files into several streams and much, much more - he remains for me one of the most disliked tasks. It seems like I managed to handle it. If interested - welcome under cat.
A little bit of coffeescript awaits you, quite a bit of complaints about jQuery, a brief description of $ .Deferred, one to the place and an inadvertently applied pattern not to the place and a reference to one funny and interesting book.


So, the task before us is to stick a file uploader on the site. Of course, AJAX, of course, with progress and of course into an already finished design. For starters, why is this a problem? Cross-browser. Good old techniques (a-la submit forms in the iframe) will not give us progress, and the new ones (xhr.send) will not work in older browsers. Appeared was the hope of standardizing browsers died suddenly , so the problem of cross-browser will not die. Additional task - Drag'n'Drop. There is still trouble with the appearance of input'a.
What options do we have?
Path number 1, Google-driven development

Shustrenko google a fairly popular jQuery library that implements all the required functions. The approach is correct with a lack of time. Extremely quickly embedded in the project, she is worried about the compatibility with browsers and even displays some kind of formatted DOM. After all, there are no problems with DOM reworking styles (or even using jQuery, this is his main specialization, right?), Quietly convincing designers to move a little (“I can't do that to you, go to hell!”), Even agree with interested persons about the functionality ("Well, yes, we did not do it. But then look what kind of thing happened to you from the side. After all, is it really super?"). And everything seems to be super, and everything seems to be great. During the day you can cope and give in testing. But then ... The first swallow will fly in from the designers - some diva came out (someone interrupted the download, or created another, more exotic situation). And you have not seen this div, and therefore the styles on it, to put it mildly, are not exactly those. We digest the deserved reproaches of designers, we observe the arrival of a delegation of testers. It turns out that in some wonderful browser, in which, because of corporate standards, as many as XX percent of our users (and the most valuable ones!) Do not see any progress! Horror. You boldly send testers to hell (“Well, how can I do this if the browser does not support ???”) and with a triumphant view you miss the hook to the jaw: “So, VKontakte upload with progress works with this browser!”. And at this very moment you are committing one of the most terrible crimes against the project - you climb into the source code of this wonderful plugin. (As an option, find the last year's message about this error in the plugin's tracker and, if you're lucky, any crutch plugging this particular problem). In fact, this is not so scary for you as for a programmer. You will learn to read someone else's code - sometimes good, sometimes ... different. Debugger once again popolzuytes. I in no case want to say anything bad about jQuery plugin developers. Just in most cases I have encountered, their code is not designed for the support of a stranger. But the timing of the project can begin to burn. Remember, we chose this option precisely because of lack of time?
And also - if you plug in the plugin, be sure to leave only the minified version of the plugin in the project repository. And in no case do not leave links to the site of the plugin anywhere.
Summary: unfamiliar plug-ins are used only if the design and functionality can be forgotten. Well, or for revenge / training of designers and other testers. Or as a practice of reverse engineering skills.

Path number 2, historical

Ha! Half a year ago we wrote something like that. For the Dutch (Greeks, Australians, Persians ...). There was still a green oval progress bar. We open the old project (another computer, a burned-down repository, and another 100,500 reasons why it is not so easy). Saw uploader, smoke sorts. And there ... First of all - support only the latest browsers. Secondly, flash-resize on the client side. Thirdly, this is the most memorable oval (designers - bastards!) Progress is hard-coded into the uploader code itself. Fourth, fifth, etc. Plus a lot of specifics of that project. From the point of view of pleasure - also not ice. Instead of flying thoughts to the highlands of modern technology - roll back six months ago.
Summary: not interesting. Even if you were a guru six months ago and gave out only a high-quality code, now you are better off anyway.
')
Path number 3, self-confident

So let's try to dream a little. What do we want? (besides beer, neighbor and cat). At a minimum, we want to do an uploader. As a maximum, we want to do it once and for a long time. To make it easy to take from the current project and stick in the next with minimal modification. The desire, by the way, is inspired by one most amusing little book - Design Patterns by Eric and Elizabeth Freeman. No, this is not the one, the Original Book of the Gang of Four, Which Everyone Should Read. This other one is much easier. Not so strong, but also easier to read. One of the first principles that its authors are trying to drive into the reader's head is to encapsulate what changes. In our case, the most variable aspect of the architecture should be the method of sending data to the server (not only, but more on that later). If you remember, we discussed this problem before the jQuery plugin. So, if we make any pool of mechanisms for sending data to the server (if you prefer more OOP wording - let there be a set of classes with a common interface) and define the interface of interaction with the uploader - one big problem is divided into several smaller ones. And to solve them is more pleasant. What does this look like? Very simple:

Uploader = senders: [xhrFile, formDataFile, formDataForm, iframe] send: (options)-> stream = false $.each @senders, (i, func)=> if stream = func(options) false stream 


In 2 words - we iterate over the array of functions (javascript is the same!), We slip each options (mostly because we don’t know what we’ll actually slip) options and, the first one that returned a non-empty result is used for sending. (As an option, the first one did not generate an exception, it would be more correct but lazy for the time being.) At the same time, the most “valuable” functions are first in the array, and the last one is faultless (the same submit-form-to-iframe method). Yes, we already guess that the functions will be returned to us by deferred - then in the interface part of the uploader we will hang all the interested listeners (and we will not pass them to options). A little messy described $ .Deferred under the spoiler after examples of functions.
Now a couple of lines about the quality of the code in this article:
- the code is tied to jQuery not because the author believes that any project will use it anyway, sooner or later, but because of a certain amount of “amenities”, including Deferred, which we will use very tightly
- Partially code taken from one great plugin . If to be honest, the idea was born from reading this plug-in and sobs: “Well, why, why logic is not separated from the implementation?”
- at the moment the code is rather poorly tested, it is just an illustration of the architecture.

Now examples of functions:

  iframe = (options)-> return false unless options.input && options.input.value id = 'frame' + Math.random() $form = $ '<form>', method: options.method, enctype:'multipart/form-data', target: id, action: options.url $('<iframe>', name: id ).appendTo($form).on 'load', -> try response = @contents() throw new Error unless response.length && response[0].firstChild dfd.resolve response, name: options.input.value catch e dfd.reject response, name: options.input.value $form.hide().appendTo('body').submit() (dfd = $.Deferred()).promise() 


This is our very reliable function for older browsers. In short - we create the form, we aim at the iframe, submit it. We return deferred, which is cut off or edited by loading the target iframe. The arguments passed to the deferred will be received by the listeners hung outside. If my description of deferred is not completely clear - welcome under the spoiler after the next function, although it is better, of course, to read something, for example, post on habr and documentation .

  formDataFile = (options)-> return false unless options.files && options.files.length && window.FormData $.when.apply $, options.files.map (f)-> formData = new FormData() formData.append options.name, f xhr = new XMLHttpRequest() dfd = $.Deferred() xhr.onload = xhr.onerror = ->dfd[@status == 200 && 'resolve' || 'reject'] @response, file if xhr.upload xhr.upload.onprogress = (e)->dfd.notify e, file xhr.open options.method, options.url, true xhr.responseType = 'text' xhr.send formData dfd.promise() 


There will be more interesting. For each of the specified files (where options.files came from and what’s in it - just below) we create a separate XMLHttpRequest and deferred. Deferred we, as in the first example, reject or rezolvim for downloading data from the server. Unlike the fail-safe method, besides resolve / reject, we will use deferred to transfer data about the upload progress (xhr.upload.onprogress = (e) -> dfd.notify e, file) if the browser deigns. The resulting deferred we use to group $ .when into one and return. In this case, the use of when is not quite justified, I’m happy to read why in the comments.

Deferred for the smallest
Roughly speaking, deferred is a mechanism for separating the addition of event handlers from calling these handlers. Very cool to use for asynchronous calls. For example, I’ll give an ajax request, as it is most often used and closely familiar to most web programmers. It used to be like this:

  $.get('/some/url/for/ajax', function(){ alert('got response'); }, function(){ alert('got error'); }) 


Now so:

  var request = $.get('/some/url/for/ajax'); request.done(function(){ alert('got response'); }); request.fail(function(){ alert('got error'); }) 


what changed? The most important thing for us is that before, at the time of the request, you needed to know what you would do with the answer (the handler functions needed to be passed at the time of $ .get call). Now it is not necessary. You can even hang on the request variable (it will store the deferred object) handlers
after the completion of the request. True, then you need to be ready to immediately trigger the handler. This is the client (“consumer”) side of Deferred - we get deferred from the $ .get method and just wait for it to change state. Now the “dark” side, feel jQuery:

  var def = $.Deferred(); def.done(function(){ // ""  ("")   ""  deferred- console.log('done1', arguments) }); //     def.resolve('param12');//   ""  deferred     done-,      'param12' //   -  ['done1', Arguments['param12']] def.done(function(){ // ""  ("")   ""  deferred- console.log('done2', arguments) }); //    //   -  ['done2', Arguments['param12']] 


We get a couple of done - resolve methods. The first one adds a handler to the transition to the 'resolved' state, the second one transfers the object to this state and, thus, calls all the handlers already added.
The second pair of fail - reject methods work in the same way with the 'rejected' state
The third pair of methods of progress - notify works without a change of state. Just when calling notify, all progress-handlers are triggered.

There is also such a protective thing as promise (). Will return to you the "cut off" deferred. You can hang handlers for it (done, fail, progress), and the state cannot be changed. That is what $ .get returns to you. And correctly - why do you need to manually change the state of the ajax request?

To understand the code, it would also be nice to deal with $ .when. This is the "union" of deferred objects. It works like this:

  var def = $.when(a, b, c ....); //  "" deferred def.done(function(){ alert('resolved'); //   1 , ,     deferred  resolve }); def.fail(function(){ alert('got error'); //   1 ,        deferred  reject }) def.progress(function(){ alert('some error'); //  ,        deferred  notify }) 


If you use $ .when - pay attention to the arguments passed to the handlers by the composite object. He accumulates them. Those. if the functions a.resolve (1) and b.resolve (2) were called, then the composite list-listeners will get 1, 2 as arguments. You can play on fiddle .

But it is better to read about Deferred - here or at least here .



Quite conditionally, the situation can be described as follows: we launch the function of sending data to the server several times (once for each file), leave the sensor (deferred) in each one and collect indicators (promise) from these sensors. Our sensors and indicators are three-channel.

An experienced attentive reader should already exclaim “Bah! So this is a chain of responsibilities! ”And yes, it will be 100% right (taking into account the peculiarities of JS and the desire to make it easier). Skeptics tighten their lips in disgust, patterns in js. But we will not listen to them, something else is important for us: dancing from the conditions of the task and guided by common sense, we almost got to the realization of the pattern ourselves, which means, if we wish, we can read a little about it, and be ready for characteristic surprises .

If they did not solve one problem, they found an acceptable way to a solution. What we have left? There is also the task of reading files. Depending on the capabilities of the browser, we can either get the object selected in the input file, or even be able to read the contents of the folder (recursively if necessary). Or we will not be able to (guess what browser) - just get input. Immediately, there is a desire to act in a similar way - to form an array of readers (chain of duties) and iterate over it, selecting the appropriate function. Put it all there, in our Uploads object. Now it will look like this:

 Uploader = _responsibilityChain: (options, chain, name = false)-> stream = false $.each chain, (i, func)=> if stream = func(options) #        Uploader #          @[name] = func if name false stream readers: [entry, file, input] read: (options)->@_responsibilityChain options, @readers, 'read' senders: [xhrFile, formDataFile, formDataForm, iframe] send: (options)->@_responsibilityChain options, @senders, 'send' 


The implementation of the mechanism was carried out in a separate Uploader function. And right inside the search, we set the appropriate function as the required method (read or send) to the Uploader object. Now let's take a look at the functions themselves for reading (mostly taken from the plugin code, along with comments).

 #     input = (options)-> return false unless options.input.value $.Deferred().resolve([]).promise() #    -      input #         , Deferred      3-  file = (options)-> files = $.makeArray $(options.input).prop 'files' return false unless files.length if (files[0].name == undefined && files[0].fileName) # File normalization for Safari 4 and Firefox 3: $.each files, (index, file)-> file.name = file.fileName; file.size = file.fileSize; $.Deferred().resolve(files).promise() #   -reader.    . , ..  #  Deferred.    ,    entry = (options)-> roots = $(options.input).prop('webkitEntries') || $(options.input).prop('entries') return false unless roots && roots.length > 0 readEntries = (entries, path='')-> $.when.apply($, $.map entries, (entry)-> dfd = $.Deferred() errorHandler = (e)-> e.entry = entry if e && !e.entry # Since $.when returns immediately if one # Deferred is rejected, we use resolve instead. # This allows valid files and invalid items # to be returned together in one set: dfd.resolve [e] resolveHandler = (file)-> # Workaround for Chrome bug #149735 file.relativePath = path dfd.resolve file if entry.isFile entry._file && resolveHandler(entry._file) || entry.file resolveHandler, errorHandler else if entry.isDirectory entry.createReader().readEntries((entries)-> readEntries(entries, path + entry.name + '/' ).done((files)->dfd.resolve files ).fail(errorHandler) , errorHandler) else # Return an empy list for file system items other than files or directories: dfd.resolve([]); dfd.promise() #we do need this pipe here bc we do resolve some files scoped ).pipe -> Array.prototype.concat.apply [],arguments readEntries(roots).promise() 


In short, each of the functions tries in its own way to rape input from options and return deferred, which will be resolved with a list of read files.

An attentive and experienced reader should cry out somewhere here: “Not that pattern!”. And again, it will be 100% right. The internal programmer chuik says something is wrong here. The result of the work of some readers can be given to send not to all senders (for example, after the reader input, the xderFile cannot send). Well, hell with them. We will handle this situation return false unsuitable sender. But reading the data from the input right inside the sender is not ice - do not forget, will we still have Drag'n'Drop? For the time being, let's leave it as it is, what if a miracle happens and a good advice appears in the comments?

One more note - the input reader does nothing - returns a fi nished, empty array deferred. This is just the default value; you can transfer this functionality inside Uploader.read. Left for beauty - ugly, but uniform.

Next - we need to link read and send and add some default values ​​for options. The function is again thrust into our object Uploader:
 Uploader: #...  ,      upload: (options)-> options = $.extend method: 'POST' name: options.input && options.input.name , options @read(options).then (files)=>$.when.apply $, @send $.extend options, files:files 


Everything is simple here - we read the files from the input, add the resulting values ​​to the options and give the resulting object to be sent to Uploader.send. Again, we get grouped from send deferreds with when and give the grouped deferred to the client.

How to use it all:
  $('#some-file-input').change -> Uploader.upload(input: this, url: '/files/upload' ) .done -> #         alert "#{arguments.length} files uploaded" .fail -> #          alert "Some files were not uploaded" .progress -> #      (~ 50 ),    console.log arguments 


Summary


Whole uploader
 Senders = _makeForm: (options)-> $('<form>', method: options.method, enctype:'multipart/form-data' ).append(options.input.clone()) _sendData: (options, data, file)-> xhr = new XMLHttpRequest() dfd = $.Deferred() xhr.onload = xhr.onerror = ->dfd[@status == 200 && 'resolve' || 'reject'] @response, file if xhr.upload xhr.upload.onprogress = (e)->dfd.notify e, file else options.no_progress = true xhr.open options.method, options.url + '?name=' + (options.file_name || file.name), true xhr.responseType = 'text' xhr.send data dfd.promise() iframe: (options)-> return false unless options.input && options.input.value options.no_progress = true id = 'frame' + Math.random() $form = Senders._makeForm(options).attr target: id, action: options.url $('<iframe>', name: id ).appendTo($form).on 'load', -> try response = @contents() throw new Error unless response.length && response[0].firstChild dfd.resolve response, name: options.input.value catch e dfd.reject response, name: options.input.value $form.submit() (dfd = $.Deferred()).promise() formDataForm: (options) -> return false unless options.input && options.input.value && window.FormData form = options.input.form || Senders._makeForm(options).get 0 Senders._sendData options, new FormData(form), name: options.input.value formDataFile: (options)-> return false unless options.files && options.files.length && window.FormData options.files.map (f)-> formData = new FormData() formData.append options.name, f Senders._sendData $.extend(options, file_name:f.name), formData, f xhrFile : (options)-> return false unless options.files && options.files.length && window.ProgressEvent && window.FileReader $.map options.files, (file)-> Senders._sendData options, file, file Readers = input: (options)-> return false unless options.input.value $.Deferred().resolve([]).promise() file: (options)-> files = $.makeArray $(options.input).prop 'files' return false unless files.length if (files[0].name == undefined && files[0].fileName) # File normalization for Safari 4 and Firefox 3: $.each files, (index, file)-> file.name = file.fileName; file.size = file.fileSize; $.Deferred().resolve(files).promise() entry: (options)-> roots = $(options.input).prop('webkitEntries') || $(options.input).prop('entries') return false unless roots && roots.length > 0 readEntries = (entries, path='')-> $.when.apply($, $.map entries, (entry)-> dfd = $.Deferred() errorHandler = (e)-> e.entry = entry if e && !e.entry # Since $.when returns immediately if one # Deferred is rejected, we use resolve instead. # This allows valid files and invalid items # to be returned together in one set: dfd.resolve [e] resolveHandler = (file)-> # Workaround for Chrome bug #149735 file.relativePath = path dfd.resolve file if entry.isFile entry._file && resolveHandler(entry._file) || entry.file resolveHandler, errorHandler else if entry.isDirectory entry.createReader().readEntries((entries)-> readEntries(entries, path + entry.name + '/' ).done((files)->dfd.resolve files ).fail(errorHandler) , errorHandler) else # Return an empy list for file system items other than files or directories: dfd.resolve([]); dfd.promise() #we do need this pipe here bc we do resolve some files scoped ).pipe -> Array.prototype.concat.apply [],arguments readEntries(roots).promise() Uploader: _responsibilityChain: (options, chain, name = false)-> stream = false $.each chain, (i, func)=> if stream = func(options) #        Uploader #          @[name] = func if name false stream readers: [entry, file, input] read: (options)->@_responsibilityChain options, @readers, 'read' senders: [xhrFile, formDataFile, formDataForm, iframe] send: (options)->@_responsibilityChain options, @senders, 'send' upload: (options)-> options = $.extend method: 'POST' name: options.input && options.input.name , options @read(options).then (files)=>$.when.apply $, @send $.extend options, files:files 



input[type=file] — position:absolute , label.

, file-uploader. upload — senders . , , $.Deferred. But:


— — « », « ». progress ,

— ( ). , , , .

:
1. jQuery-File-Upload — github.com/blueimp/jQuery-File-Upload/blob/master/js/jquery.fileupload.js
2. jQuery.Deferred — api.jquery.com/category/deferred-object
3. « » — www.ozon.ru/context/detail/id/20216992

Source: https://habr.com/ru/post/225289/


All Articles