封裝 Ajax 操作


實際上,在之前的文件中,已經逐漸對 XMLHttpRequest 的相關操作做了些封裝了,現在需要的是,建立一個 Ajax-1.0.0.js,將先前已經做的一些封裝放進去,並做一些補強,首先是對 XMLHttpRequest 的基本封裝:

// 組合與編碼請求參數
function params(paraObj) {
    return Object.keys(paraObj)
                 .map(name => {
                     let paraName = encodeURIComponent(name);
                     let paraValue = encodeURIComponent(paraObj[name]);                         
                     return `${paraName}=${paraValue}`.replace(/%20/g, '+');
                 })
                 .join('&');
}

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;
    }

    set responseType(type) {
        this.xhr.responseType = type;
        return this;
    }

    get response() {
        return this.xhr.response;
    }
}

在我的想法中,XMLHttpRequest 的操作有一定的複雜性,完全隱藏相關操作流程是不可能的,因此,這個 XHR 只是做簡單封裝,如果需要細部的操作,就使用 XHR,你還是得知道相關操作流程,然而,利用 XHR 實例,可以在操作時使用流暢風格,而在事件上,也與先前文件中的事件處理風格一致。

當然,由於經常使用 XMLHttpRequest 做些簡單的 GETPOST,基於方便,可以封裝個 getpost 函式:

// 對 Ajax 請求相關設定的封裝
function ajax({method, url, headers = {}, body = null, responseType = '', handlers = {}}) {
    let request = new XMLHttpRequest();

    request.responseType = responseType;

    Object.keys(handlers).forEach(handler => {
        request[handler] = handlers[handler];
    });

    request.open(method, `${url}`);

    Object.keys(headers).forEach(header => {
        request.setRequestHeader(header, headers[header]);
    });

    request.send(body);    
}

// 方便的 get 函式,用於 GET 請求
function get(url, {headers = {}, paraObj = {}, responseType = '', handlers = {}}) {
    let targetUrl = Object.keys(paraObj).length === 0 ? url : `${url}?${params(paraObj)}`;

    ajax({
        method : 'GET',
        url  : targetUrl,
        headers,
        responseType,
        handlers
    });
}

// 方便的 post 函式,用於 POST 請求
function post(url, {headers = {}, body = null, responseType = '', handlers = {}}) {
    let bodyContent = body;
    if(headers['Content-Type'] === 'application/x-www-form-urlencoded' && typeof body !== 'string') {
        bodyContent = params(body);
    }

    ajax({
        method : 'POST',
        url,
        headers,
        body : bodyContent,
        responseType,
        handlers
    });
}

如此在需要簡單的 GETPOST 時,只要提供相關引數就可以了,基本流程被封裝起來了,getpost 的好處是,在請求類型是 application/x-www-form-urlencoded,會自動建立查詢字串。至於 GETPOST 以外的請求,可以試著使用 ajax 函式,使用它時必須自行處理 URL 與請求本體,getpost 實際上也是委託 ajax 來處理請求。

另一種封裝的思路是,把 XMLHttpRequest 封裝為像是 Fetch API,這會需要使用到 Promise,會需要這麼做的情況是,目標瀏覽器不支援,而你又打算使用 Fetch API,有興趣知道怎麼實作的話,可以參考 fetch 的 polyfill。

接下來把必要的名稱匯出:

export {params, XHR, ajax, get, post};

export default function(method, url, options) {
    if(method === undefined) {
        return new XHR();
    }

    switch(method.toLowerCase()) {
        case 'get':
            get(url, options);
            break;
        case 'post':
            post(url, options);
            break;
        case 'put':
        case 'delete':
        case 'head':
        case 'option':
        case 'trace':
            ajax({
                method,
                url,
                ...options
            });
            break;
        default:
            throw new Error('no such http method');
    }
};

在預設匯出的部份,這邊設計為可建立 XHR 實例,或者是可執行指定請求的工廠函式,判別的方式是簡單地看看有無指定 method

你可以在 Ajax-1.0.0.js,以及 XD-1.2.0.jsEvt-1.0.0.jsStyle-1.0.0.js 下載到這系列文件中已建立之模組。

