位元詩人 [Golang] 網頁設計教學:使用 Ajax 以非同步模式傳遞資料

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

傳統的網頁程式,使用 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 等前端框架可以縮短前端程式的程式碼,但學習新框架則是要付出的成本。

關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。