# 建立銀行網頁應用程式 Part 1：HTML 模板與網頁路由

## 課前測驗

[課前測驗](https://ashy-river-0debb7803.1.azurestaticapps.net/quiz/41?loc=zh_tw)

### 大綱

自從 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/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/docs/Web/API/Document/getElementById)。
2. 複製模板元素，使用 [`cloneNode`](https://developer.mozilla.org/docs/Web/API/Node/cloneNode)。
3. 將複製元素接到 DOM 的顯示元素上，例如使用 [`appendChild`](https://developer.mozilla.org/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/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/docs/Web/API/History/pushState)，更新網址位置並建立瀏覽紀錄，同時不更新整個 HTML 頁面。

> 筆記：網頁超連結元素 [`<a href>`](https://developer.mozilla.org/docs/Web/HTML/Element/a) 可以建立不同網址的連接，但它預設上會讓瀏覽器重新載入 HTML 檔。我們需要手動新增 JavaScript 處理路由以避免此行為發生，在點擊事件中使用函式 preventDefault() 。

### 課題

我們來建立新的函式，讓應用程式得以做網頁的訪問：

```js
function navigate(path) {
  window.history.pushState({}, path, 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(event)">Login</a>
...
<a href="/login" onclick="onLinkClick(event)">Logout</a>
```

使用 [`onclick`](https://developer.mozilla.org/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/docs/Web/API/History/pushState)，你會發現如果狀態改變 ── 同時代表著網址改變 ── [`popstate`](https://developer.mozilla.org/docs/Web/API/Window/popstate_event) 事件就會被觸發。我們會利用此特徵來修復這個問題。

### 課題

為了在瀏覽器歷史改變時更新該被顯示的模板，我們會以新函式來呼叫 `updateRoute()`。我們在 `app.js` 檔最下方加入：

```js
window.onpopstate = () => updateRoute();
updateRoute();
```

> 筆記：我們在這裡使用[箭頭函式](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Functions/Arrow_functions)，簡短地宣告 `popstate` 事件處理器。它與正規的函式的功能是一樣的。

這是關於箭頭函式的回想影片：

[![箭頭函式](https://img.youtube.com/vi/OP6eEbOj2sc/0.jpg)](https://youtube.com/watch?v=OP6eEbOj2sc "箭頭函式")

> 點擊上方圖片以觀看關於箭頭函式的影片。

現在，試著點擊瀏覽器上的上一頁與下一頁，檢查這次模板是否正確地更新出來。

---

## 🚀 挑戰

加入新的模板與對應的關聯表，顯示出本應用程式第三頁的功能 ── 帳戶餘額。

## 課後測驗

[課後測驗](https://ashy-river-0debb7803.1.azurestaticapps.net/quiz/42?loc=zh_tw)

## 複習與自學

網頁路由是網頁開發中很棘手的部分，特別是將網頁切換轉變為單一頁面應用程式(Single Page Application)。閱讀關於[Azure Static Web App 提供服務的方式](https://docs.microsoft.com/azure/static-web-apps/routes/?WT.mc_id=academic-77807-sagibbon)以處理網頁路由。你能解釋為什麼文件上的某些決定會如此重要呢？

## 作業

[增進網頁路由](assignment.zh-tw.md)
