上一篇我們利用 Tailwind 打造了登入頁的基本樣貌,但光有門面還不夠,若沒有鎖與防護,這扇大門依然不安全😣。本篇我們將引入 React 生態系的函式庫進行功能優化,替昨天的樣板頁加上第一道防線 — 表單驗證。這樣,登入頁不再只是展示用的畫面,而是具備基礎功能與安全性的入口。
本篇重點整理:
react-router-form
的用途並介紹幾個核心函式,簡單實作頁面切換cn.ts
( 整合 clsx
+ tailwind-merge
) 優化動態樣式管理,並將今日介紹的套件應用到登入頁,讓樣本頁正式升級為可用的登入頁
一個網頁的傳輸協定與伺服器再怎麼穩固,若登入頁安全性不足,就好比《進擊的巨人》裡的那道城門,只要輕輕一踢,就能讓整個城牆的防線徹底瓦解,讓巨人們享用一場吃到飽的 buffet 💀。
因此,表單驗證 ( Form Validation ) 就是守住城門的第一道鎖,避免因為最外層的疏忽,導致整個系統被輕易攻破。它主要解決兩個面向的問題:
使用者體驗( UX )
資安保護( Security )
📌 總結來說,表單驗證就像門上的第一道鎖:
它不會阻止專業駭客入侵,但能擋下大部分的粗心與低階攻擊,確保登入系統至少有最基本的安全防護。
我們先將表單驗證所需的套件安裝到專案中,除了 react-hook-form
與 yup
之外,@hookform/resolvers
則是幫助兩者「溝通」的橋樑:
npm i react-hook-form yup @hookform/resolvers
一個具有驗證功能的基本表單大致會長這樣(以下程式碼已移除 Tailwind 的 className 與 TypeScript 型別,方便聚焦在邏輯上):
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
const schema = yup.object({
email: yup.string().email("Email 格式錯誤").required("Email 必填"),
password: yup.string().min(8, "至少 8 碼").required("密碼必填"),
});
export default function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: yupResolver(schema)
});
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
<input {...register("email")} placeholder="Email" />
<p>{errors.email?.message}</p>
<input type="password" {...register("password")} placeholder="Password" />
<p>{errors.password?.message}</p>
<button type="submit">登入</button>
</form>
);
}
yup:
Yup 的驗證規則通常長這樣:.<規則>("錯誤訊息")
.required("必填")
— 欄位是必填項.string()
/ .number
— 限制欄位型別.min(8, "至少 8 碼")
— 設定最小長度或數值.email("Email 格式錯誤")
— 檢查是否為 Email 格式.matches(正則, "自訂規則")
— 進階驗證(如密碼至少要有大寫 + 數字)React-hook-form:
useForm
— Hook Form 的核心,負責初始化表單狀態,並回傳工具函式。register
— 用來綁定輸入欄位,取代原本的 name
屬性,讓 Hook Form 可以追蹤值。handleSubmit
— 包裝送出行為,會先透過 yup 驗證,驗證成功才執行 callback。errors
( from formState
) — 當驗證失敗時,錯誤訊息會出現在這裡,可用來顯示提示文字。在 React 專案中,路由管理是很常見的需求。以往傳統網站換頁都需要整頁重新整理,體驗不佳;而 React-router-dom 則能在不刷新網頁的情況下,實現畫面切換,同時讓專案的路由結構更清晰、好維護。
( 眼尖的同學應該已經在上一篇程式碼看到過 router 的影子了 👀 )
在這裡我們先簡單示範「頁面跳轉」
npm i react-router-dom
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
function Home() {
return <h2>Home🏠</h2>;
}
function About() {
return <h2>Aboutℹ️</h2>;
}
export default function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">首頁</Link> | <Link to="/about">關於</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</BrowserRouter>
);
}
BrowserRouter
— 負責整個應用程式的路由環境,監聽網址變化。Routes
/ Route
— 宣告路徑對應的元件,例如 /about
→ <About />
。Link
— React Router 提供的導覽連結,取代 <a>
,切換頁面時不會整頁刷新,效能更好。前面我們已經介紹了 react-hook-form 、 yup 和 react-router-dom,現在就來把它們整合進登入頁吧!
不過在開始之前要再次強調:
👉 yup 驗證只能防止一些基本的輸入問題,並不能真正防範 SQL Injection 或 XSS 攻擊。
完整的資安防護,還需要在後端實作 雜湊 (Hashing)、加鹽 (Salting)、輸入清理與編碼 等機制,這些會在後續文章詳細解說。
隨著程式碼逐漸加入功能,我們也需要一些動態樣式來提升使用者體驗,如果直接在元件裡堆疊多組 Tailwind class,容易造成可讀性差或覆蓋問題。
這裡我們可以引入 tailwind-merge
與 clsx
,並將它們整合在一個 cn.ts
工具檔中,讓我們能更優雅地管理 Tailwind class,避免樣式衝突,同時讓程式碼更乾淨易讀。
npm i tailwind-merge clsx
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
LoginForm.tsx
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { loginSchema, type LoginInputs } from "@utils/validSchema";
import { cn } from "@utils/cn";
export default function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginInputs>({
resolver: yupResolver(loginSchema),
mode: "onBlur",
});
const onSubmit = (data: LoginInputs) => {
console.log("表單提交資料:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col space-y-8">
{/* 使用者名稱 */}
<div className="relative w-full">
<label htmlFor="username">
<span className="sr-only">使用者名稱</span>
<FontAwesomeIcon
icon="user"
className="absolute mt-4.5 ml-3.5 text-xl text-gray-500"
/>
</label>
<input
id="username"
{...register("username")}
type="text"
placeholder="使用者名稱"
className={cn(
"w-full py-3 pl-12 rounded-xl text-lg bg-white shadow-md focus:outline-none focus:ring-2 focus:ring-primary transition duration-200",
errors.username &&
"ring-1 ring-red-500 focus:ring-2 focus:ring-red-500"
)}
/>
{errors.username && (
<p className="absolute mt-1 ml-2 text-xs text-red-600">
{errors.username.message}
</p>
)}
</div>
{/* 密碼 */}
<div className="relative w-full">
<label htmlFor="password">
<span className="sr-only">密碼</span>
<FontAwesomeIcon
icon="lock"
className="absolute mt-4.5 ml-3.5 text-xl text-gray-500"
/>
</label>
<input
id="password"
{...register("password")}
type="password"
placeholder="密碼"
className={cn(
"w-full py-3 pl-12 rounded-xl text-lg bg-white shadow-md focus:outline-none focus:ring-2 focus:ring-primary transition duration-200",
errors.password &&
"ring-1 ring-red-500 focus:ring-2 focus:ring-red-500"
)}
/>
{errors.password && (
<p className="absolute mt-1 ml-2 text-xs text-red-600">
{errors.password.message}
</p>
)}
</div>
{/* 登入按鈕 */}
<div className="flex justify-center">
<button
type="submit"
className="w-3xs py-3 rounded-xl bg-primary-dark text-white text-xl font-bold hover:bg-primary focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50 shadow-md transition duration-200"
>
登入
</button>
</div>
</form>
);
}
validSchema.ts
import * as yup from "yup";
const usernameRegex = /^[a-zA-Z0-9_-]+$/;
const passwordRegex = /^[a-zA-Z0-9!@#$%^&*_+-=;':"|,./?`~]+$/;
// 登入相關
export interface LoginInputs {
username: string;
password: string;
}
export const loginSchema = yup.object().shape({
username: yup
.string()
.min(7, "* 使用者名稱最少為 7 個字元")
.max(16, "* 使用者名稱最多為 16 個字元")
.matches(
usernameRegex,
"* 使用者名稱只能包含英文字母、數字、底線( _ )和減號( - )"
)
.required("* 使用者名稱為必填欄位"),
password: yup
.string()
.min(8, "* 密碼最少為 8 個字元")
.max(20, "* 密碼最多為 20 個字元")
.matches(passwordRegex, "* 密碼只能包含英文字母、數字和常見特殊字元")
.required("* 密碼為必填欄位"),
});
// 註冊相關
export interface RegisterInputs {
username: string;
email: string;
password: string;
confirmPassword: string;
}
export const registerSchema = yup.object().shape({
username: yup
.string()
.min(7, "* 使用者名稱最少為 7 個字元")
.max(16, "* 使用者名稱最多為 16 個字元")
.matches(
usernameRegex,
"* 使用者名稱只能包含英文字母、數字、底線( _ )和減號( - )"
)
.required("* 使用者名稱為必填欄位"),
email: yup
.string()
.email("* 請輸入有效的電子郵件地址")
.required("* 電子郵件為必填欄位"),
password: yup
.string()
.min(8, "* 密碼至少為 8 個字元")
.max(20, "* 密碼最多為 20 個字元")
.matches(passwordRegex, "* 密碼只能包含英文字母、數字和常見特殊字元")
.required("* 密碼為必填欄位"),
confirmPassword: yup
.string()
.oneOf([yup.ref("password")], "* 兩次輸入的密碼不一致")
.required("* 請再次輸入密碼"),
});
圖片來源: