使用 Ajax 以非同步模式傳遞資料

    前言

    傳統的網頁程式,使用 HTML 表單 (HTML form) 和使用者互動。但傳送 HTML 表單相當於發出新的請求 (request),每傳送一次表單就要重刷一次頁面,對於使用者體驗來說不是很好。

    近年來的網頁程式,會充份利用 Ajax 的特性,以非同步的方式傳接資料,再利用 JavaScript 程式動態改變使用者介面。透過 Ajax 傳送資料,瀏覽器不會重刷頁面,藉以改善網站使用的體驗。

    在本範例程式中,我們以 Ajax 重構先前文章介紹的 TODO 清單程式。在不修改該程式功能的前提下,藉由 Ajax 避開重刷頁面的議題,藉以改善使用者體驗。

    展示本範例程式

    在功能面上,本範例程式和前一個版本的程式是相同的。但兩者會在細微的行為上出現差異,讀者如果時間允許的話,務必兩個版本的程式都把玩一下,以比較其差異。

    注意事項

    為了免除讀者設置資料庫的負擔,我們使用 SQLite,並將資料庫存在記憶體中。透過這種方式,我們不會在硬碟上留上實體資料,相當適合用於展示。但以這種方式儲存的資料,每次關閉程式時資料就歸零,所以實務上不適合用這種方式儲存資料。

    專案程式碼

    由於這個範例程式的程式碼較長,我們不會在文章中貼出所有的程式碼,以免文章過長。我們把完整的專案放在這裡,有興趣的讀者可自行追踪程式碼。我們會在文章中展示一部分程式碼,以利說明。

    初始化主頁面

    我們先來看模板的部分。在新增 TODO 項目的使用者介面,大抵上是相同的:

    <form action='/todo/' method='POST'>
        <div class='row'>
            <div class='offset-lg-1 col-lg-8 offset-md-1 col-md-7' style='margin-bottom: 5pt;'>
    
                <input type='text' class='form-control' name='todo' placeholder='Something to do.'>
            </div>
    
            <div class='col-lg-3 col-md-4'>
                <button class='btn btn-primary'>Add</button>
            </div>
        </div>
    </form>
    

    但在現有 TODO 項目的部分,則縮減到剩單一 <div> 元素:

    <div id='todos'></div>
    

    這是因為我們不再由後端輸出 (render) TODO 項目,改由 Ajax 呼叫取得所有的 TODO 項目後,再由前端的 JavaScript 程式動態生成使用者介面的部分。

    至於錯誤訊息的部分則改動如下:

    <div class='row'>
        <div class='offset-lg-2 col-lg-8 offset-md-1 col-md-10 col-sm-12'>
            <div id='message'></div>
        </div>
    </div>
    

    一開始錯誤訊息的使用者介面也是空的,我們會在必要時動態加上錯誤訊息。

    我們仍然在頁面上註冊 ESC 鈕的事件處理器:

    document.addEventListener('keydown', function (event) {
        if (event.which === 27) {
            let todos = document.getElementsByClassName('todo');
    
            for (let i = 0; i < todos.length; i++) {
                let label = todos[i].querySelector('label');
                let inputTODO = todos[i].querySelector('[name="index"]');
                let indexTODO = inputTODO.value;
    
                if (!label) {
                    let input = todos[i].querySelector('input');
    
                    let text = input.value;
    
                    let label = document.createElement('label');
    
                    label.classList.add('col-form-label');
                    label.innerText = text;
    
                    label.addEventListener('click', function () {
                        loadItem(indexTODO);
                    });
    
                    let index = todos[i].querySelector('[name="index"]').getAttribute('value');
    
                    let inputIndex = document.createElement('input');
    
                    inputIndex.setAttribute('value', index);
                    inputIndex.name = 'index'
                    inputIndex.setAttribute('hidden', true);
    
                    todos[i].innerHTML = '';
                    todos[i].appendChild(label);
                    todos[i].appendChild(inputIndex);
                }
            }
        }
    });
    

    在頁面上按下 ESC 鈕時,會將所有以 <input> 呈現的 TODO 項目回覆成以 <label> 呈現。

    我們在頁面載入完成時會觸發一次 Ajax 呼叫,在 Ajax 任務結束時更新 TODO 項目:

    superagent
        .get(`${baseURL}/todos/`)
        .set('accept', 'json')
        .then(function (res) {
            clearMessage();
    
            let ts = res.body.todos;
    
            for (let i = 0; i < ts.length; i++) {
                addTODO(ts[i]);
            }
        })
        .catch(function (err) {
            if (err.reponse) {
                showMessage(err.reponse.message);
            }
        });
    

    在此處,我們沒有用原生的 XMLHttpRequest 寫 Ajax 任務,而用 SuperAgent。這是因為 SuperAgent 的 API 比原生 API 簡單地多,而且可以用 promise 來寫,在語法上比較簡潔。

    我們這個範例的 Ajax 請求都以 promise 的形式來寫。在這種形式下,會將請求成功後的動作寫在 .then() 函式所在的回呼函式內。若請求失敗,則會跳到 .catch() 函式所在的回呼函式內。由於我們使用 promise 來撰寫程式碼,可以避開 callback hell 這種 JavaScript 常見的模式。

    該 Ajax 任務會以非同步模式對 http://localhost:8080/todos/ 發出 GET 請求方法,該請求不帶參數,會回傳 JSON 文件。該文件代表目前程式中所有的 TODO 項目。

    實際在頁面上生成 TODO 項目的 addTODO() 函式其實很長,該函式大部分的程式碼都和動態生成 TODO 項目有關,請讀者自行前往專案觀看。我們使用原生網頁 API 來寫,不借助前端框架。使用原生網頁 API 的好處是易上手,但寫出來的程式碼會比較長。

    在後端網頁程式中,我們要為 /todos/ 路徑註冊事件處理器:

    mux.GET("/todos/", getTODOHandler)
    

    該事件處理器的程式碼如下:

    func getTODOHandler(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
    	rows, err := db.Table("todos").Select("*").Rows()
    	if err != nil {
    		ErrorMessage(w, http.StatusBadGateway, "Unable to retrieve database")
    		return
    	}
    
    	var todos []TODO
    
    	todos = make([]TODO, 0)
    
    	for rows.Next() {
    		var todo struct {
    			ID   uint
    			Todo string `gorm:"todo"`
    		}
    
    		db.ScanRows(rows, &todo)
    
    		todos = append(todos, TODO{
    			Index: todo.ID,
    			Item:  todo.Todo,
    		})
    	}
    
    	data := TODOs{
    		todos,
    	}
    
    	json, _ := json.Marshal(data)
    
    	w.Header().Set("Content-Type", "application/json")
    	w.Write(json)
    }
    

    我們會試著對資料庫做查詢,當查詢失敗時,以 JSON 字串回傳錯誤訊息,並同時回傳相對應的 HTTP 狀態碼 (HTTP response status code)

    當查詢成功時,我們把查詢結果包成 JSON 字串後回傳。

    新增 TODO 項目

    我們在頁面中兩處註冊新增 TODO 項目的事件處理器。一個是在 <input> 元素按下 Enter 鈕時,一個是按下 Add 鈕時。參考程式碼如下:

    (function () {
        let form = document.querySelector('form');
    
        let input = form.querySelector('input');
    
        input.addEventListener('keydown', function (ev) {
            if (ev.which === 13) {
                ev.preventDefault();
    
                createTODO();
            }
        }, false);
    
        let btn = form.querySelector('button');
    
        btn.addEventListener('click', function (ev) {
            ev.preventDefault();
    
            createTODO();
        }, false);
    
        function createTODO() {
            let item = input.value;
    
            superagent
                .post(`${baseURL}/todo/`)
                .send({
                    item: item,
                    index: 0
                })
                .set('accept', 'json')
                .then(function (res) {
                    clearMessage();
    
                    addTODO(res.body);
                    input.value = '';
                })
                .catch(function (err) {
                    if (err.response) {
                        showMessage(err.response.message);
                    }
                });
        }
    })();
    

    由於兩處所執行的任務相同,我們把該任務重構到函式中。該任務是一 Ajax 請求,該請求對 http://localhost:8080/todo/ 發出 POST 請求。該請求傳送一個代表單一 TODO 項目的 JSON 字串。

    該 Ajax 請求成功時會得到同一個包在 JSON 字串的 TODO 項目,我們會呼叫 addTODO 函式在頁面上動態地新增一項 TODO。若該請求失敗,則會回傳一個代表錯誤訊息的 JSON 字串,我們會將錯誤訊息秀在頁面上。

    在兩個事件處理器中,我們都使用 Event.preventDefault() 函式來避免觸發原本的 HTML 表單傳送請求,以免瀏覽器在不必要時重刷頁面。

    我們要在網頁後端程式的 /todo/ 路徑註冊相關的事件處理器:

    mux.POST("/todo/", addTODOHandler)
    

    該事件處理器的程式碼如下:

    func addTODOHandler(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
        decoder := json.NewDecoder(r.Body)
    
        var t TODO
        err := decoder.Decode(&t)
        if err != nil {
            ErrorMessage(w, http.StatusUnprocessableEntity, "Failed to parse input")
            return
        }
    
        db.Table("todos").Create(struct {
            Todo string `gorm:"todo"`
        }{
            Todo: t.Item,
        })
    
        var rec struct {
            ID   uint
            Todo string `gorm:"todo"`
        }
    
        db.Table("todos").Last(&rec)
    
        data := TODO{
            Index: rec.ID,
            Item:  rec.Todo,
        }
    
        json, _ := json.Marshal(data)
    
        w.Header().Set("Content-Type", "application/json")
        w.Write(json)
    }
    

    一開始我們試著解析傳入的 JSON 文件,若解析失敗,則回傳錯誤訊息。解析失敗的原因可能是 JSON 文件不符格式等。我們在撰寫網頁程式時,還是要考慮失敗的情境,不能假定傳人的資料一定是正確的。

    若傳入成功,我們在資料庫中新增一筆記錄 (record)。並將新增的記錄重新讀入後包成 JSON 物件回傳。

    修改 TODO 項目

    我們在兩處新增修改 TODO 項目的事件處理器。一個在 Update 按鈕上,一個在動態生成的 <input> 元素上。

    Update 按鈕的事件處理器如下:

    /* Create a `Update` button. */
    let btnUpdate = document.createElement('button');
    
    btnUpdate.innerText = 'Update';
    btnUpdate.type = 'submit';
    btnUpdate.name = '_method';
    btnUpdate.value = 'update';
    btnUpdate.addEventListener('click', function (ev) {
        ev.preventDefault();
    
        /* Get TODO item and index from the page. */
        let item;
        let index;
    
        let form = btnUpdate.parentNode.parentNode.parentNode;
    
        let todo = form.querySelector('.todo');
    
        let label = todo.querySelector('label');
    
        if (label) {
            item = label.innerText;
        } else {
            let _input = todo.querySelector('input');
    
            item = _input.value;
        }
    
        index = todo.querySelector('[name="index"]').getAttribute('value');
    
        /* Send `PUT` event with Ajax. */
        superagent
            .put(`${baseURL}/todo/`)
            .send({
                item: item,
                index: Number(index)
            })
            .set('accept', 'json')
            .then(function (res) {
                clearMessage();
    
                let form = btnUpdate.parentNode.parentNode.parentNode;
    
                let todo = form.querySelector('.todo');
                let inputTODO = todo.querySelector('[name="index"]');
                let indexTODO = inputTODO.value;
    
                let item = res.body.item;
                let index = res.body.index;
    
                /* Re-create new label and hidden input elements. */
                let _label = document.createElement('label');
    
                _label.classList.add('col-form-label');
                _label.innerText = item;
    
                _label.addEventListener('click', function () {
                    loadItem(indexTODO);
                });
    
                let inputIndex = document.createElement('input');
    
                inputIndex.setAttribute('value', index);
                inputIndex.name = 'index';
                inputIndex.setAttribute('hidden', true);
    
                /* Clear old elements and append new elements. */
                todo.innerHTML = '';
                todo.appendChild(_label);
                todo.appendChild(inputIndex);
            })
            .catch(function (err) {
                if (err.response) {
                    showMessage(err.response.message);
                }
            });
    }, false);
    

    這段程式碼稍微長一點。這段範例程式會從頁面上取得修改後的 TODO 項目資料,以 Ajax 請求傳到網頁後端程式。待 Ajax 請求成功後會更新頁面上相對應的 TODO 項目。

    let input = document.createElement('input');
    
    input.classList.add('form-control');
    input.name = 'todo';
    input.setAttribute('value', text);
    
    input.addEventListener('keydown', function (event) {
        /* Update data when pressing ENTER or ESC key. */
        if (event.which === 13 || event.which === 27) {
            let form = event.target.parentNode.parentNode.parentNode;
    
            let todo = form.querySelector('.todo');
    
            let _input = todo.querySelector('input');
    
            let item = _input.value;
            let index = todo.querySelector('[name="index"]').getAttribute('value');
    
            /* Update the TODO item by sending a `PUT` event with Ajax. */
            superagent
                .put(`${baseURL}/todo/`)
                .send({
                    item: item,
                    index: Number(index)
                 })
                .set('accept', 'json')
                .then(function (res) {
                    clearMessage();
    
                    let form = btnUpdate.parentNode.parentNode.parentNode;
    
                    let _todo = form.querySelector('.todo');
                    let _inputTODO = todo.querySelector('[name="index"]');
                    let _indexTODO = _inputTODO.value;
    
                    let item = res.body.item;
                    let index = res.body.index;
    
                    /* Re-create new label and input elements. */
                    let _label = document.createElement('label');
    
                    _label.classList.add('col-form-label');
                    _label.innerText = item;
    
                    _label.addEventListener('click', function () {
                        loadItem(_indexTODO);
                    });
    
                    let inputIndex = document.createElement('input');
    
                    inputIndex.setAttribute('value', index);
                    inputIndex.name = 'index'
                    inputIndex.setAttribute('hidden', true);
    
                    /* Clear old elements and append new elements. */
                    _todo.innerHTML = '';
                    _todo.appendChild(_label);
                    _todo.appendChild(inputIndex);
                })
                .catch(function (err) {
                    if (err.response) {
                        showMessage(err.response.message);
                    }
                });
        }
    });
    

    為了處理這段 ajax 請求,我們要在網頁程式中加入相對應的事件處理器:

    mux.PUT("/todo/", updateTODOHandler)
    

    該事件處理器的程式碼如下:

    func updateTODOHandler(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
        decoder := json.NewDecoder(r.Body)
    
        var t TODO
        err := decoder.Decode(&t)
        if err != nil {
            ErrorMessage(w, http.StatusUnprocessableEntity, "Failed to parse input")
            return
        }
    
        db.Table("todos").Where("id == ?", t.Index).Update(struct {
            Todo string `gorm:"todo"`
        }{
            Todo: t.Item,
        })
    
        data := TODO{
            Index: t.Index,
            Item:  t.Item,
        }
    
        json, _ := json.Marshal(data)
    
        w.Header().Set("Content-Type", "application/json")
        w.Write(json)
    }
    

    這段事件處理器並不困難。當找到特定的 TODO 項目時,會更新該項目,然後回傳更新後的值。若找不到該項目,則回傳錯誤訊息。

    移除 TODO 項目

    由於移除 TODO 項目是具破壞性的動件,我們只在 Delete 按鈕加上相關的事件處理器:

    /* Create a `Delete` button. */
    let btnDelete = document.createElement('button');
    
    btnDelete.innerText = 'Delete';
    btnDelete.type = 'submit';
    btnDelete.name = '_method';
    btnDelete.value = 'delete';
    btnDelete.addEventListener('click', function (ev) {
        ev.preventDefault();
    
        /* Get TODO index from the page. */
        let item;
        let index;
    
        let form = btnUpdate.parentNode.parentNode.parentNode;
    
        let todo = form.querySelector('.todo');
    
        let label = todo.querySelector('label');
    
        if (label) {
            item = label.innerText;
        } else {
            let _input = todo.querySelector('input');
    
            item = _input.value;
        }
    
        index = todo.querySelector('[name="index"]').getAttribute('value');
    
        /* Send `DELETE` event with Ajax. */
        superagent
            .delete(`${baseURL}/todo/`)
            .send({
                item: item,
                index: Number(index)
            })
            .set('accept', 'json')
            .then(function (res) {
                clearMessage();
    
                /* Remove the whole form. */
                let form = btnUpdate.parentNode.parentNode.parentNode;
    
                form.parentNode.removeChild(form);
            })
            .catch(function (err) {
                if (err.response) {
                    showMessage(err.response.message);
                }
            });
    }, false);
    

    取得該項目的值和索引後,會觸發一個 Ajax 請求。該請求以非同步的方式對 /todo/ 路徑發出 DELETE 請求方法。

    我們同樣用 Event.preventDefault() 避免觸發表格傳送事件,因為我們不需要重刷頁面。

    為了處理該 Ajax 請求,我們在網頁後端程式中註冊相對應的事件處理器:

    mux.DELETE("/todo/", deleteTODOHandler)
    

    該事件處理器的程式碼如下:

    func deleteTODOHandler(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
        decoder := json.NewDecoder(r.Body)
    
        var t TODO
        err := decoder.Decode(&t)
        if err != nil {
            ErrorMessage(w, http.StatusUnprocessableEntity, "Failed to parse input")
            return
        }
    
        db.Table("todos").Where("id == ?", t.Index).Delete(struct {
            ID   uint
            Todo string
        }{})
    
        data := struct {
            Message string `json:"message"`
        }{
            Message: "TODO item deleted",
        }
    
        json, _ := json.Marshal(data)
    
        w.Header().Set("Content-Type", "application/json")
        w.Write(json)
    }
    

    這段程式碼的寫法和先前雷同,讀者應可自行閱讀。

    結語

    在本範例程式中,我們以 Ajax 改寫先前的 TODO 清單程式。讀者可以和前文相互比較兩隻範例程式的差異。

    由於我們使用 Ajax 請求傳接資料,不受到瀏覽器的限制,可以放心地使用各種 HTML5 支援的 HTTP 請求方法。由於使用 Ajax 請求的網頁程式大體上在使用者體驗會比較好,應儘量把網頁程式改用 Ajax 傳輸資料。

    然而,當我們使用 Ajax 請求傳接資料後,網頁後端不再負責大部分的頁面生成,頁面生成的任務就會移到前端來。我們比較兩個範例程式後,可以發現本範例程式的前端程式碼明顯增長不少。使用 React、Vue.js 等前端框架可以縮短前端程式的程式碼,但學習新框架則是要付出的成本。

    【分享本文】
    Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Yahoo
    【追蹤本站】
    Facebook Facebook Twitter