在網頁或是 App 中,如果我們希望使用者輸入某些資料,例如登入帳號、搜尋關鍵字、發送留言等,就需要一個表單 (form) 來收集這些資料。HTML 中的 <form>
標籤就是專門用來建立表單的容器,裡面可以放輸入框、下拉選單、按鈕等元素。
基礎處理:GET 方法
表單的結構必須包含表單容器(用 <form>
包住)以及輸入欄位與送出按鈕。一個最簡單的表單結構為:
1 | <form action="/目標路由"> |
簡易表單
延伸之前的根頁面,我們在裡面新增表單:
1 | <form action="/show"> |
接著在主程式建立路由:
1 |
|
執行程式後打開頁面,即可看到表單裡面的按鈕:

圖 1:表單頁面範例
按下按鈕後,就會跳轉到 /show
路徑,並且帶上查詢字串:http://127.0.0.1:5000/show?
,只是此時尚未設定變數,因此查詢字串為空。

圖 2:表單送出結果
可以觀察一下後端的狀況,當點擊按鈕後,前端會發送一個 GET 請求,並且成功打到 /show
這個路徑:
1 | * Running on http://127.0.0.1:5000 |
帶參數的表單
正如上面提到的,按下送出按鈕後,前端會帶著表單打到目標路徑。因此我們這時後可以設定輸入區,一個以 input
標籤包住的區域,並指定變數名稱為何。輸入區的基本語法為:
1 | <input type="text" name="變數名稱"> |
type
:欄位類型(常見有text
、password
、number
等)name
:送出時的參數名稱value
:欄位預設值
例如我們可以設定輸入姓名欄位:
1 | 姓名 <input type="text" name="name"> |
打開網站後,即可看到文字與輸入區:

圖 3:表單輸入區範例
輸入 Anthony
後,點擊按鈕觀察到網址的變化,可以發現此時它成功帶著參數 name
過去,網址變成 http://127.0.0.1:5000/show?name=Anthony
。

圖 4:表單輸入區送出結果
當然我們可以對傳入的參數進行一些操作,例如在點擊後,在成功訊息後面加上姓名:
1 |
|
執行後即可成功看到結果:

圖 5:表單輸入區變數
另一個範例是允許使用者輸入數字,由後端計算平方後回傳至前端。首先我們需要建立一個表單:
1 | <form action="/getSquared"> |
接著設定對應的路由 /getSquared
:
1 |
|
執行後即可看到數字的輸入區:

圖 6:平方數字範例
輸入 15
後便可看到結果為 225
。

圖 7:平方數字結果
使用渲染樣板
由於表單會帶參數至後端的特性,我們也可以善用此特點,將表單傳入樣板中。將上面計算平方邏輯修改一下,回傳至 show.html
這份樣板中,並帶入變數 result
,為使用者輸入數字之平方:
1 |
|
然後建立 show.html
樣板,並用兩層 {}
包住變數:
1 | <html lang="en"> |
執行程式、輸入文字並按下送出後,可以看到此時會顯示 show.html
樣板的文字,而非單純的字串:

圖 8:平方數字使用渲染樣板
基礎處理:POST 方法
前面的處理都是預設表單為 GET 方法,因此使用 request.args.get()
從網址的查詢字串取得參數。如果我們改用 POST 方法送出表單,資料就不會出現在網址中,而是放在 HTTP 請求的主體 (body),這時後端就要改用 request.form
來讀取。
取得方法 | 適用情況 | 資料來源位置 | 資料會不會出現在網址 |
---|---|---|---|
request.args |
GET | URL 的查詢字串(? 後面) |
會 |
request.form |
POST | HTTP 主體 | 不會 |
至於為何我們那麼在意資料是否會出現在網址中,用以下的範例便可知一二。假設我們有一個登入頁面 login.html
:
1 | <html lang="en"> |
在主程式中直接使用 request.args
取得資料:
1 |
|
開啟頁面後即可看到登入畫面

圖 9:登入頁面
但是當我們輸入帳號密碼並按下登入按鈕後,可怕的事情發生了:帳密出現在網址!

