translate lesson 7 to zh-tw

pull/206/head
CRTao 4 years ago
parent 93c02d6d70
commit cbdc177742

@ -0,0 +1,307 @@
# 建立銀行網頁應用程式 Part 1HTML 模板與網頁路由
## 課前測驗
[課前測驗](https://nice-beach-0fe9e9d0f.azurestaticapps.net/quiz/41)
### 大綱
自從 JavaScript 出現在瀏覽器後,網頁開始變得更複雜、更多互動。網頁技術已經普遍地用於建立功能齊全的應用程式,執行在瀏覽器上,我們稱之為[網頁應用程式](https://zh.wikipedia.org/zh-tw/%E7%BD%91%E7%BB%9C%E5%BA%94%E7%94%A8%E7%A8%8B%E5%BA%8F)。基於網頁應用程式的高互動性,使用者不會想在互動後做所有頁面載入所需的等待。這也是為什麼 JavaScript 使用 DOM 來更新 HTML提供使用者更流暢的網頁體驗。
在這堂課程中,我們會譜出銀行網頁應用程式的基礎,使用 HTML 模板建立不同的畫面,各自顯示並更新內容,而不必每次都需要載入整個 HTML 頁面。
### 開始之前
你需要一個網頁伺服器來測試我們要建的專案。如果你還沒有,你可以安裝 [Node.js](https://nodejs.org) 並在你的專案資料夾中使用指令 `npx lite-server`。這會建立一個本地端的網頁伺服器,在瀏覽器上開啟你的網頁程式。
### 準備
在你的電腦上,建立資料夾 `bank`,並在裡面建立檔案 `index.html`。我們以這個 HTML [樣板](https://en.wikipedia.org/wiki/Boilerplate_code)來開始:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bank App</title>
</head>
<body>
<!-- This is where you'll work -->
</body>
</html>
```
---
## HTML 模板(templates)
如果你想在同一個網頁上建立不同的畫面,其中一種方法是各自建立一個 HTML 檔給每一個你想呈現的子畫面。然而,這個方式有許多不便之處:
- 你需要在切換頁面時,重新載入整個網頁。這會很花時間。
- 在不同子頁面上共享數據會是一大難題。
另一個解決方案是只有一個 HTML 檔案,並使用 `<template>` 元素定義多個 [HTML 模板](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template)。
一個模板提供可重複利用的 HTML 區塊,它不會顯示在瀏覽器上,而在需要之時由 JavaScript 以呈現出來。
### 課題
我們會建立一個銀行網頁應用程式,其中包含兩個子畫面:登入頁面與儀表板頁面。首先,我們在網頁應用程式的 HTML body 上,建立放置區來放置模板的子頁面。
```html
<div id="app">Loading...</div>
```
我們給它 `id`,以利後續 JavaScript 對它的追蹤。
> 提示:因為它裡面元素的內容會被置換,我們可以建立載入中訊息或提示,在應用程式載入時顯示出來。
接下來,我們加入下列的 HTML 模板,給登入畫面使用。現在我們只加入一行標題與一個有連結的區塊,進行簡單的功能。
```html
<template id="login">
<h1>Bank App</h1>
<section>
<a href="/dashboard">Login</a>
</section>
</template>
```
接著,加入另一個 HTML 模板給儀表板頁面。這個頁面就會包含不同的區塊:
- 包含標題與登出連結的網頁標頭
- 現在的銀行帳戶餘額
- 一個歷史交易清單的表格
```html
<template id="dashboard">
<header>
<h1>Bank App</h1>
<a href="/login">Logout</a>
</header>
<section>
Balance: 100$
</section>
<section>
<h2>Transactions</h2>
<table>
<thead>
<tr>
<th>Date</th>
<th>Object</th>
<th>Amount</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
</template>
```
> 提示:在建立 HTML 模板時,如果你想知道它的呈現樣子為何,你可以註解掉 `<template>``</template>`。使用 `<!-- -->` 來註解它們。
✅ 你知道為什麼我們需要使用模板的 `id` 屬性嗎?我們可以使用別的屬性,例如 classes 嗎?
## 利用 JavaScript 顯示模板
現在,如果你使用瀏覽器打開你的應用程式,你會看到畫面卡在 `Loading...` 的畫面。那是因為我們需要為它新增一些 JavaScript 的程式碼來顯示出這些 HTML 模板。
展現模板通常需要三個步驟:
1. 在 DOM 內接收模板元素,舉例來說,使用 [`document.getElementById`](https://developer.mozilla.org/en-US/docs/Web/API/Document/getElementById)。
2. 複製模板元素,使用 [`cloneNode`](https://developer.mozilla.org/en-US/docs/Web/API/Node/cloneNode)。
3. 將複製元素接到 DOM 的顯示元素上,例如使用 [`appendChild`](https://developer.mozilla.org/en-US/docs/Web/API/Node/appendChild)。
✅ 我們為什麼需要在接到 DOM 前,複製一份模板?你能想像如果我們省略了此步驟後,會發生什麼事嗎?
### 課題
在資料夾中建立新檔案 `app.js`,並在你的 HTML 檔案的 `<head>` 區塊中中匯入這個新檔案:
```html
<script src="app.js" defer></script>
```
`app.js` 中,我們建立新函式 `updateRoute`
```js
function updateRoute(templateId) {
const template = document.getElementById(templateId);
const view = template.content.cloneNode(true);
const app = document.getElementById('app');
app.innerHTML = '';
app.appendChild(view);
}
```
這裡做的事情就是我們上述提及過的三個步驟。我們使用 `templateId` 展現了模板,並將複製的內容接在我們的放置區中。注意我們需要使用 `cloneNode(true)` 來複製整個模板的子樹。
現在我們呼叫這個函式,指定特定的模板並觀察結果。
```js
updateRoute('login');
```
✅ 程式碼中 `app.innerHTML = '';` 的目的為何?如果刪去它會發生什麼事?
## 建立網頁路由(Routing)
當提及網頁應用程式時,我們稱呼 *路由(Routing)* 來連接**網址(URLs)**到特定的畫面上,呈現相關內容。一個含有多個 HTML 檔的網頁,網址又象徵著檔案路徑,這能自動地完成網址與檔案的轉換。舉例來說,專案資料夾內有這些檔案:
```
mywebsite/index.html
mywebsite/login.html
mywebsite/admin/index.html
```
若我們建立網路伺服器,根目錄為 `mywebsite`,則 URL 路由為:
```
https://site.com --> mywebsite/index.html
https://site.com/login.html --> mywebsite/login.html
https://site.com/admin/ --> mywebsite/admin/index.html
```
然而,在我們的網頁應用中,我們使用單一個 HTML 檔包含所有的子畫面到其中,所以預設的路由行為並不能幫助到本次專案。我們需要手動進行連接,使用 JavaScript 更新該被顯示出來的模板。
### 課題
我們使用簡單的物件來達成 URL 網址與模板的[關聯實體關係](https://en.wikipedia.org/wiki/Associative_array)。加入這個物件到 `app.js` 檔案的最上方。
```js
const routes = {
'/login': { templateId: 'login' },
'/dashboard': { templateId: 'dashboard' },
};
```
現在,我們對函式 `updateRoute` 做一些更動。我們不直接將 `templateId` 作為參數傳遞,而是接收現在的 URL 網址,在使用關聯表來取得相對應的模板 ID 數值。我們可以使用 [`window.location.pathname`](https://developer.mozilla.org/en-US/docs/Web/API/Location/pathname) 來取得網址的部分路徑。
```js
function updateRoute() {
const path = window.location.pathname;
const route = routes[path];
const template = document.getElementById(route.templateId);
const view = template.content.cloneNode(true);
const app = document.getElementById('app');
app.innerHTML = '';
app.appendChild(view);
}
```
這邊我們建立了模板的路由關係。你可以藉由修改網址,來測試你的網頁是否正確的轉移。
✅ 如果你輸入了不存在的網址,它會發生什麼事?我們該如何解決呢?
## 加入網頁訪問
下一個步驟為在不更改網址的情況下,新增網頁訪問的途徑。這會做出兩件事情:
1. 更新現在的網址
2. 更新要被顯示的模板到新的網址中
我們已經完成了第二點,藉由使用函式 `updateRoute` 來完成,所以我們需要釐清該如何更新現在的網址。
我們需要使用 JavaScript詳細來看為 [`history.pushState`](https://developer.mozilla.org/en-US/docs/Web/API/History/pushState),更新網址位置並建立瀏覽紀錄,同時不更新整個 HTML 頁面。
> 筆記:網頁超連結元素 [`<a href>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a) 可以建立不同網址的連接,但它預設上會讓瀏覽器重新載入 HTML 檔。我們需要手動新增 JavaScript 處理路由以避免此行為發生,在點擊事件中使用函式 preventDefault() 。
### 課題
我們來建立新的函式,讓應用程式得以做網頁的訪問:
```js
function navigate(path) {
window.history.pushState({}, path, window.location.origin + path);
updateRoute();
}
```
這個方法根據導入的路徑位置,更新了現在的網址位置,再更新模板上去。`window.location.origin` 回傳了網址的根路徑,允許我們重新構築完整的網址。
現在,藉由上述的函式,我們可以解決找不到網頁路徑的問題。我們修改函式 `updateRoute`,在找不到該網頁時強制轉移到一個存在的網頁。
```js
function updateRoute() {
const path = window.location.pathname;
const route = routes[path];
if (!route) {
return navigate('/login');
}
...
```
如果找不到網頁路由時,我們會導往 `login` 的頁面。
現在,我們建立新的函式,在連結被點擊時取得網址位置,並避免瀏覽器進行預設上的重新載入:
```js
function onLinkClick(event) {
event.preventDefault();
navigate(event.target.href);
}
```
現在我們完成應用程式的網頁訪問系統,在 HTML 檔的 *Login**Logout* 連結加入此函式。
```html
<a href="/dashboard" onclick="onLinkClick()">Login</a>
...
<a href="/login" onclick="onLinkClick()">Logout</a>
```
使用 [`onclick`](https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers/onclick) 屬性會將 `click` 事件連接到 JavaScript 程式碼中,這邊會再呼叫函式 `navigate()`
試著點擊這些連結,你應該能造訪網頁中不同的的畫面了。
`history.pushState` 這個方法是 HTML5 標準的一部份,支援在[所有當代的瀏覽器](https://caniuse.com/?search=pushState)上。如果你要為舊款的瀏覽器設計網頁應用程式的話,這邊有一個技巧來加在這個 API 上:在路徑前面加上 [hash (`#`)](https://en.wikipedia.org/wiki/URI_fragment),你可以完成網頁路由與不須重載網頁的功能,它的目的就是在同一個網頁中做內部連結的切換。
## 處理瀏覽器的「上一頁」與「下一頁」
使用 `history.pushState` 會建立瀏覽器的瀏覽紀錄。你可以使用瀏覽器的*上一頁*來確認,它應該要能呈現像這樣的畫面:
![瀏覽歷史的截圖](../history.png)
點擊上一頁數次,你會看到網址會改變且歷史紀錄也更新上去了,但同一個模板還是被顯示出來。
這是因為網頁不知道該如何依據歷史紀錄來呼叫 `updateRoute()`。如果你閱讀了 [`history.pushState` 技術文件](https://developer.mozilla.org/en-US/docs/Web/API/History/pushState),你會發現如果狀態改變 ── 同時代表著網址改變 ── [`popstate`](https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event) 事件就會被觸發。我們會利用此特徵來修復這個問題。
### 課題
為了在瀏覽器歷史改變時更新該被顯示的模板,我們會以新函式來呼叫 `updateRoute()`。我們在 `app.js` 檔最下方加入:
```js
window.onpopstate = () => updateRoute();
updateRoute();
```
> 筆記:我們在這裡使用[箭頭函式](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions),簡短地宣告 `popstate` 事件處理器。它與正規的函式的功能是一樣的。
這是關於箭頭函式的回想影片:
[![箭頭函式](https://img.youtube.com/vi/OP6eEbOj2sc/0.jpg)](https://youtube.com/watch?v=OP6eEbOj2sc "箭頭函式")
> 點擊上方圖片以觀看關於箭頭函式的影片。
現在,試著點擊瀏覽器上的上一頁與下一頁,檢查這次模板是否正確地更新出來。
---
## 🚀 挑戰
加入新的模板與對應的關聯表,顯示出本應用程式第三頁的功能 ── 帳戶餘額。
## 課後測驗
[課後測驗](https://nice-beach-0fe9e9d0f.azurestaticapps.net/quiz/42)
## 複習與自學
網頁路由是網頁開發中很棘手的部分,特別是將網頁切換轉變為單一頁面應用程式(Single Page Application)。閱讀關於[Azure Static Web App 提供服務的方式](https://docs.microsoft.com/en-us/azure/static-web-apps/routes?WT.mc_id=academic-13441-cxa)以處理網頁路由。你能解釋為什麼文件上的某些決定會如此重要呢?
## 作業
[增進網頁路由](assignment.zh-tw.md)

@ -0,0 +1,14 @@
# 增進網頁路由
## 簡介
我們的網頁路由的定義只包含模板的 ID。但當顯示新的網頁頁面時我們或許會用到更多東西。讓我們來增進我們的網頁路由方式新增兩項功能
- 給各個模板標題,在模板切換後同時更新網頁視窗的標題。
- 加入額外選項,在模板切換後執行特定程式。我們希望在切換到儀表板頁面時,在開發者命令欄顯示 `'Dashboard is shown'`
## 學習評量
| 作業內容 | 優良 | 普通 | 待改進 |
| -------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------ |
| | 兩個新功能運作正常,標題與程式也能執行新的 `routes` 規則 | 兩個新功能運作正常,但行為是 hardcoded 上去而非使用 `routes` 規則。新的路由規則無法完整地運作 | 新功能不完全或不正常運行 |

@ -0,0 +1,297 @@
# 建立銀行網頁應用程式 Part 2登入與註冊表單
## 課前測驗
[課前測驗](https://nice-beach-0fe9e9d0f.azurestaticapps.net/quiz/43)
### 大綱
在大多數當代網頁應用程式中,你可以建立自己的帳戶來擁有自己的私人空間。許多用戶在同一時間可以存取同一個網頁應用程式,你就必須有一套機制分開儲存不同用戶的資料並顯示適當的資訊。我們不會涉及到如何管理[用戶個資的安全](https://zh.wikipedia.org/wiki/%E8%BA%AB%E4%BB%BD%E9%AA%8C%E8%AF%81),它是個相當廣泛的主題,我們僅會確保每個用戶能在這款銀行應用上建立一到多個數位帳戶。
在這單元中,我們會使用 HTML 表單來新增登入與註冊的功能。我們會看到如何使用伺服器 API 傳遞資料,定義基本的用戶字串輸入之檢查機制。
### 開始之前
你需要完成第一單元 [HTML 模板與網頁路由](../../1-template-route/translations/README.zh-tw.md)的應用程式。你還需要安裝 [Node.js](https://nodejs.org) 與在本地端[運行伺服器 API](../../api/translations/README.zh-tw.md)以傳輸建立帳戶所需的資料。
你可以測試伺服器是否運作正常,在終端機內輸入指令:
```sh
curl http://localhost:5000/api
# -> 會回傳結果 "Bank API v1.0.0"
```
---
## 表單與其控制
`<form>` 元素打包了 HTML 文件中使用者輸入與提交資料的地方。有許多種使用者介面(UI)以表單的方式呈現,最常見的內容會包含 `<input>``<button>` 元素。
有許多種 `<input>` 的[種類](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input),舉例來說,若要建立使用者輸入使用者名稱的地方,你可以:
```html
<input id="username" name="username" type="text">
```
`name` 屬性同時亦是表單傳輸資料的名稱。`id` 屬性是用來與 `<label>` 做表單控制(form control)的連接。
> 花點時間看看 [`<input>` 種類](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input)的清單與[其他表單控制](https://developer.mozilla.org/en-US/docs/Learn/Forms/Other_form_controls),讓你在建立使用者介面時,有全部供你使用的原生 UI 元素可以參考。
✅ 紀錄一下 `<input>` 是種[空元素](https://developer.mozilla.org/en-US/docs/Glossary/Empty_element),你*不應該*在它後面加上對應的結束標籤。然而,你仍然可以在它的後面使用 `<input/>`,這沒有強制規定。
表單中的 `<button>` 元素是有些特別。如果你沒有指定它的 `type` 屬性,它會在你輸入文字時,自動地提交表單內容給伺服器。這邊有一些你可以設定的 `type` 內容:
- `submit` `<form>` 內的預設型態,按鈕會觸發表單提交這項行為。
- `reset` 按鈕會重置所有表單控制回初始狀態。
- `button` 在按鈕按下時不執行預設行為。你可以藉由 JavaScript 自由定義之後的動作。
### 課題
`login` 模板內加入表單。我們需要*使用者名稱(username)*的輸入框與*登入(Login)*的按鈕。
```html
<template id="login">
<h1>Bank App</h1>
<section>
<h2>Login</h2>
<form id="loginForm">
<label for="username">Username</label>
<input id="username" name="user" type="text">
<button>Login</button>
</form>
</section>
</template>
```
如果你仔細地看,你會注意到我們在這裡還加了 `<label>` 元素。`<label>` 元素被用來新增文字到 UI 上,譬如說我們的使用者名稱。為了讓表單得以被閱讀,標籤是很重要的,此外它還有額外的優點:
- 連結標籤到表單控制上,它能幫助使用者的額外工具,好比說螢幕報讀器,理解接下來該提供何種資料。
- 你可以點擊標籤,它會跳轉到相對應的輸入框,讓使用觸控型裝置的用戶更容易操作。
> [網頁親和力](https://developer.mozilla.org/en-US/docs/Learn/Accessibility/What_is_accessibility)是非常重要但常被忽視的主題。感謝[語義化 HTML 元素](https://developer.mozilla.org/en-US/docs/Learn/Accessibility/HTML)的幫助,建立無障礙的網頁內容變得更加容易。你可以[閱讀更多有關網頁親和力的文章](https://developer.mozilla.org/en-US/docs/Web/Accessibility),避免觸犯到常見的錯誤並成為負責任的開發者。
現在,我們加入第二張表單給用戶註冊使用,就像前一張一樣:
```html
<hr/>
<h2>Register</h2>
<form id="registerForm">
<label for="user">Username</label>
<input id="user" name="user" type="text">
<label for="currency">Currency</label>
<input id="currency" name="currency" type="text" value="$">
<label for="description">Description</label>
<input id="description" name="description" type="text">
<label for="balance">Current balance</label>
<input id="balance" name="balance" type="number" value="0">
<button>Register</button>
</form>
```
藉由 `value` 屬性,我們可以定義輸入框內的預設值。
注意一下 `balance` 的輸入類型為 `number`。它看起來與其他輸入框不一樣嗎?試著與它互動看看。
✅ 你能只利用鍵盤造訪表格,與表格互動嗎?你是如何做到的?
## 提交資料給伺服器
現在我們有可以使用的 UI 了,下一個步驟要將資料送給我們的伺服器。讓我們來快速地測試一下程式:在點擊 *Login**Register* 按鈕後,發生了什麼事?
你有注意到瀏覽器的網址列改變了嗎?
![截圖:點擊 Register 按鈕後,瀏覽器網址列改變](../images/click-register.png)
`<form>` 預設的行為:使用 [GET 方法](https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.3)提交表格,將表格資料接在網址後面,傳送給目前網址的伺服器。然而這個方法有一些缺點:
- 資料大小有上限限制(大約 2000 字元)
- 可以直接在網址內看到資料(對密碼而言,這並不恰當)
- 它不能做檔案的上傳
這也是為什麼你需要將它轉換為 [POST 方法](https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.5),將表單資料存在 HTTP 請求的內容中。這樣就不會遇到上述的限制。
> POST 是常見的資料傳輸方法,[在一些特別的情況下](https://www.w3.org/2001/tag/doc/whenToUseGet.html),使用 GET 方法相對起來比較恰當。例如進行搜尋的時候。
### 課題
加入 `action``method` 屬性到註冊表單之中:
```html
<form id="registerForm" action="//localhost:5000/api/accounts" method="POST">
```
現在,試著以你的名字申請新的帳戶。在點擊 *Register* 按鈕後,你應該能看到像這樣的畫面:
![瀏覽器網址為 localhost:5000/api/accounts並顯示 JSON 的資料字串。](../images/form-post.png)
若所有事情都運作正常,伺服器應該會回應你的請求,附帶 [JSON](https://www.json.org/json-en.html) 包含著你剛建立的帳戶資料。
✅ 試著以相同名字再註冊一次。發生了什麼事?
## 不重新載入地提交資料
你可能會注意到,這些行動間出現了一個小問題:在提交表單時,我們離開了網頁應用,瀏覽器又重新導回到伺服器的網址。我們試著避免網頁應用重新載入所有的頁面,做出[單一頁面應用程式 (SPA)](https://zh.wikipedia.org/zh-tw/%E5%8D%95%E9%A1%B5%E5%BA%94%E7%94%A8)。
為了讓傳遞資料給伺服器時,不發生頁面重新載入的情況,我們需要使用 JavaScript。
比起直接在 `<form>` 元素的 `action` 屬性加入網址,你可以使用 `javascript:` 字串接在程式語句前頭來執行自訂的行為。使用這方法也意味著你需要額外修改一些原本瀏覽器會做的行為。
- 接收表單資料
- 轉換並編碼表單資料成合適的格式
- 建立 HTTP 請求並傳遞給伺服器
### 課題
將註冊表單的 `action` 替換為:
```html
<form id="registerForm" action="javascript:register()">
```
開啟 `app.js`,加入新的函式 `register`
```js
function register() {
const registerForm = document.getElementById('registerForm');
const formData = new FormData(registerForm);
const data = Object.fromEntries(formData);
const jsonData = JSON.stringify(data);
}
```
我們使用 `getElementById()` 蒐集表單的元素,使用 [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) 協助從表單控制中取出 key/value 的數據對。
之後,利用 [`Object.fromEntries()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/fromEntries) 轉換資料成正規物件,最後再將檔案轉成 [JSON](https://www.json.org/json-en.html) ── 一個在網路上常見的資料交換格式。
現在資料已經準備提交給伺服器了。建立新函式 `createAccount`
```js
async function createAccount(account) {
try {
const response = await fetch('//localhost:5000/api/accounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: account
});
return await response.json();
} catch (error) {
return { error: error.message || 'Unknown error' };
}
}
```
這個函式做了什麼?首先,注意關鍵字 `async`,代表著函式包含了[**非同步化程式**](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function)。在與關鍵字 `await` 一起使用時,它會在繼續運行程式前,等待非同步的程式被執行,就像等待伺服器回應一樣。
這邊有關於使用 `async/await` 的影片:
[![Async 與 Await 管理 promises](https://img.youtube.com/vi/YwmlRkrxvkk/0.jpg)](https://youtube.com/watch?v=YwmlRkrxvkk "Async 與 Await 管理 promises")
> 點擊上方圖片以觀看關於 async/await 的影片。
我們使用 API `fetch()` 來傳送 JSON 資料給伺服器。這個方法需要使用兩個參數:
- 伺服器的網址,在此使用 `//localhost:5000/api/accounts`
- 網頁請求的設定,就是我們定義 `POST` 方法與提供請求的 `body`。當我們傳輸 JSON 資料給伺服器,我們還需要在標頭的 `Content-Type` 定為 `application/json`,伺服器才知道該如何解讀裡面的內容。
當伺服器以 JSON 回應請求後,我們可以使用 `await response.json()` 來取得 JSON 的內容並回傳結果。注意在此為非同步程式的方法,我們使用關鍵字 `await` 回傳任何在解讀封包時產生的錯誤訊息。
現在,在函式 `register` 中呼叫 `createAccount()`
```js
const result = await createAccount(jsonData);
```
因為我們在這此使用了關鍵字 `await`,我們需要在註冊函式前新增關鍵字 `async`
```js
async function register() {
```
最後,我們儲存一些紀錄來檢查結果。最後的函式應該會如下方格式:
```js
async function register() {
const registerForm = document.getElementById('registerForm');
const formData = new FormData(registerForm);
const jsonData = JSON.stringify(Object.fromEntries(formData));
const result = await createAccount(jsonData);
if (result.error) {
return console.log('An error occured:', result.error);
}
console.log('Account created!', result);
}
```
過程有些冗長,但我們達成了!當你開啟[瀏覽器開發者工具](https://developer.mozilla.org/en-US/docs/Learn/Common_questions/What_are_browser_developer_tools),試著註冊新的帳戶,你應該能看到網頁並沒有改變,但命令欄中會顯示帳戶成功註冊的訊息。
![瀏覽器命令欄中顯示紀錄訊息之截圖](../images/browser-console.png)
✅ 你覺得傳給伺服器的資料是安全的嗎?其他人有辦法攔截網頁請求嗎?你可以閱讀 [HTTPS](https://en.wikipedia.org/wiki/HTTPS),了解更多關於安全的資料傳輸。
## 資料驗證
試著在註冊新帳戶時,不輸入你的使用者名稱,你會發現伺服器回傳了錯誤狀態訊息:[400 (Bad Request)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400#:~:text=The%20HyperText%20Transfer%20Protocol%20(HTTP,%2C%20or%20deceptive%20request%20routing).)。
在傳輸資料給伺服器之前,最好先[驗證表單資料](https://developer.mozilla.org/en-US/docs/Learn/Forms/Form_validation),以確保我們傳送合法的網頁請求。 HTML5 表單控制內建包含了驗證方法,使用了多樣的屬性: controls provides built-in validation using various attributes:
- `required` 輸入框必須被填寫,否則表單不能被提交。
- `minlength``maxlength` 定義輸入框的文字下限與文字上限。
- `min``max` 定義輸入框的數字下限與數字上限。
- `type` 定義輸入框內的資料格式,例如`數字`、`email`、`檔案`或是[其他內建的格式](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input)。這個屬性可能會改變表單控制的表現方法。
- `pattern` 允許定義[正規表示法](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions)的字串,測試輸入的內容是否滿足它。
> 提示:你可以自定義表單控制的呈現方法,利用 CSS pseudo-classes `:valid``:invalid` 判斷內容是否合理。
### 課題
在建立新的合法帳戶時,有兩個必須被填寫的輸入框:使用者名稱與資產狀態,而其他選項則是可有可無。現在更新表單的 HTML 語法,使用 `required` 屬性並標記提示在標籤中:
```html
<label for="user">Username (required)</label>
<input id="user" name="user" type="text" required>
...
<label for="currency">Currency (required)</label>
<input id="currency" name="currency" type="text" value="$" required>
```
伺服器並沒設定輸入框的文字上限,定義合理的文字輸入上限是必要的。
在文字框內加入 `maxlength` 屬性:
```html
<input id="user" name="user" type="text" maxlength="20" required>
...
<input id="currency" name="currency" type="text" value="$" maxlength="5" required>
...
<input id="description" name="description" type="text" maxlength="100">
```
現在,如果文字框並沒有滿足我們所定義的規則時,在點擊了 *Register* 按鈕後,你會看到:
![傳輸錯誤表單而出現驗證失敗的截圖](../images/validation-error.png)
這類在傳輸資料給伺服器*之前*的驗證系統稱之為**用戶端(client-side)**驗證。但注意有些資料是沒有辦法在傳輸前被驗證的。舉例來說,我們沒辦法在發出請求前,確認是否已經存在著一組相同姓名的帳戶。伺服器上額外的驗證措施就稱之為**伺服器端(server-side)**驗證。
通常這兩個驗證都需要去編寫,用戶端驗證能及時回饋給用戶,提升使用者體驗;伺服器端驗證確保你要處理的用戶資料是合理且安全的。
---
## 🚀 挑戰
當相同使用者名稱的帳戶已經存在時,在 HTML 上顯示錯誤訊息。
這邊有做過一些造型的最終登入頁面範本。
![加上 CSS 造型的登入頁面截圖](../images/result.png)
## 課後測驗
[課後測驗](https://nice-beach-0fe9e9d0f.azurestaticapps.net/quiz/44)
## 複習與自學
開發者在建立表單時需要發揮他們的創意,尤其是策畫資料驗證的規則。在 [CodePen](https://codepen.com) 上學習不同表單流程,你能發現什麼有趣且令人發想的表單嗎?
## 作業
[造型化你的銀行程式](assignment.zh-tw.md)

@ -0,0 +1,13 @@
# 造型化你的銀行程式
## 簡介
建立新的檔案 `styles.css`,匯入到你的 `index.html` 檔案中。藉由 CSS 檔,你能讓*登入*與*儀表板*頁面看起來更漂亮且整潔。試著為你的程式加入主題色彩,對應到你的品牌。
> 提示:你可以修改 HTML 檔,在必要時新增元素與 classes。
## 學習評量
| 作業內容 | 優良 | 普通 | 待改進 |
| -------- | -------------------------------------------------- | ------------------------------------ | ---------------------------------------- |
| | 所有的頁面整潔且易讀:統一的主題色彩且排版顯示正常 | 頁面有調整過,但缺乏主題且排版有瑕疵 | 頁面缺乏造型,排版凌亂且頁面資訊難以理解 |

@ -0,0 +1,336 @@
# 建立銀行網頁應用程式 Part 3取得並使用資料
## 課前測驗
[課前測驗](https://nice-beach-0fe9e9d0f.azurestaticapps.net/quiz/45)
### 大綱
每一個網頁應用程式的核心為*資料*。資料有很多種格式,但它們的目的都是為了顯示使用者需要的資訊。網頁應用程式變得高互動性與複雜,使用者如何取得內容並與之進行互動變成網頁開發重要的一環。
在這堂課中,我們會了解伺服器是如何非同步地取得資料,並在不重新載入 HTML 的情況下,利用這些資料顯示在網頁上。
### 開始之前
你需要先完成系列課程 ── [登入與註冊表單](../../2-forms/translations/README.zh-tw.md)。你還需要安裝 [Node.js](https://nodejs.org) 並[執行伺服器 API](../../api/translations/README.zh-tw.md)。
你可以測試伺服器是否運作正常,在終端機中輸入指令:
```sh
curl http://localhost:5000/api
# -> 會回傳結果 "Bank API v1.0.0"
```
---
## AJAX 和取得資料
傳統的網頁在使用者點擊連結,或是提交表單資料時,重新載入全部的 HTML 頁面來更新網頁內容。每當資料要被更新時,伺服器就需要回傳全新的 HTML 頁面給瀏覽器處理,同時也干涉到使用者正在進行的動作,重新載入的機制也限制了許多互動功能。這種工作流程被稱為*多頁面應用程式(Multi-Page Application)*,簡稱 *MPA*
![多頁面應用程式的更新流程](../images/mpa.png)
網頁應用程式變得更加複雜,促使新的技術問世:[AJAX (Asynchronous JavaScript and XML)](https://zh.wikipedia.org/wiki/AJAX)。
這個技巧允許網頁應用程式使用 JavaScript 非同步地傳遞與接收伺服器的資料,不需要重新載入 HTML 頁面,也反映在更快速的更新速率與更流暢的使用者體驗。在接收伺服器的新資料時,目前的 HTML 頁面可以被 JavaScript 利用 [DOM](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model) API 更新。自此之後,這種流程演變成現今的[*單一頁面應用程式(Single-Page Application)**SPA*](https://zh.wikipedia.org/wiki/%E5%8D%95%E9%A1%B5%E5%BA%94%E7%94%A8)。
![單一頁面應用程式的更新流程](../images/spa.png)
在 AJAX 早期,唯一取得資料的 API 為 [`XMLHttpRequest`](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest)。但當代的瀏覽器已經建立出更方便且強大的 [`Fetch` API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API),它們使用 Promises 物件且更適合應用在 JSON 資料上。
> 許多當代瀏覽器支援 `Fetch API`,如果你想確認你的網頁應用程式是否運作在舊款的瀏覽器,檢查[caniuse.com 上的相容性測試](https://caniuse.com/fetch)是一個好方法。
### 課題
在[前一堂課程中](../../2-forms/translations/README.zh-tw.md),我們製作出註冊表單來建立新帳戶。現在我們來加入新程式,使用現有的帳戶登入,並取得其相關資料。開啟檔案 `app.js`並新增函式 `login`
```js
async function login() {
const loginForm = document.getElementById('loginForm')
const user = loginForm.user.value;
}
```
現在我們利用 `getElementById()` 接收表單元素,並藉由 `loginForm.user.value` 取得輸入框內的使用者名稱。每一個表單控制可以以各自名稱(即 HTML 內的 `name` 屬性)來存取。
就像我們為註冊帳戶作的事一樣,我們建立另一個函式來執行伺服器請求,但這次是為了取得帳戶資料:
```js
async function getAccount(user) {
try {
const response = await fetch('//localhost:5000/api/accounts/' + encodeURIComponent(user));
return await response.json();
} catch (error) {
return { error: error.message || 'Unknown error' };
}
}
```
我們使用 `fetch` API 來向伺服器做非同步資料請求。這次我們不需要添加額外的參數,如網址,我們只詢問資料內容。預設上,`fetch` 建立出 [`GET`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET) HTTP 請求,即我們想做的事情。
✅ 函式 `encodeURIComponent()` 可以轉換網址內的特殊字元。如果我們不呼叫這個函式,而是直接將 `user` 這項數值放入網址中,這會發生什麼問題?
讓我們來更新函式 `login`,使用 `getAccount`
```js
async function login() {
const loginForm = document.getElementById('loginForm')
const user = loginForm.user.value;
const data = await getAccount(user);
if (data.error) {
return console.log('loginError', data.error);
}
account = data;
navigate('/dashboard');
}
```
首先,`getAccount` 是一個非同步函式,它利用關鍵字 `await` 等待伺服器的回傳結果。就如其他伺服器請求一樣,我們也必須要處理錯誤的情況。現在我們只加錯誤訊息給這些情況,之後再回過頭解決這些問題。
接著,我們必須儲存資料,在之後可以輸出成儀表板的資訊。目前變數 `account` 還沒存在,我們建立它的全域變數在檔案最上方:
```js
let account = null;
```
在用戶資料存到變數中後,我們可以使用函式 `navigate()` 從*登入*頁面切換到*儀表板*頁面。
最後,在登入表單提交時,呼叫函式 `login`。修改 HTML 語法:
```html
<form id="loginForm" action="javascript:login()">
```
測試註冊功能,以及新註冊的帳戶的登入行為是否運作正常。
在進行下一步驟前,我們還必須完成函式 `register`。在此函式的最下方加入:
```js
account = result;
navigate('/dashboard');
```
✅ 你知道在預設上,你只能從*同一個網域(domain)與連接埠(port)*的網頁呼叫伺服器 APIs嗎這是瀏覽器強制性的安全機制。但我們的網頁應用程式在 `localhost:3000` 上執行,而伺服器 API 則在 `localhost:5000` 上執行。為什麼這樣能正常運作?利用[跨來源資源共用 (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS),只要伺服器添加特殊的標頭檔到網頁回應中,我們就可以處理跨資源的 HTTP 請求,允許特殊的網域進行呼叫。
> 藉由前往[此課程](https://docs.microsoft.com/en-us/learn/modules/use-apis-discover-museum-art?WT.mc_id=academic-13441-cxa)學習更多有關 API 的資訊。
## 更新 HTML 顯示資料
現在取得完用戶資料,我們必須更新到現有的 HTML 上。我們已經知道如何接收 DOM 的元素,例子為 `document.getElementById()`。只要你有元素,這邊有一些 API 讓你修改,或是新增子元素上去:
- 使用 [`textContent`](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent) 屬性,你可以改變元素的文字內容。注意改變此數值會刪除它的所有子元素(若存在的話),並以該文字內容來替換它。同時,這也是個有效的方法來刪去所有的子成員,只要賦予它空字串 `''`
- 使用 [`document.createElement()`](https://developer.mozilla.org/en-US/docs/Web/API/Document/createElement) 與 [`append()`](https://developer.mozilla.org/en-US/docs/Web/API/ParentNode/append) 這兩方法,你可以建立並接上一到多個子元素成員。
✅ 使用 [`innerHTML`](https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML) 元素屬性也能改變 HTML 的內容,但這方法要避免使用。這可能會遭遇有關[跨網站指令碼 (XSS)](https://developer.mozilla.org/en-US/docs/Glossary/Cross-site_scripting)的攻擊。
### 課題
在來到儀表板畫面之前,我們還需要幫*登入*頁面加一件事。目前,如果你試著使用不存在的帳戶進行登入,訊息只會出現在命令欄中,而使用者不會發覺到任何事情改變,也不清楚網頁發生了什麼事。
我們在登入表單中新增顯示錯誤訊息的地方。最好的地方為登入按鈕 `<button>` 之前:
```html
...
<div id="loginError"></div>
<button>Login</button>
...
```
這個 `<div>` 元素為空的,代表著畫面不會印出任何訊息,直到我們添加內容進去。我們還給了它 `id`,讓 JavaScript 可以容易地存取它。
回到檔案 `app.js`,建立新的補助函數 `updateElement`
```js
function updateElement(id, text) {
const element = document.getElementById(id);
element.textContent = text;
}
```
這條就很直觀:給定元素的 *id**text*,它會更新 DOM 元素內符合 `id` 條件的文字內容。我們也使用這個方法到前面 `login` 函式的錯誤訊息中:
```js
if (data.error) {
return updateElement('loginError', data.error);
}
```
現在,試著以不合法的帳戶進行登入,你應該能看到像這樣的畫面:
![登入出現錯誤訊息之截圖](../images/login-error.png)
現在我們印出錯誤訊息,但螢幕報讀器並沒有做任何報讀。為了讓被動態加入的文字能被螢幕報讀器閱讀出來,我們需要使用 [Live Region](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions)。這邊我們使用一種 Live Region 的類型 alert
```html
<div id="loginError" role="alert"></div>
```
建立同樣的行為到函式 `register` 的錯誤訊息當中,也別忘了更新你的 HTML。
## 在儀表板顯示資訊
使用同樣的技巧,我們需要將帳戶資訊印在儀表板頁面中。
這是從伺服器接收到的帳戶資料物件:
```json
{
"user": "test",
"currency": "$",
"description": "Test account",
"balance": 75,
"transactions": [
{ "id": "1", "date": "2020-10-01", "object": "Pocket money", "amount": 50 },
{ "id": "2", "date": "2020-10-03", "object": "Book", "amount": -10 },
{ "id": "3", "date": "2020-10-04", "object": "Sandwich", "amount": -5 }
],
}
```
> 筆記:為了讓開發更加的容易,你可以使用已經存在資料的帳戶 `test`
### 課題
我們開始置換掉 HTML 檔內的 "Balance" 區域,加入放置區:
```html
<section>
Balance: <span id="balance"></span><span id="currency"></span>
</section>
```
我們還需要在下方新增區域來顯示帳戶資訊:
```html
<h2 id="description"></h2>
```
✅ 表示帳戶資訊的函式剛好為在內容的標題處,我們可以將它作為語義化的標頭。學習更多關於[標頭結構](https://www.nomensa.com/blog/2017/how-structure-headings-web-accessibility),它對於網頁親和力格外重要,也明顯地表達出頁面的標頭位置。
接著,我們在 `app.js` 檔案中加入新的函式來為放置區新增內容:
```js
function updateDashboard() {
if (!account) {
return navigate('/login');
}
updateElement('description', account.description);
updateElement('balance', account.balance.toFixed(2));
updateElement('currency', account.currency);
}
```
首先,我們需要先檢查帳戶的資料。使用我們之前建立的函式 `updateElement()` 來更新 HTML 檔。
> 為了讓帳戶餘額漂亮地呈現,我們使用 [`toFixed(2)`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toFixed) 這個方法,強迫數值只顯示到小數點第二位。
現在,每當儀表板被載入時,我們就需要呼叫函式 `updateDashboard()`。如果你已經完成[課程一的作業](../../1-template-route/translations/assignment.zh-tw.md),就不需要額外做處理,不然你可以使用接下來的設定。
加入這段程式碼到函式 `updateRoute()` 的下方:
```js
if (typeof route.init === 'function') {
route.init();
}
```
並更新路由定義:
```js
const routes = {
'/login': { templateId: 'login' },
'/dashboard': { templateId: 'dashboard', init: updateDashboard }
};
```
做完這些更動後,當儀表板要被呈現時,函式 `updateDashboard() 就會被呼叫。在你登入後就能看到帳戶的描述、餘額與交易狀況。
## 利用 HTML 模板動態建立表格列
在[第一堂課](../../1-template-route/translations/README.zh-tw.md)中,我們使用 HTML 模板與方法 [`appendChild()`](https://developer.mozilla.org/en-US/docs/Web/API/Node/appendChild) 來做出應用程式內的轉換。模板還能執行更小規模的行為,動態地改變一部份的頁面
我們使用類似的方式來顯示 HTML 表格中的交易清單。
### 課題
加入新的模板到 HTML 的 `<body>` 中:
```html
<template id="transaction">
<tr>
<td></td>
<td></td>
<td></td>
</tr>
</template>
```
這個模板表示單一條的表格列,其中包含了三格欄位:交易的*日期*、*物件* 與 *金額*
接著,加入 `id` 屬性到模板的表格 `<tbody>` 元素中,讓 JavaScript 能更容易地取得:
```html
<tbody id="transactions"></tbody>
```
當我們的 HTML 準備好時,我們切換到 JavaScript 檔案中,加入新函式 `createTransactionRow`
```js
function createTransactionRow(transaction) {
const template = document.getElementById('transaction');
const transactionRow = template.content.cloneNode(true);
const tr = transactionRow.querySelector('tr');
tr.children[0].textContent = transaction.date;
tr.children[1].textContent = transaction.object;
tr.children[2].textContent = transaction.amount.toFixed(2);
return transactionRow;
}
```
這個函式做就如它名字的功能:藉由剛建立的模板,建立出新的表格列並填入交易明細的資料。我們會在函式 `updateDashboard()` 中,利用它來更新表格:
```js
const transactionsRows = document.createDocumentFragment();
for (const transaction of account.transactions) {
const transactionRow = createTransactionRow(transaction);
transactionsRows.appendChild(transactionRow);
}
updateElement('transactions', transactionsRows);
```
這裡我們使用了方法 [`document.createDocumentFragment()`](https://developer.mozilla.org/en-US/docs/Web/API/Document/createDocumentFragment),建立新的 DOM 分段,再接到我們的 HTML 表格中。
我們還需要做一件事才能讓程式運作正常,目前函式 `updateElement()` 只能接受文字類型的內容。我們稍微修改一下程式碼:
```js
function updateElement(id, textOrNode) {
const element = document.getElementById(id);
element.textContent = ''; // Removes all children
element.append(textOrNode);
}
```
我們使用方法 [`append()`](https://developer.mozilla.org/en-US/docs/Web/API/ParentNode/append),它能連接文字或者是 [DOM 節點](https://developer.mozilla.org/en-US/docs/Web/API/Node)到父元素中,正好滿足我們的需求。
試著以 `test` 帳戶來登入,你應該能看到儀表板顯示出交易明細了 🎉。
---
## 🚀 挑戰
花功夫讓儀表板頁面看起來像是正規的銀行界面。如果你已經為你的應用程式做好造型,你可以試試 [media queries](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries) 來建立出[回應式網頁設計](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Responsive/responsive_design_building_blocks),它能完美地呈現在電腦或是行動裝置上。
這邊有造型過後的儀表板例子:
![造型化後的儀表板截圖](../../images/screen2.png)
## 課後測驗
[課後測驗](https://nice-beach-0fe9e9d0f.azurestaticapps.net/quiz/46)
## 作業
[重構並註解你的程式碼](assignment.zh-tw.md)

@ -0,0 +1,15 @@
# 重構並註解你的程式碼
## 簡介
正當你的程式碼越來越多時,頻繁地重構你的程式,讓程式碼容易去閱讀與維護變得十分重要。加入一些註解並重構檔案 `app.js` 來增進檔案的品質:
- 取出常數,好比說伺服器 API 的根網址
- 重構相同的程式碼:舉例來說,你可以建立函式 `sendRequest()` 來合併 `createAccount()``getAccount()`
- 重新編排你的程式和加入註解,讓它更容易地閱讀。
## 學習評量
| 作業內容 | 優良 | 普通 | 待改進 |
| -------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------- |
| | 程式碼有做註解,分塊地整理好。常數有被取出來且函式 `sendRequest()` 已設定完成 | 程式碼有做過處理,但仍可以藉由加入註解、排版與重構來增進品質 | 程式碼很雜亂,缺乏註解,常數與函式並沒有做規劃 |

@ -0,0 +1,284 @@
# 建立銀行網頁應用程式 Part 4 狀態控管的概念
## 課前測驗
[課前測驗](https://nice-beach-0fe9e9d0f.azurestaticapps.net/quiz/47)
### 大綱
隨著網頁應用越來越龐大,追蹤資料流的動向也是一種挑戰。程式取得了何種資料、網頁如何處理它、何時何處被更新上去……這些很容易地導致程式碼凌亂而難以維護。尤其是當你需要在不同頁面上做資料共享時,好比說使用者的資料。*狀態控管(state management)* 的觀念已經存在於所有程式中,我們也開始需要在開發複雜的網頁應用程式時,注意這個關鍵點。
在這個最終章內,我們會總覽整個程式並重新思考該如何管理程式狀態,讓瀏覽器能在任何時刻做重新整理,在不同的使用者階段維持資料的狀態。
### 開始之前
你需要先完成[取得資料](../../3-data/translations/README.zh-tw.md)的網頁開發章節。你還需要安裝 [Node.js](https://nodejs.org) 並於本地端[執行伺服器 API](../../api/translations/README.zh-tw.md)以管理使用者資料。
你可以測試伺服器是否運作正常,在終端機中輸入指令:
```sh
curl http://localhost:5000/api
# -> should return "Bank API v1.0.0" as a result
```
---
## 思考狀態控管
在[前一堂課](../../3-data/translations/README.zh-tw.md)中,我們介紹了應用程式基本的狀態,全域變數 `account` 提供登入帳戶的相關銀行資料。然而,現在的專案存在著一些瑕疵。試著在儀表板介面中重新整理。發生了什麼事?
目前我們的程式碼有三個問題:
- 網頁狀態並沒有被儲存,當瀏覽器重新整理時,會被導回登入頁面。
- 有許多函式會修改網頁狀態。隨著應用程式變大,我們很難去追蹤之後的改變,時刻地去更新相關的網頁狀態。
- 網頁狀態並不完整,當你*登出*帳戶時,帳戶資訊仍然顯示在登入頁面上。
我們是可以逐一的解決這些問題,但這樣會創造出許多獨立的程式碼,讓應用程式更複雜而難以去管理。或者是我們停下來思考一下我們的策略。
> 我們究竟要解決什麼問題?
[狀態控管(State management)](https://en.wikipedia.org/wiki/State_management)可以為兩項問題提供良好的解決方案:
- 如何讓應用程式中的資料流容易理解?
- 如何讓網頁狀態一直與使用者介面,或是相關物件進行同步?
一旦你處理好這些問題,其他問題可以被簡化,甚至被一併解決。有許多可能的方法能解決這些問題,但我們使用一種常見的解法:**中心化資料與更新方式**。資料流會呈現下列模式:
![HTML、使用者行為與網頁狀態的架構圖](../images/data-flow.png)
> 我們不會處理如何讓資料同步觸發頁面的更新,這比較像是關於[回應式程式設計](https://zh.wikipedia.org/wiki/%E5%93%8D%E5%BA%94%E5%BC%8F%E7%BC%96%E7%A8%8B)的更進階知識。當你更深入網頁開發領域時,這是個很好的發展方向。
✅ 有許多函式庫提供狀態管理的方式,[Redux](https://redux.js.org) 就是常見的選擇。閱讀它的概念與運作模式,這是種有效的的學習方式,讓你在大型的網頁開發中預測潛在的風險,並預想解決方案。
### 課題
我們會先做一些程式重構。替換掉 `account` 的定義:
```js
let account = null;
```
變成:
```js
let state = {
account: null
};
```
這個構想是要*中心化*應用程式資料到一個狀態物件中。目前我們只有 `account` 在狀態中,但這能提供未來新增新功能的基礎。
我們還需要更新與它相關的函式。在函式 `register()``login()` ,將 `account = ...` 替換為 `state.account = ...`
在函式 `updateDashboard()` 的上方,加入此行:
```js
const account = state.account;
```
這個重構並不會帶來任何提升,但這是之後改變上的基礎。
This refactoring by itself did not bring much improvements, but the idea was to lay out the foundation for the next changes.
## 追蹤資料改變
現在我們有 `state` 物件儲存資料了,接下來要來中心化這些更新。目標是能輕易地追蹤任何被觸發的改變。
為了避免改動 `state` 物件,我們考慮使它[*不可變*](https://zh.wikipedia.org/wiki/%E4%B8%8D%E5%8F%AF%E8%AE%8A%E7%89%A9%E4%BB%B6),意味著它不能被做任何的修改。
這也代表你必須建立新的狀態物件來替換它。藉由這個方式,你就有一套保護措施阻絕潛在非預期[風險](https://zh.wikipedia.org/wiki/%E5%89%AF%E4%BD%9C%E7%94%A8_(%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%A7%91%E5%AD%A6)),也開創出應用程式內還原與重做的功能,讓程式偵錯更加的容易。舉例來說,你可以紀錄狀態的改變,儲存狀態的歷史紀錄來了解錯誤的來源。
在 JavaScript 中,你可以使用 [`Object.freeze()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze) 來建立不可變物件。若你想在不可變物件上做更動,例外處理(exception)就會發生。
✅ 你知道*淺複製(shallow)*和*深複製(deep)*這兩種不可變物件的差別嗎?你可以從[這裡](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze#What_is_shallow_freeze)閱讀相關資訊。
### 課題
我們來建立新的函式 `updateState()`
```js
function updateState(property, newData) {
state = Object.freeze({
...state,
[property]: newData
});
}
```
在這個函式中,我們會建立新的狀態物件,並利用[*展開運算子(`...`)(Spread Operator)*](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#Spread_in_object_literals)複製前一個資料狀態。接著,我們使用[括弧記法(Bracket Notation)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Working_with_Objects#Objects_and_properties) `[property]` 賦予並覆蓋特定的狀態物件。最後,我們為物件上鎖,`Object.freeze()` 避免任何的改動。目前我們只有 `account` 資料存在狀態中,利用此方法可以讓你新增任何你想要的資料。
我們會更新 `state` 初始化設定,確保初始狀態也被上鎖:
```js
let state = Object.freeze({
account: null
});
```
接著,更新函式 `register`,將 `state.account = result;` 替換為:
```js
updateState('account', result);
```
在函式 `login` 上做一樣的事,將 `state.account = data;` 替換為:
```js
updateState('account', data);
```
藉由這個機會,我們能解決帳戶資料在*登出*時,不會被清除的問題。
建立新的函式 `logout()`
```js
function logout() {
updateState('account', null);
navigate('/login');
}
```
`updateDashboard()` 中,替換重新導向 `return navigate('/login');``return logout()`
試著註冊新的帳戶,登入登出以確保功能都運作正常。
> 提示:你可以觀察所有的狀態改變,在 `updateState()` 裡的最下方加入 `console.log(state)`,開啟瀏覽器開發工具,命令欄就會顯示狀態的紀錄。
## 紀錄狀態
多數的網頁應用程式需要儲存資料以確保運作正常。所有重要的資料都會存在資料庫中,並藉由伺服器 API 來存取,就像我們專案中的帳戶資料。但有時候,瀏覽器用戶端的應用程式也需要儲存一些資料,提供更好的使用者體驗與增進負載效能。
當你想在瀏覽器內儲存資料,你必須思考幾項重要的問題:
- *這項資料很危險嗎?* 你應該要避免在用戶端儲存敏感的資料,例如帳戶密碼。
- *你需要儲存資料多久?* 你打算短時間內做存取,還是永久地保存?
網頁應用程式中有許多儲存資訊的方法,一切都取決於你想達成的目標。舉例來說,你可以利用網址來儲存搜尋資訊,讓使用者間能共享資訊。若資料需要與伺服器共享,好比說[認證](https://zh.wikipedia.org/wiki/%E8%BA%AB%E4%BB%BD%E9%AA%8C%E8%AF%81)資訊,你可以使用 [HTTP cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies)。
另一個選擇是使用其中一個廣大的瀏覽器 API 來儲存資料。下列這兩項就特別有趣:
- [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage)[Key/Value 儲存法](https://zh.wikipedia.org/wiki/%E9%94%AE-%E5%80%BC%E5%AD%98%E5%82%A8)可以保存不同時刻的網頁資料。這些資料不會有期限的限制。
- [`sessionStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage):它的運作模式與 `localStorage` 相同,只差在資料會在網頁段落結束時被清除,如瀏覽器關閉時。
紀錄一下這兩個 API 只能儲存[字串](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)格式。
如果你想儲存更複雜的物件,你需要利用 [`JSON.stringify()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) 將資料整理成 [JSON](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON) 格式。
✅ 如果你想要建立不仰賴伺服器的網頁應用程式,你有辦法在用戶端建立資料庫。[`IndexedDB` API](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) 可以應用在更進階的案例上,儲存更大量的資料,當然使用上也相對複雜。
### 課題
我們想讓使用者在登出之前,保持登入狀態。所以我們使用 `localStorage` 來儲存帳戶資料。首先,定義一組 key 來紀錄我們的資料內容。
```js
const storageKey = 'savedAccount';
```
在函式 `updateState()` 末端加入此行:
```js
localStorage.setItem(storageKey, JSON.stringify(state.account));
```
藉由此方式,帳戶資料就能保存下來,並隨著之前中心化後的狀態而更新。我們開始從之前的重構獲取效益了 🙂。
當資料被儲存後,我們還需要在程式讀取時載入資料。在 `app.js` 下方編寫更多的初始化程式,建立新的函式 `init` 並收入之前的程式碼:
```js
function init() {
const savedAccount = localStorage.getItem(storageKey);
if (savedAccount) {
updateState('account', JSON.parse(savedAccount));
}
// 之前的初始化程式
window.onpopstate = () => updateRoute();
updateRoute();
}
init();
```
我們在此接收了儲存資料,並同步地更新狀態資訊。這必須在更新路由*之前*完成,否則有些程式碼會在頁面更新時,依據狀態來決定其行為。
當儲存完帳戶資料後,我們也定義了*儀表板*頁面為我們的預設首頁。若程式沒有找到資料,儀表板頁面也能重新導向回*登入*頁面。在 `updateRoute()` 中,替換回傳值 `return navigate('/login');``return navigate('/dashboard');`
登入應用程式並重新整理頁面。你應該能維持在儀表板那頁。這個改變也解決了我們最初面臨的問題......
## 重整資料
......但我們可能也產生了新問題。啊呀!
使用 `test` 帳戶進入儀表板頁面,在終端機內執行下列指令以建立新的交易項目:
```sh
curl --request POST \
--header "Content-Type: application/json" \
--data "{ \"date\": \"2020-07-24\", \"object\": \"Bought book\", \"amount\": -20 }" \
http://localhost:5000/api/accounts/test/transactions
```
試著重新整理瀏覽器內儀表板頁面。發生了什麼事?你有看到新的交易項目嗎?
感謝 `localStorage` 的幫助,狀態成功的儲存下來,但也代表我們在登出登入之前,不能再改變它的內容了!
一個可能的修復策略是在儀表板載入時,重新載入帳戶資訊以避免資料不同步。
### 課題
建立新的函式 `updateAccountData`
```js
async function updateAccountData() {
const account = state.account;
if (!account) {
return logout();
}
const data = await getAccount(account.user);
if (data.error) {
return logout();
}
updateState('account', data);
}
```
這個方法能檢查我們是否已經登入,重新從伺服器載入用戶資料。
建立另一個函式 `refresh`
```js
async function refresh() {
await updateAccountData();
updateDashboard();
}
```
這能更新帳戶資料,更新 HTML 中的儀表板頁面。這是在儀表板路由被載入時,我們所需要呼叫的函式。更新路由定義為:
```js
const routes = {
'/login': { templateId: 'login' },
'/dashboard': { templateId: 'dashboard', init: refresh }
};
```
試著重新載入儀表板,它現在應該能顯示更新後的帳戶資料。
---
## 🚀 挑戰
每一次儀表板載入時,我們都會重新載入帳戶資料,你認為我們還需要在用戶端儲存*所有的帳戶*資料嗎?
試著改變 `localStorage` 內的儲存內容,只包含我們能運行程式的必要資料。
## 課後測驗
[課後測驗](https://nice-beach-0fe9e9d0f.azurestaticapps.net/quiz/48)
## 作業
[編寫"加入交易明細"視窗](assignment.zh-tw.md)
這邊有完成之後的結果:
!["加入交易明細"視窗的例子截圖](../images/dialog.png)

@ -0,0 +1,25 @@
# 編寫"加入交易明細"視窗
## 簡介
我們的銀行應用程式還缺乏一項重要的功能:輸入新的交易明細。
使用你在這四堂課中學到的知識,編寫"加入交易明細"視窗:
- 在儀表板頁面新增"加入交易明細"按鈕
- 加入新的 HTML 模板建立新頁面,或是在同一頁面中使用 JavaScript 顯示 HTML 窗格(可以使用 [`hidden`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/hidden) 屬性,或是 CSS classes)
- 確保視窗能滿足[鍵盤與螢幕報讀器的相容性](https://developer.paciellogroup.com/blog/2018/06/the-current-state-of-modal-dialog-accessibility/)
- 編寫 HTML 表單來接收輸入資料
- 建立 JSON 表單資料並傳送到 API 上
- 使用新資料更新到儀表板頁面上
看看[伺服器 API 規格](../../api/README.zh-tw.md)來查詢你需要呼叫的 API 和所需的 JSON 格式。
這邊有完成作業後的成果:
!["加入交易明細"視窗的例子截圖](../images/dialog.png)
## 學習評量
| 作業內容 | 優良 | 普通 | 待改進 |
| -------- | ------------------------------------ | -------------------------------------------------- | ---------------------- |
| | 利用課程內容完美的製作出交易明細功能 | 有製作出交易明細功能,但有缺少部分要點且功能不完全 | 新的交易明細功能不正常 |

@ -0,0 +1,33 @@
# 銀行 API (Bank API)
> 由 [Node.js](https://nodejs.org) 與 [Express](https://expressjs.com/) 建立而成。
這套 API 已經建好而不在本次課程的範疇內。
然而,如果你想學習如何建立 API你可以追蹤這一系列的影片https://aka.ms/NodeBeginner (影片 17 到 21 為這套 API)。
你也可以看看這套互動式教學: https://aka.ms/learn/express-api
## 運行伺服器
確保你的 [Node.js](https://nodejs.org) 已經安裝完成。
1. Git clone 這個數據庫.
2. 在資料夾 `api` 中開啟終端機,執行 `npm install`
3. 執行 `npm start`
伺服器應該要在連接埠 `5000` 上監聽訊息。
> 筆記:所有儲存的資料不是永久保存的,伺服器終止時會遺失所有資料。
## API 項目
路由 | 描述
---------------------------------------------|------------------------------------
GET /api/ | 取得伺服器資訊
POST /api/accounts/ | 建立新的帳戶,範例: `{ user: 'Yohan', description: 'My budget', currency: 'EUR', balance: 100 }`
GET /api/accounts/:user | 取得特定帳戶的所有資料
DELETE /api/accounts/:user | 移除特定帳戶
POST /api/accounts/:user/transactions | 建立新的交易明細,範例: `{ date: '2020-07-23T18:25:43.511Z', object: 'Bought a book', amount: -20 }`
DELETE /api/accounts/:user/transactions/:id | 移除特定交易明細

@ -2362,7 +2362,7 @@
"questionText": "為什麼阻止用戶輸入不信任的資料很重要?", "questionText": "為什麼阻止用戶輸入不信任的資料很重要?",
"answerOptions": [ "answerOptions": [
{ {
"answerText": "因為使用奇特字元讓UI變很醜。", "answerText": "因為使用奇特字元讓 UI 很醜。",
"isCorrect": "false" "isCorrect": "false"
}, },
{ {
@ -2433,7 +2433,7 @@
] ]
}, },
{ {
"questionText": "下列何者為最合適的方式來儲存不同session中重要的用戶資料?", "questionText": "下列何者為最合適的方式來儲存不同 session 中重要的用戶資料?",
"answerOptions": [ "answerOptions": [
{ {
"answerText": "利用檔案。", "answerText": "利用檔案。",

Loading…
Cancel
Save