Uploading files without a full postback (无刷新上传文件)

Well, ever since i've started working with ATLAS (I mean, AJAX extensions:) )I've been trying to build a cool control which would let me upload files like gmail does. As most of you know by now, reading files from the browser in a way that works in all of them is not  an easy task. So, about 3 or 4 months ago, i started developing a client behavior which i called FileUpload. As you might guess, this will be a long post where i'm going to explain the major design decisions i've made while building my component. Before going on, here are some things which youmust know and completly understand before reading and using my code:

  1. I'm not giving any kind of support for this code. So, there's really no need to send me lots and lots of emails. I've only built this control to learn new stuff and to show that it's really possible for a mere mortal (like myself) to produce something cool which might help others;
  2. you can only use this control with IIS 6 and above. In IIS 5.1 there's a bug which the MS team marked as non fixable which prevents me from returning a Response.Redirect from the server side when the current request exceeds the maximum allowed size. btw, this control will not work correctly with the internal server that comes with VS;
  3. Internaly, the client behavior makes use of window.frames[0] in non IE clients.  This means that the control will only work properly if you don't use iframes on the page. alternatively, you can change the code so that you can add those elements to the page;
  4. The control has only been tested in Firefox 2.0 RC3 and Internet Explorer. I think it may need some care if anyone decides to port it to Opera.

If you've read it and agreed, then please download the files here.

Presenting the idea

So, what's the problem we're having here? We can't access the contents of a file from a browser. I'm already seeing several guys saying that i'm wrong and that it can be done easily if we use FSO. Well, the problem is that FSO only works in IE (after all, it's a COM object). Ok, so where does this leave us? yes, that's right: we'll have to do it with the classical <input type="file"> control and use the correct ammount of Javascripts and iframes :)

The plan is simple (as always, the devil is on the details): we must add a hidden iframe which will host an <input type="file"> control inside a form. Then, we can perform the postback by submitting that form progamatically. Since I wanted to support IE and Firefox, I had to take into account that there are several differences between what you can do with Javascript in each browser. For instance, in IE there's no need to show the <input type="file"> control since we can show a window that lets the user pick the file he's interested in programaticaly (ie, by writing some javascript). On the other hand, Firefox doesn't like this and will only show the file dialog when the user clicks on the button associated with the input control.