圖 10:登入頁面含有隱私資訊
因此如同前面所說的,request.args
的資料會出現在網址,密碼等敏感資訊會被記錄在瀏覽器歷史與伺服器日誌中,非常不安全。所以我們需要使用 request.form
來處理這類需要隱藏敏感資訊的問題。使用方法其實很簡單,只要將 login.html
裡面表單的請求方法改為 POST:
1 | <form action="/login" method="POST"> |
後端加上允許 POST 請求方法[1],並判斷請求方法是否為 POST,最後重新導向至首頁:
1 |
|
此時重新輸入帳號密碼,按下送出後,在後端可以看到確實接收到帳號密碼,且請求方法為 POST:
1 | * Running on http://127.0.0.1:5000 |
表單驗證
如果我們每支路由都自己 request.form.get()
、自己檢查必填、長度、格式,久了程式碼會變得很亂。WTForms 就是為了這件事生的:它讓你用 Python 類別定義表單,每個欄位是欄位物件,驗證是驗證器,送出時幫你綁定資料 + 驗證 + 提供錯誤訊息;Flask‑WTF 則是把 WTForms 跟 Flask 牽起來的擴充套件,讓它變得更好用。
安裝與設定
在開始前我們需要安裝 flaks-wtf
以及 wtforms
。如果使用 pip
,可以使用以下方式:
1 | pip install flask-wtf wtforms |
如果用 Poetry 之類的專案管理,則使用以下方式安裝:
1 | poetry add flask-wtf wtforms |
安裝完畢後,要在 app.py
中加上以下這行:
1 | app.config["SECRET_KEY"] = "dev-secret-change-me" |
目的是設定 Flask 應用的祕密金鑰 (secret key),Flask 內部多個機制都要靠它來簽名資料,避免被竄改。
WTForms
正如前面所言,WTForms 的本質與 Flask 無直接關聯,但是正是因為其核心設計理念,大大解決了複雜化的表單建立問題:
欄位類別化:WTForms 將每一個表單欄位抽象成 Python 物件,並根據欄位性質提供不同的類別:
StringField
:文字輸入框PasswordField
:密碼框IntegerField
:整數輸入SubmitField
:送出按鈕
這種做法的好處是:開發者在程式碼中可以像定義資料模型一樣定義表單,欄位的型態與規格清楚明確,後續維護時只要檢視 class 定義就能了解整份表單的結構與需求。
驗證器 (validators):每個欄位都能直接綁定一組或多組驗證規則,例如:
DataRequired()
:必填Length(min, max)
:長度限制Email()
:格式必須是 Email
驗證器會在表單送出後自動執行,避免開發者在每個路由中重複撰寫檢查程式,並且讓驗證邏輯與欄位定義緊密結合。
錯誤訊息統一管理:當某個欄位的驗證失敗時,WTForms 會將對應的錯誤訊息收集到表單物件的
errors
屬性中,並以字典的方式存放。這樣不僅方便在後端進行除錯,也方便在前端模板中直接依照欄位名稱顯示錯誤提示,保持使用者體驗一致。與模板整合:WTForms 與 Jinja2 模板引擎結合緊密,欄位物件本身就能直接輸出 HTML 代碼。例如:
{{ form.username.label }}
:生成對應的<label>
標籤{{ form.username() }}
:生成<input>
標籤及其屬性
這樣一來,表單的後端定義與前端輸出保持一致,就可減少 HTML 與 Python 之間的重複定義與同步問題。
例如以下是一個 WTForms 的範例:
1 | from wtforms import Form, StringField, PasswordField, validators |
這段程式碼定義了一個表單 LoginForm
,有 username
與 password
兩個欄位,各欄位綁定了驗證規則,包含必填與長度限制。
Flask-WTF
Flask-WTF 解決了幾個 WTForms 沒有,但是與網站開發相關的工作:
自動 CSRF 保護:WTForms 本身不知道什麼是 CSRF,但 Flask-WTF 會在表單中自動加入隱藏欄位
csrf_token
。這個 token 在送出表單時會自動驗證,防止跨站請求偽造攻擊。與 Flask Request 整合:在 WTForms 中,我們必須手動將
request.form
的資料傳進表單物件。但是 Flask-WTF 會在建立表單時自動綁定request.form
(POST)或request.args
(GET)的資料,讓我們可以直接檢查。簡化驗證流程:提供
validate_on_submit()
方法,一次檢查「是不是 POST 請求」和「所有驗證器都通過」,等價於以下寫法:1
request.method == "POST" and form.validate()
實際操作
首先定義帳密表單 LoginForm
,將欄位結構與驗證規則集中在一個類別裡,以後任何路由或樣板要用同一份帳密表單都引用這個類別,不會重複寫驗證邏輯。
1 | # 定義帳密表單 |
接著撰寫登入路由,在這邊我們將路徑設定為根頁面。
1 |
|
Flask‑WTF 會在建立 form = LoginForm()
時,自動把 request.form
綁到欄位(POST 時)。
form.validate()
會逐一執行欄位 validators(此例為 DataRequired()
),任何一個失敗就回傳 False
,錯誤訊息存在 form.<欄位>.errors
。而在 login.html
樣板中,需要處理 CSRF token:
1 | <html lang="en"> |
hidden_tag()
用於輸出隱藏欄位(含 CSRF token),沒有它的話 validate_on_submit()
會因 CSRF 驗證失敗而回傳 False
。最後執行程式,開啟首頁後,即可看到以下登入頁面:

圖 11:使用 WTForms & Flask-WTF 的登入頁面
輸入帳號密碼並登入後,即可看到成功訊息,並且在瀏覽器網址列看不到帳密等隱私資訊,相較於原先使用原生表單的做法安全。

圖 12:使用 WTForms & Flask-WTF 的登入成功頁面
文件上傳
在許多 Web 應用中,使用者不僅會輸入文字或選擇選項,還會需要上傳檔案,例如上傳頭像、附件、照片或報表。Flask 內建對檔案上傳的支援,核心機制是透過 request.files
來存取上傳的檔案物件,並由開發者決定要如何驗證、儲存與處理。在 Flask 中,我們可以用兩種方式實作檔案上傳:
- 原生 Flask:直接使用
request.files
搭配自訂驗證邏輯,靈活但需要自行處理安全檢查與錯誤訊息 - WTForms & Flask-WTF:將檔案上傳欄位與驗證器定義在表單類別中,與其他欄位共用一致的驗證流程與錯誤管理
原生 Flask
如果只是要快速實作檔案上傳功能,而不需要與 WTForms 整合,我們可以使用原生 Flask,直接透過 request.files
存取上傳檔案。雖然這種方式比較靈活、容易上手,但同時需要自行處理檔案驗證與安全性,否則可能會讓應用暴露在風險之下。HTML 端必須使用 POST
方法,並將表單的 enctype
屬性設為 multipart/form-data
,這是瀏覽器在上傳檔案時必須使用的編碼方式,否則檔案資料不會被送到後端。
1 | <html lang="en"> |
<input type="file" name="file">
的name
屬性很重要,這個值會成為後端request.files
的索引鍵enctype="multipart/form-data"
必須設定,否則檔案內容不會被包含在請求中
在後端,我們可以從 request.files
取出檔案物件,檢查它是否存在,然後將它儲存到指定目錄。
1 | import os |
注意到我們使用 secure_filename()
來處理檔案名稱,因為如果直接使用 file.filename
可能存在安全風險,例如攻擊者上傳名為 ../../app.py
的檔案,可能會覆蓋後端檔案;secure_filename()
會將檔名中不安全的字元移除或替換,並保證只留下安全的檔名。

圖 13:上傳頁面
點擊上傳後,可以看到後端出現 POST 到 /upload
的要求,表示成功上傳。
1 | INFO:werkzeug:127.0.0.1 - - [10/Aug/2025 15:04:56] "POST /upload HTTP/1.1" 302 - |
如果想要檢查檔案是否為特定類型,可以定義一個函式 allowed_file
,判斷其是否有 .
與副檔名是否在允許的類型內:
1 | ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "pdf"} |
接著在 file.save()
前檢查一遍:
1 | # 檢查檔案類型 |
WTForms & Flask-WTF
相比原生 Flask 用 request.files
處理檔案上傳,WTForms + Flask-WTF 能將欄位定義、驗證規則、錯誤訊息集中管理,還自動整合 CSRF 保護。樣板其實與原生 Flask 相差不大,只是需要加上 CSRF 防護機制而已,並且善用 Flask-WTF 生成標籤的功能:
1 | <html lang="en"> |
接著需要定義可重複使用的上傳表單類別 UploadForm
:
1 | from flask_wtf import FlaskForm |
定義上傳頁面路由:
1 |
|
執行程式後打開 /upload_wtf
頁面即可看到選擇檔檔案區與上傳按鈕:

圖 14:使用 WTForms & Flask-WTF 的上傳頁面
如果上傳允許的檔案類型,則會出現 302 代碼:
1 | * Running on http://127.0.0.1:5000 |
但如果上傳不允許的檔案類型,頁面會顯示錯誤提示:

圖 15:上傳檔案類型不被允許
而且後端不會出現 302 代碼,而是出現正常的 200 代碼,因為重新導向至上傳頁面,要求使用者重新上傳:
1 | INFO:werkzeug:127.0.0.1 - - [10/Aug/2025 15:56:34] "POST /upload_wtf HTTP/1.1" 200 - |
- 1.預設是 GET 方法。 ↩