開啟網頁與按下按鈕都可以改變畫面,但背後的工作未必相同。開啟網頁時,瀏覽器可能向伺服器索取文件;按鈕也可能完全在瀏覽器內改變已有的文件。另一個按鈕則可能先向伺服器取得更多資料,再更新文件的一部分。理解這些差別,就能在選擇框架之前解釋網頁應用程式的運作。
本節會追蹤一個小型閱讀清單網頁的初次載入與本機互動,辨認請求所指向的資源、負責每項改變的程式,以及所產生狀態的生命週期。
1. 網頁應用程式由多個部分協作
定義
客戶端、伺服器與資源
客戶端主動發出服務請求。在本節例子中,瀏覽器擔任客戶端。伺服器接收請求並產生回應。資源是以 URL 識別的對象,例如 HTML 文件、圖片、樣式表或一組資料。
客戶端與伺服器描述的是角色,並非兩種指定的實體電腦。一部手提電腦可以同時執行瀏覽器與本機網頁伺服器程序。瀏覽器仍然負責請求資源,伺服器程序仍然負責處理這些請求。
伺服器也不一定只傳送磁碟上已有的檔案。它可以執行程式來建構回應。例如,閱讀清單服務可以選取可供閱讀的書籍並產生 HTML,也可以回傳 JSON 表示,供瀏覽器的 JavaScript 更新清單。兩種情況都有請求、回應,以及資源的一種表示。
Web 描述這套通訊與文件環境。Internet 是底層的網絡基礎設施,HTTP 則是使用這套設施的一種應用層協定。網頁應用程式也可以在私人網絡中運作,或透過同一部電腦上的本機伺服器運作。
2. 把 URL 理解為有結構的位址
考慮以下示例位址:
https://reading.example.test/shelf/books?topic=web#practice
| 部分 | 值 | 作用 |
|---|---|---|
| 協定方案 | https | 選用 HTTPS,即透過加密連線傳輸的 HTTP。 |
| 主機 | reading.example.test | 識別伺服器名稱。 |
| 路徑 | /shelf/books | 識別該伺服器 URL 空間中的資源。 |
| 查詢 | topic=web | 提供額外的請求參數。 |
| 片段 | practice | 識別回傳資源的某個部分,供客戶端導覽。 |
一般 HTTP 請求不會把片段傳送至伺服器。瀏覽器取得文件後可以使用片段,例如捲動至 id 為 practice 的元素。路徑可以由應用程式路由處理,因此單憑路徑不能判定伺服器磁碟上存在同名檔案。
協定方案也決定預設連接埠:HTTPS 通常使用 443,HTTP 通常使用 80。明確指定連接埠的位址,例如 http://localhost:8080/,則選用另一個連接埠。localhost 指正在執行瀏覽器的電腦,適合用來存取在該電腦上開發時執行的伺服器。
例題
解析相關資源的位址
假設目前文件的 URL 是 https://reading.example.test/shelf/index.html,而且文件沒有 base 元素。
theme.css解析為同一主機上的/shelf/theme.css。/assets/book.svg從該主機的根目錄開始解析,得到/assets/book.svg。../about.html解析為/about.html。#practice保持文件位置,並選取其practice片段。
開頭的斜線會改變路徑解析的起點。因此,把 HTML 檔案移至另一個目錄,可能改變相對連結;相對於根目錄的連結則在同一主機內保持原有路徑。
思考檢查
哪個部分會傳到伺服器?
比較 /shelf/books?topic=html#examples 與 /shelf/books?topic=css#examples。伺服器可以利用哪個部分區分這兩個請求?如果只把 #examples 改成 #exercises,新的片段會傳到伺服器嗎?
解答 · 比較查詢與片段
兩個查詢分別是 topic=html 與 topic=css。伺服器產生回應時可以使用這些查詢值。片段不屬於 HTTP 的請求目標。只改變同一份文件的片段,通常是在該文件內導覽,無須向伺服器請求另一份文件。
3. 追蹤一次文件請求
輸入 HTTPS 位址會啟動數項工作。瀏覽器識別主機,按需要取得其網絡位址,並建立連線;HTTP 隨後提供請求與回應的通訊方式。部分工作可以沿用先前瀏覽或快取的結果,因此以下是概念上的流程,並非每次瀏覽都必須逐項重新執行。
Browser Server
| |
|--- request the /shelf/index.html resource ->|
| | choose or generate content
|<-- status, headers and HTML body -----------|
| |
| parse HTML |
|--- request a referenced stylesheet -------->|
|<-- stylesheet response ---------------------|
| build the displayed page |
請求包含方法、目標與標頭;部分方法也會帶有主體。回應包含狀態、標頭,而且通常帶有主體。以下以簡化的 HTTP/1.1 形式示範:
例題
解讀一次 HTML 請求與回應
假設瀏覽器向 reading.example.test 請求 /shelf/index.html,而伺服器成功回傳文件,沒有重新導向。先讀請求,再判斷瀏覽器可以從回應得知甚麼。
GET /shelf/index.html HTTP/1.1
Host: reading.example.test
Accept: text/html
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
<!doctype html>
<html lang="en">
<head><title>Reading shelf</title></head>
<body><h1>Reading shelf</h1></body>
</html>
空白行把標頭與主體分隔。Content-Type 識別這種表示的媒體類型,讓接收端正確解讀主體。HTTP/2 與 HTTP/3 採用不同的訊息編碼方式,但仍保留方法、狀態、標頭與回應資料這些概念。
GET 指明取得資源的操作,路徑識別所請求的資源,Host 則識別伺服器名稱。回應的 200 表示成功,內容類型告訴瀏覽器應把主體解讀為 UTF-8 HTML。解析主體後,得到以「Reading shelf」為標題的網頁。請求描述所索取的內容,回應則提供結果與資源表示。
HTML 文件可以引用 CSS、JavaScript、字型與圖片。讀者看見的網頁由這些資源組合而成,未必只來自一個回應。多個請求的執行時間可以重疊,也可以重用快取資源。反過來說,一張圖片的請求失敗,並不表示 HTML 文件的請求也失敗。
| 狀態 | 所表達的結果 | 接下來值得觀察的資料 |
|---|---|---|
200 | 請求成功。 | 檢查內容類型與回傳內容。 |
404 | 找不到所請求的資源。 | 檢查完整路徑、拼寫與相對連結的解析結果。 |
500 | 伺服器處理請求時發生錯誤。 | 開發該伺服器時,檢查伺服端的失敗原因。 |
這些狀態代表不同的結果。更改 CSS 顏色不能修正錯誤的資源路徑;修正路徑也不能修復在產生回應時失敗的伺服器程式。
4. 分清結構、呈現與行為
HTML、CSS 與 JavaScript 互相協作,但分別回答不同的問題。
| 技術 | 主要職責 | 閱讀清單例子 |
|---|---|---|
| HTML | 賦予內容結構與意義。 | 標題、書籍清單與按鈕。 |
| CSS | 控制呈現方式與版面配置。 | 間距、顏色、方便閱讀的行長與響應式分欄。 |
| JavaScript | 執行程式邏輯並回應事件。 | 計算按鈕次數,或在收到資料後更新清單。 |
HTML 是標記語言,元素描述文件的結構。CSS 提供呈現規則。JavaScript 執行指令,並可以改變瀏覽器對文件的表示,也就是文件物件模型(Document Object Model,DOM)。框架同樣在這個環境內運作;理解各部分的基本角色,有助於解釋框架產生了甚麼。
瀏覽器執行客戶端 JavaScript。伺服器也可以執行 JavaScript,例如使用 Node.js,但兩者屬於不同的執行環境。伺服器腳本只改變自身某個變數,不能直接改變已顯示在瀏覽器中的網頁。它必須透過回應或其他支援的通訊機制,傳送瀏覽器可以使用的結果。
5. 一個完整的本機互動
例題
追蹤點擊與重新載入後的計數
把以下獨立文件儲存為 reading-counter.html,並在瀏覽器中開啟。計數值存放在目前文件的 JavaScript 狀態中;按鈕不會發出網絡請求。
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reading counter</title>
<style>
body { max-width: 38rem; margin: 2rem auto; padding: 0 1rem; }
button { font: inherit; padding: 0.5rem 1rem; }
</style>
</head>
<body>
<main>
<h1>Reading counter</h1>
<p>Pages recorded: <output id="count" aria-live="polite">0</output></p>
<button id="record" type="button">Record one page</button>
</main>
<script>
let count = 0;
const display = document.querySelector("#count");
const button = document.querySelector("#record");
button.addEventListener("click", () => {
count = count + 1;
display.textContent = String(count);
});
</script>
</body>
</html>
元素寫在腳本之前,因此執行 querySelector 時已經存在。選擇器 #count 與 #record 分別匹配相應的 ID。事件處理函式只註冊一次,每當點擊發生時便執行。textContent 改變輸出元素的文字,不會加入 HTML 標記。
令 表示點擊 次後的計數值。初始條件是 ,每次點擊都執行
輸出元素呈現這個狀態。程式先改變 count,再更新顯示的文字。如果移除第二項指令,變數仍會改變,但讀者會繼續看見舊值。
| 操作 | JavaScript 狀態 | 輸出文字 | 事件處理函式所發出的伺服器請求 |
|---|---|---|---|
| 初始文件 | count = 0 | 0 | 無 |
| 第一次點擊 | count = 1 | 1 | 無 |
| 第二次點擊 | count = 2 | 2 | 無 |
| 重新載入文件 | 再次為 count = 0 | 0 | 處理函式本身仍不發出請求。 |
重新載入會建立新文件,並再次執行初始化。視乎開啟方式,瀏覽器可能重新讀取檔案,也可能載入伺服器提供的版本。兩種情況下,這個程式都不會儲存先前的計數。要跨越重新載入保留數值,需要持久儲存機制;要跨裝置共用數值,則需要合適的共用服務與通訊。
常見錯誤
畫面改變不代表曾經聯絡伺服器
畫面可能因為瀏覽器的事件處理函式改變了 DOM 而更新。單憑這項觀察,不能判定資料曾經傳送或儲存。判斷伺服器是否參與之前,應先辨認程式執行的路徑,或檢查網絡活動。
思考檢查
先預測計數結果,再動手試驗
點擊三次,重新載入,再點擊一次。每個階段會顯示甚麼值?如果移除 display.textContent = String(count),結果會有甚麼改變?
解答 · 追蹤初始化與更新
三次點擊後顯示 3。重新載入會把新的 count 初始化為 0,文件顯示 0;再點擊一次便顯示 1。如果沒有 textContent 賦值,記憶體中的計數仍會隨點擊改變,但顯示文字保持為 0。模型中的值與可見的表示是不同的對象,程式必須維持兩者一致。
6. 觀察負責的層次來診斷問題
瀏覽器開發者工具可以檢驗解釋,而無須只憑外觀推斷所有事情。
- 在文件檢查器中找出輸出元素。按下按鈕,觀察其文字節點改變。
- 網頁由本機伺服器提供時,在 Network 面板檢查初始文件請求,並比較其回應與所引用資源的請求。
- 清空網絡紀錄,再按計數按鈕。所提供的處理函式不會引起請求。
- 重新載入,把原始回應或檔案內容與目前的 DOM 比較。原始程式碼載入後,腳本仍可以改變 DOM。
網頁看起來有問題時,先提出精確的問題:資源是否已取得?文件結構是否正確?樣式規則是否生效?處理函式是否更新了預期的狀態與元素?不同問題會指向不同的證據。
練習
1. 辨認工作的執行位置
對以下每項操作,指出主要負責的層次:HTML、CSS、瀏覽器 JavaScript 或伺服器程式,並解釋理由。
- 把書名標記為標題。
- 畫面變窄時,把兩欄改成一欄。
- 點擊後增加本機閱讀計數。
- 服務只回傳符合查詢參數的書籍。
解答 · 解答 1:各部分的職責
- HTML 以標題元素標示內容的角色。
- CSS 描述版面如何回應可用空間。
- 瀏覽器 JavaScript 處理事件、更新變數並改變 DOM。
- 伺服器程式解讀請求中的查詢,並準備符合條件的表示。瀏覽器 JavaScript 可以顯示結果,但只接收結果並不等於執行了伺服器的篩選工作。
2. 在明確假設下計算資源數目
一份文件引用一份樣式表、一份腳本與兩張不同的圖片。假設快取為空、沒有重新導向、這些檔案不再引用其他資源,而且每個資源的請求都成功。連同文件本身,共需要多少次資源請求?這是否表示所有請求必須逐一執行?
解答 · 解答 2:計算請求,不預設執行時序
共有 次請求,即文件與四個引用資源。這個數目並未確定執行時序。瀏覽器發現相依資源後,可以同時請求其中部分資源,無須等候每個請求完成才開始下一個。
3. 解釋重新載入的結果
學習者按計數按鈕五次,關閉分頁,再開啟同一網頁。他預期看見 5,卻看見 0。這是否必然表示點擊處理函式有錯?指出程式中可以解釋這個結果的證據。
解答 · 解答 3:狀態的生命週期
處理函式可以完全正確。let count = 0 會為每份新文件初始化狀態,而且處理函式沒有使用儲存或伺服器通訊。關閉文件會結束該實例的狀態,新的載入則建立另一個從零開始的實例。正確的本機更新與持久儲存是不同要求。
4. 解析目錄改變後的位址
一份 HTML 檔案由 /shelf/index.html 移至 /courses/web/index.html。文件沒有 base 元素。比較移動前後,theme.css 與 /assets/book.svg 各會解析為甚麼位址。
解答 · 解答 4:相對路徑與相對於根目錄的路徑
theme.css 由 /shelf/theme.css 變為 /courses/web/theme.css,因為它相對於文件所在目錄解析。/assets/book.svg 在同一主機上保持該路徑,因為開頭的斜線表示由主機根目錄開始解析。移動文件不會自動移動這兩個資源。
接着閱讀語義化 HTML 文件,清楚表達網頁的結構與意義。