來使用這個程式庫,實際改寫一下〈使用 GET 請求〉中第一個範例(你的瀏覽器必須支援 ES6 模組):

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
</head>
<body>
    圖書:<br>
    <select id="category">
        <option>-- 選擇分類 --</option>
        <option value="theory">理論基礎</option>
        <option value="language">程式語言</option>
        <option value="web">網頁技術</option>
    </select><br><br>
    採購:<div id="book"></div>

<script type="module"> 
    import {elemsById} from './js/XD-1.2.0.js';   
    import {get} from './js/Ajax-1.0.0.js';

    elemsById('category').addEvt('change', evt => {
        let paraObj = {
            category : evt.target.value,
            time     : new Date().getTime()
        };
        let handlers = {
            onload(evt) {
                let req = evt.target;
                if(req.status === 200) {
                    elemsById('book').html(req.responseText);
                }
            }
        };

        get('GET-1.php', {
            paraObj, 
            handlers
        });        
    });

</script>

</body>
</html>

按我觀看執行結果

接下來是改寫〈使用 POST 請求〉中的範例:

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

    新增書籤:<br>
    網址:<input id="url" type="text">
    <span id="message" style="color:red"></span><br>
    名稱:<input type="text">

<script type="module">    
    import {elemsById} from './js/XD-1.2.0.js';
    import {post} from './js/Ajax-1.0.0.js';

    elemsById('url').addEvt('blur', evt => {
        let headers = {
            'Content-Type' : 'application/x-www-form-urlencoded'
        };
        let body = {
            url : document.getElementById('url').value
        };
        let handlers = {
            onload(evt) {
                let req = evt.target;
                if(req.status === 200 && req.responseText === 'existed') {
                    elemsById('message').html('URL 已存在');
                }  
            }
        };

        post('POST-1.php', {
            headers,
            body,
            handlers
        });    
    });
</script>

</body>
</html>

按我觀看執行結果

這個 post 函式也可以處理檔案上傳,例如改寫〈結合 FormData 上傳檔案〉中第一個範例:

<!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="module">
    import {elemsById} from './js/XD-1.2.0.js';
    import http from './js/Ajax-1.0.0.js';

    elemsById('upload').addEvt('click', evt => {
        let formData = new FormData(document.getElementById('f'));

        http('POST', 'upload', {
            body     : formData,
            handlers : {
                onload(evt) {
                    let req = evt.target;
                    if(req.status === 200) {
                        elemsById('message').html('File Uploaded');
                    }                          
                }
            }
        });

        evt.preventDefault();
    });

</script>

  </body>
</html>

上頭使用的是預設匯出的工廠函式,由於指定 'POST',因此底層會使用 post 函式,接著來改寫〈使用 responseType〉中搜尋框的範例:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <style type="text/css">
        div {
            color: #ffffff;
            background-color: #ff0000;
            border-width: 1px;
            border-color: black;
            border-style: solid;
            position: absolute;
        }    
    </style>
</head>
<body>
    <hr>
    搜尋:<input id="search" type="text">

<script type="module">
    import x from './js/XD-1.2.0.js';
    import http from './js/Ajax-1.0.0.js';

    let doc = x(document);
    let search = doc.elemsById('search');

    search.addEvt('keyup', evt => {
        doc.elemsByTag('div').remove();

        let value = search.val();

         // 沒有輸入值,直接結束
        if(value === '') {
            return;
        }

        http('GET', `ResponseType-1.php?keyword=${value}`, {
            responseType : 'json',
            handlers     : {
                onload(evt) {

                    let request = evt.target;

                    if(request.status === 200) {
                        // response 會是 JSON 物件
                        let keywords = request.response;

                        // 字串陣列長度不為0時加以處理
                        if(keywords.length !== 0) {
                            let innerHTML = keywords.map(keyword => `${keyword}<br>`)
                                                    .join('');

                            let offset = search.offset();
                            let offsetWidth = search.attr('offsetWidth');
                            let offsetHeight = search.attr('offsetHeight');

                            // 建立容納選項的<div>
                            let div = x('div').toElemCollection()
                                              .html(innerHTML)
                                              .css({
                                                  left  : `${offset.x}px`,
                                                  top   : `${offset.y + offsetHeight}px`,
                                                  width : `${offsetWidth}px`
                                              });

                            document.body.appendChild(div.get());
                        }
                    }            

                }            
            }
        });
    });

</script>

</body>
</html>

按我觀看執行結果