Learning Objectives
- 說明整頁重新載入與部分更新的差別
- 用 fetch、await、response.ok、response.json() 呼叫 API 並處理錯誤
- 用 textContent 更新畫面,說明為什麼不要用 innerHTML 接伺服器資料
- 說明同源政策、CORS 與預檢,以及 CORS 為什麼不是權限機制
整頁重新載入與部分更新
第 10 課的留言板每新增一則,瀏覽器就送一次表單、收到轉址、重新載入一整頁。頁面捲動的位置、其他輸入框的內容都會重來。
另一種做法是讓頁面裡的 JavaScript 自己呼叫 API,拿到 JSON 之後只改畫面的一小塊:
| 整頁重新載入 | 部分更新 | |
|---|---|---|
| 伺服器回傳 | 一整頁 HTML | JSON |
| 誰決定畫面 | 伺服器(模板) | 瀏覽器(JavaScript) |
| 輸入框、捲動位置 | 重來 | 保留 |
| 不開 JavaScript 時 | 可以用 | 不能用 |
兩種都是正常的做法,同一個網站也常常混用。這種做法早期叫 AJAX,現在瀏覽器內建的工具是 fetch。這一課用到的 JavaScript 語法如果想補強,可以看 JavaScript 軌。
基本寫法:
const response = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: '期中考範圍' }),
});
const data = await response.json();
if (!response.ok) {
showError(data.error);
return;
}
appendNote(data);
await表示等請求完成再往下。等待的時候頁面不會卡住,使用者還能操作fetch預設是 GET。要送 JSON 時,方法、Content-Type、JSON.stringify三樣都要有;少了Content-Type,Flask 的get_json()會回 415response.ok在狀態碼是 200 到 299 時為真
最容易踩到的一點:fetch 收到 400、404、500 時不會跳到 catch。 依 MDN 的說明,只要伺服器有回應,fetch 就算成功;catch 只接得到連不上伺服器這類錯誤。所以一定要自己檢查 response.ok。
逐步看:同一支 API 成功與失敗
逐步動畫只能追蹤 Python,所以這裡用 Python 寫出和上面相同的流程,call 這個函數就是前端的角色。對照方式:
| JavaScript | 這個範例 |
|---|---|
await fetch(…) |
app.test_client().post(…) |
response.ok |
ok |
await response.json() |
response.get_json() |
更新畫面用 textContent
剛才的練習裡,同一筆資料只差一個屬性,結果就差很多:
li.textContent = note.text; // 當成文字,標籤會原樣顯示
li.innerHTML = note.text; // 當成 HTML,裡面的標籤與事件都會生效
第 6 課的 Jinja2 會自動跳脫,但 JavaScript 自己組畫面時沒有人幫你跳脫。如果別的同學存進一則帶 onerror 的筆記,所有打開這頁的人都會執行那段程式,這就是 XSS(跨站腳本攻擊),第 17 課會再談。
所以規則很簡單:伺服器來的資料一律用 textContent,或用 createElement 一個一個組。真的需要顯示使用者寫的 HTML(例如 Markdown 轉出來的內容)時,要先經過 DOMPurify 這類專門的清理工具。
同源政策與 CORS
到目前為止,頁面和 API 都在同一個網站。如果 https://other.example 這一頁的程式去呼叫你的 API 呢?
想像你登入了學校的系統,又開了一個不認識的網站。如果那個網站的程式可以隨意讀取學校系統的回應,它就能看到你的成績。所以瀏覽器有 同源政策(same-origin policy):頁面的程式只能讀取同一個來源的回應。
來源(origin)由通訊協定、主機、埠號三者組成,三個都相同才算同源。下面第一段動畫逐一比較。
有時候跨來源是你要的,例如前端和 API 放在不同的網域。CORS(Cross-Origin Resource Sharing)讓伺服器用回應標頭告訴瀏覽器:這個來源可以讀。
Access-Control-Allow-Origin: https://other.example
跨來源的請求分成兩種:
| 條件(節錄) | 瀏覽器怎麼做 | |
|---|---|---|
| 簡單請求 | GET、HEAD、POST;Content-Type 只能是表單類或 text/plain;沒有自訂標頭 |
直接送出,再看回應標頭決定要不要給頁面讀 |
| 需要預檢 | 其他情況,例如 Content-Type: application/json、PUT、DELETE |
先送 OPTIONS 問伺服器,允許了才送真正的請求 |
注意第一列:請求已經送到伺服器了,瀏覽器擋的是頁面讀取回應。第二段動畫從伺服器的角度看一次。
逐步看:哪些網址算同源
左邊都是同一頁,右邊是它想呼叫的網址。第 2 個為什麼是同源?第 4 個的主機只差一點點,算嗎?
逐步看:從伺服器的角度
每個請求都帶 Origin 標頭,就像瀏覽器從別的網站送來的一樣。view 函數有沒有檢查它?按讚的請求有沒有被處理?
CORS 不是權限機制
剛才的練習可以整理成三點:
- CORS 是瀏覽器在執行的規則。 curl、Python 的 requests、手機 App 都不管它,所以不能靠它保護 API。誰可以讀寫資料,要由伺服器檢查登入與權限(第 15、16 課)。
- 簡單請求還是會到伺服器。 沒有
Access-Control-Allow-Origin只代表頁面讀不到回應,資料可能已經改了。這和第 17 課的 CSRF 直接相關。 - CORS 是放寬,不是加強。 同源政策本來就擋著,CORS 是在上面開洞,所以只開給需要的來源。
幾個實務上的細節:
Access-Control-Allow-Origin: *表示任何來源都能讀,適合公開、不需要登入的資料。帶 Cookie 的請求不能用*,必須寫明來源,還要加上Access-Control-Allow-Credentials: true,前端也要設credentials: 'include'- 要開放給多個來源時,先把
Origin和白名單比對,符合才回傳那個來源,並加上Vary: Origin(下面的實作題) - 實際專案常用 Flask-CORS 這類擴充處理預檢,原理和這裡相同
動手做:CORS 白名單
完成 add_cors:Origin 在白名單裡時,加上 Access-Control-Allow-Origin,值就是那個來源;所有回應都加 Vary: Origin,告訴快取「回應會依 Origin 不同」。程式最下面的測試不要改。
想一想
API 回了 404,下面的程式會顯示什麼?
try {
const response = await fetch('/api/notes/99');
const data = await response.json();
show('成功:' + data.error);
} catch (err) {
show('失敗');
}
API 的 404 回應內容是 {"error": "找不到"}。
要把伺服器回傳的筆記內容放進畫面,哪一種寫法才不會執行內容裡的程式?
頁面在 https://note.example.tw,下面哪個網址和它同源?
另一個網站的程式對你的 API 送了 fetch(url, {method: "POST"}),你的回應沒有任何 CORS 標頭。下面哪一句正確?
同學說:「我們的 API 設了 CORS 白名單,所以只有自己的前端能呼叫,不用再檢查登入了。」哪裡有問題?
重點整理
fetch讓頁面只更新一部分;送 JSON 時方法、Content-Type、JSON.stringify都要有fetch收到 400、500 不會跳到catch,一定要檢查response.ok- 伺服器來的資料用
textContent放進畫面,不要用innerHTML - 同源=通訊協定、主機、埠號都相同;同源政策限制的是頁面讀取回應
- CORS 讓伺服器開放給指定的來源;簡單請求還是會到伺服器,其他請求會先預檢
- CORS 不是權限機制,誰能讀寫資料要由伺服器檢查
上面一直提到「登入」,可是 HTTP 本身不記得誰是誰。下一課看瀏覽器和伺服器怎麼靠 Cookie 與 Session 記住狀態。