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