結合 FormData 上傳檔案


過去要以 XMLHttpRequest 來上傳檔案,並沒有一個標準作法,各家瀏覽器各出奇招,現在若能使用 XMLHttpRequest Level 1 的 FormDataXMLHttpRequest 可以輕鬆地以標準方式進行檔案上傳。

FormData 可以用來收集表單資訊,如果有個 form 代表著 <form> 標籤的 DOM,可以直接作為 FormData 建構之用:

let formData = new FormData(form);

或者是建構 FormData 實例之後,自行加入想要的表單內容:

let formData = new FormData();
formData.append('username', 'Justin');
formData.append('password', '123456');

使用 XMLHttpRequest 來進行 POST,呼叫 send 方法時,可以將 FormData 實例當成引數傳入,這時請求的 Content-Type 一定是 multipart/form-data,無需也不能自行設置請求標頭 Content-Type

如果只是使用 FormData 作為一種表單序列化時的簡便 API,伺服端必須能處理 multipart/form-data 內容,而不是單純透過請求參數的 API 來取得相關請求參數。

如果表單中有 type = "file"input 標籤,當表單 DOM 物件被當成 FormData 建構時的引數,可以直接進行檔案上傳,如果是使用 append 方法,加入 type = "file"input 標籤選取之檔案,例如只選取一個檔案的情況,可以如下撰寫:

let photo = document.getElementById('photo');
let formData = new FormData();
formData.append('photo', photo.files[0]);

下面這個範例是個簡單的檔案上傳(可搭配〈getPart()、getParts()〉中的 Servlet):

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
</head>
<body>

    <form id="f" action="upload" method="post" enctype="multipart/form-data">
          Photo  :<input type="file" name="photo"/><br>
        <input id="upload" type="submit"/>
    </form> 

    <span id="message"></span>

<script type="text/javascript">

    // 對 XMLHttpRequest 做簡單封裝
    class XHR {
        constructor() {
            let xhr = new XMLHttpRequest();

            let handlers = {
                'readystatechange' : new Set(),
                'load' : new Set()
            };

            xhr.onreadystatechange = function(evt) {
                handlers['readystatechange']
                    .forEach(handler => handler.call(xhr, evt));
            };

            xhr.onload = function(evt) {
                handlers['load']
                    .forEach(handler => handler.call(xhr, evt));
            };

            this.xhr = xhr;
            this.handlers = handlers;
        }

        addEvt(evtType, handler) {
            this.handlers[evtType].add(handler);
            return this;
        }

        removeEvt(evtType, handler) {
            this.handlers[evtType].delete(handler);
            return this;
        }

        open(method, url, paraObj, async = true, username = null, password = null) {
            let openUrl = paraObj ? `${url}?${params(paraObj)}` : url; 
            this.xhr.open(method, openUrl, async, username, password);
            return this;
        }

        addHeaders(headers) {
            Object.keys(headers)
                  .forEach(name => this.xhr.setRequestHeader(name, headers[name]));
            return this;
        }

        send(body = null) {
            this.xhr.send(body);
            return this;
        }
    }

    document.getElementById('upload').onclick = function(evt) {
        let formData = new FormData(document.getElementById('f'));

        let xhr = new XHR();
        xhr.addEvt('load', evt => {
            let req = evt.target;
            if(req.status === 200) {
                document.getElementById('message').innerHTML = 'File Uploaded';
            }            
        })
        .open('POST', 'upload')
        .send(formData);

        evt.preventDefault();
    };

</script>

  </body>
</html>

如果想要實作檔案上傳進度,要使用的不是 XMLHttpRequestonprogress,而是使用 XMLHttpRequestUploadonprogress,前者是有關於回應的進度,後者是才是有關於上傳的進度,每個 XMLHttpRequest 實例都會關聯著一個 XMLHttpRequestUpload 實例,可透過 XMLHttpRequest 實例的 upload 來取得,因此,想要實作上傳進度,基本上可以如下:

xhr.upload.onprogress = function(evt) {
    console.log(evt.lengthComputable);
    console.log(evt.loaded);
    console.log(evt.total);    
};

在標準規範中,XMLHttpRequestXMLHttpRequestUpload 都繼承了 XMLHttpRequestEventTarget 介面,XMLHttpRequestEventTarget 主要是規範 onloadstartonprogressonabortonerroronloadontimeoutonloadend 這些事件處理器,而 XMLHttpRequestEventTarget 單純繼承 XMLHttpRequestEventTarget,什麼也沒新增,XMLHttpRequest 則是在繼承之後,增加了 onreadystatechangeopensend 等。

底下的範例也針對 XMLHttpRequestUpload 做了封裝:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
</head>
<body>

    <form id="f" action="upload" method="post" enctype="multipart/form-data">
          Photo  :<input type="file" name="photo"/><br>
        <input id="upload" type="submit"/>
    </form> 

    <span id="message"></span>

<script type="text/javascript">

    class XHREventTarget {
        constructor(xhr) {
            let evtTypes = ['loadstart', 'progress', 'abort', 'error', 'load', 'time', 'loadend'];

            let handlers = evtTypes.reduce((handlers, evtType) => {
                handlers[evtType] = new Set();
                return handlers;
            }, {});

            evtTypes.forEach(evtType => {
                xhr[`on${evtType}`] = function(evt) {
                    handlers[evtType].forEach(handler => handler.call(xhr, evt));
                };
            });

            this.xhr = xhr;
            this.handlers = handlers;
        }

        addEvt(evtType, handler) {
            this.handlers[evtType].add(handler);
            return this;
        }

        removeEvt(evtType, handler) {
            this.handlers[evtType].delete(handler);
            return this;
        }       
    }

    class XHRUpload extends XHREventTarget {
        constructor(xhr) {
            super(xhr);
        }
    }

    // 對 XMLHttpRequest 做簡單封裝
    class XHR extends XHREventTarget {
        constructor() {
            super(new XMLHttpRequest());

            let xhr = this.xhr;
            let handlers = this.handlers;
            handlers['readystatechange'] = new Set();

            xhr.onreadystatechange = function(evt) {
                handlers['readystatechange']
                    .forEach(handler => handler.call(xhr, evt));
            };
        }

        open(method, url, paraObj, async = true, username = null, password = null) {
            let openUrl = paraObj ? `${url}?${params(paraObj)}` : url; 
            this.xhr.open(method, openUrl, async, username, password);
            return this;
        }

        addHeaders(headers) {
            Object.keys(headers)
                  .forEach(name => this.xhr.setRequestHeader(name, headers[name]));
            return this;
        }

        send(body = null) {
            this.xhr.send(body);
            return this;
        }

        uploadXHR() {
            if(this.upload === undefined) {
                this.upload = new XHRUpload(this.xhr.upload);
            }
            return this.upload;
        }
    }

    document.getElementById('upload').onclick = function(evt) {
        let formData = new FormData(document.getElementById('f'));

        let xhr = new XHR();

        xhr.uploadXHR().addEvt('progress', evt => {
            console.log(evt.lengthComputable);
            console.log(evt.loaded);
            console.log(evt.total);
        });

        xhr.addEvt('load', evt => {
            let req = evt.target;
            if(req.status === 200) {
                document.getElementById('message').innerHTML = 'File Uploaded';
            }            
        })
        .open('POST', 'upload')
        .send(formData);

        evt.preventDefault();
    };

</script>

  </body>
</html>