Another conclusion that can be reached from the 1st paragraph of this section is that we need to be able to save the file on the server side and to validate its size (you'll se why soon). Before that, lets start exploring the client size behavior.

 

The client behavior

The client behavior is maintained in uploadbehavior.js file which is embedded in the FileUploader dll. The behavior (which is called UploadBehavior) exposes the following properties:

  1. frameId: you can use this property to set the ID of the hidden frame that is used to upload the file to the server. You only need to worry with this file when you have several UploadBehaviors on the same page (which might not work in the current release - specially if you use updatepanels and firefox);
  2. inputId: it lets you specify the ID of the input <type="file"> that's created dinamically inside the hidden iframe;
  3. url: used to specify the url of the handler that receives the file bytes (by default, the predefined handler is used);

During the initialization, the behavior starts by creating a handler and setting it to the click event fired by the associated HTML control. It also creates the hidden iframe and adds it to current HTML document:

_createFrame : function() {
  var frame = document.createElement( "iframe" );
  frame.name = this._frameId;
  frame.id = this._frameId;

  if( Sys.Browser.agent == Sys.Browser.InternetExplorer ){
      document.body.appendChild( frame );
  }
  else{
     var parent = this.get_element().parentNode;
     parent.insertBefore( frame, this.get_element() );
  }
//added this here so that if a second behavior exists on the page
//its iframe gets correctly filled
   this._generateIFrameContent();
   frame.style.display = "none"
},
//responsible for setting up the behavior
//it starts by creating a hidden iframe
//which will be used for uploading the control
initialize : function(){
   LA.UploadBehavior.callBaseMethod( this, "initialize" );
   this._clickHandler = Function.createDelegate( this, this._click );
   $addHandler( this.get_element(), "click", this._clickHandler );
   //create a frame
   this._createFrame();
}

As you can see, if the current browser is not IE, then we insert the hidden iframe before the associated HTML control. The objective is to create the illusion that iframe is on the same place  as the HTML associated control. the _generateIFrameContent is a interesting method that is responsible for generatig the contents of the hidden iframe:

_generateIFrameContent : function(){
  var frameWnd = window.frames[this._frameId];
  var frameObj = document.getElementById(this._frameId);
  var queryStringDic = {};
  
  queryStringDic["id"] = this._currentId.toLocaleString();
  queryStringDic["retMethod"] = "LA.UploadBehavior.uploadComplete"

   var queryStringEncoded = Sys.Net.WebRequest._createQueryString(queryStringDic);
   var content = "<html><body style='margin: 0px'><form name='upload' method='post' action='" + this._url +
        "?" + queryStringEncoded +"' enctype='multipart/form-data'><input type='file' style='width:100%;height:100%' id='" +
        this._inputId +"' name='" + this._inputId +"'\/><input type='hidden' id='inputId' name='inputId' value='" + this._inputId +
        "' \/><input type='hidden' id='currentId' name='currentId' value='" + this._currentId.toLocaleString() +"' \/></form></body></html>"
  //firefox has some problems recreating the iframe
  //so i'll just go into the position and let's see what happen
  if( !frameWnd.document ){
     //just get frame at pos 0;
     frameWnd = window.frames[0];
  }
  frameWnd.document.open();
  frameWnd.document.write( content );
  frameWnd.document.close();
  //set event handlers
  this._setHandler( frameWnd );
},

As you can see, there's a small  problem with Firefox: when you delete the iframe (which is done on the dispose method) and try to recreate it, you'll get a null document if you try to access the iframe through its name. To solve that, I start by checking the document property and when it's null, i'll just get the first iframe placed at position 0. It's important to keep in mind that this will only happen when you put the iframe inside an UpdatePanel and that in Firefox you can only use one behavior of this type per page in those scenarios (unless you find another way of reaching into the correct iframe).

As we've seen, the upload is started when you click on the associated HTML control. the _click method is responsible for registering the behavior in a global dictionary added to the page. This dictionary is important since it'll let you have several controls on the same page and you'll be able to get the correct answers on all the uploads. Currently, the method performs the following tasks:

_click : function(){
  LA.UploadBehavior._registerOnDictionary(this._currentId.toLocaleString(), this);
  var frameWnd = window.frames[this._frameId];
  var frameObj = document.getElementById(this._frameId);

  //firefox has some problems recreating the iframe
  //so i'll just go into the position and let's see what happen
  if( !frameWnd.document ){
     //just get frame at pos 0;
     frameWnd = window.frames[0]; 
  }

 this._generateIFrameContent();
 if( Sys.Browser.agent == Sys.Browser.InternetExplorer ){
    frameObj.style.display = "none"
    frameWnd.document.getElementById( this._inputId ).click();
 }
 else{
   this._updateFrameSize( true );
  this.get_element().style.display = "none"
 }
 return false;
},

One of the things that you should keep in mind is that iframes are special objects. When you need to access the properties of the DOM element that has been appended to the page, you should get a reference through the document.getElementById method; on the other hand, when you need to access the contents of the inner document of the window loaded inside of the iframe, you need to get a reference through the windows.frames collection maintained by our window. If you really must ask, the _updateFrameSize is a helper method that tries to set the size of the iframe so that it only occupies the space reserverd by the associated HTML control.

I don't know if you've noticed, but the _generateIFrameContent method i also responsible for setting a handler that will handle the propertychanged event that is fired by the <input type="file"> control that exists inside the iframe. Again, we must check the browser because IE and Firefox don't agree (again) on the name of the event that is fired when a property of an HTML control changes. The method _setHandler is presented in the next lines:

_setHandler : function( frame ) {
  var file = frame.document.getElementById( this._inputId );
  this._propertyChangedHandler = Function.createDelegate( this, this._onPropertyChanged );
  if( Sys.Browser.agent == Sys.Browser.InternetExplorer ){
     file.attachEvent( "onpropertychange", this._propertyChangedHandler );
  }
  else{
    file.addEventListener( "change", this._propertyChangedHandler, false );
  }
},

wtf? why am im using the attachEvent and the addEventListener instead of using the new $addHandler method? well, it happens that the method generated an exception when you try to handle the propertychange event of a control placed inside another window. Since i didn't had the time to investigate the issue, i just kept going and used those old methods for setting my handlers. As you might expect, the _onPropertyChanged method tries to submit the form maintained in the hidden iframe.

Since I wanted to use the client behavior from xml-script, i had to add a descriptor:

LA.UploadBehavior.descriptor = {
  properties: [
    { name:"frameId", type: String },
    { name: "url", type: String },
    { name: "fileId", type:String },
    { name: "inputId", type: String }
 ],
 events: [
    { name: "uploadCompleted" }
]
}

Before ending the client portion, it's also important to show the global methods that are responsible for maintaining the global dictionary and for updating the internal state of the behavior:

//global dictionary used to maintain references to
//behaviors that have started an upload
var ___myDic = new Object();

//adds a new entry to the dictionary
LA.UploadBehavior._registerOnDictionary = function(id, ref){ ___myDic[id] = ref; }

//removes reference from the dictionary
LA.UploadBehavior._removeFromDictionary = function(id){ delete ___myDic[id]; }

//global (static) method which is used to handle the uploadComplete event
LA.UploadBehavior.uploadComplete = function( info, id ){
  var obj = ___myDic[id];
  LA.UploadBehavior._removeFromDictionary(id);

  if( Sys.Browser.agent != Sys.Browser.InternetExplorer ){
     obj.get_element().style.display = ""
     obj._updateFrameSize( false );
  }

  obj.set_fileId( info );
  obj._raiseEvent( "uploadCompleted", Sys.EventArgs.Empty );
}

The uploadComplete is a static method which must be called from the server side handler to signal the end of the upload. it receives two parameters: the first, points to the name that was given to the file during the save operation performed by the server handler; the second, has the ID of the behavior that was saved previously to the global dictionary. And i think i've said everything about the client behavior. now, let's check the server handler.

The server handler

If you don't want to build a customized handler, you can reuse the one that comes with the file upload control dll. This is a very simple handler which saves the file to the current directory (normally, the root directory of the app) and is responsible for building and sending a response to the client behavior that started the upload. This response is really important! If you don't send one back, or if it gets lost somewhere or if the client behavior doesn't receive it in a predefined format, you won't be able to use the behavior to upload more files without performing a refresh (you'll start getting access denied errors).

btw, if you build a custom handler, you're expected to use the inputId and id query string parameters to get the id of the <input type="file"> that was responsible for the upload and the ID used by the behavior to register itself on the global client dictionary introduced by the client behavior file. You must also build the javascript reply and you should use the retMethod query string parameter to get the name of the method that you must call on the client side (so, what you really must do is return a predefined javascript method call from the handler). Here's the current handler's ProcessRequest implementation:

public void ProcessRequest(HttpContext context)
{
  string idFileServer = "0"
  string fileId = ""
  HttpPostedFile file = null;

  try
  {
    fileId = context.Request.Params["inputId"];
    file = context.Request.Files[fileId];
    if (file != null)
    {
        Guid guid = Guid.NewGuid();
       file.SaveAs(context.Server.MapPath(guid.ToString()));
       idFileServer = guid.ToString();
   }
}
catch
{
   idFileServer = "0"
}

  string method = context.Request.QueryString["retMethod"].Substring(1, context.Request.QueryString["retMethod"].Length - 2);
  string id = context.Request.QueryString["id"].Substring(1, context.Request.QueryString["id"].Length - 2);
  string reply = string.Concat("<script type='text/javascript'>parent.window.",
                                       method,
                                      "('", idFileServer, "','",
                                      id,
                                     "');</script>");
  context.Response.Clear();
  context.Response.ClearContent();
  context.Response.Write(reply);
  context.Response.Flush();
  context.Response.Close();
}

posted @ 2006-12-23 01:35  永不言败  阅读(944)  评论(0编辑  收藏  举报