iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0
Modern Web

不只是登入畫面!一起打造現代化登入系統系列 第 5

房門與門鎖[ 2 / 6 ]:React 生態系導入 — 表單驗證 & Router 分頁

  • 分享至 

  • xImage
  •  

上一篇我們利用 Tailwind 打造了登入頁的基本樣貌,但光有門面還不夠,若沒有鎖與防護,這扇大門依然不安全😣。本篇我們將引入 React 生態系的函式庫進行功能優化,替昨天的樣板頁加上第一道防線 — 表單驗證。這樣,登入頁不再只是展示用的畫面,而是具備基礎功能與安全性的入口。

基本表單驗證

https://ithelp.ithome.com.tw/upload/images/20250919/2011058649nrwOk3YQ.png

本篇重點整理:

  • 表單驗證: 簡單講述基本表單驗證 React-hook-form & yup ,並認識幾個核心函式
  • 路由管理: 說明 react-router-form 的用途並介紹幾個核心函式,簡單實作頁面切換
  • 登入頁升級: 引入 cn.ts ( 整合 clsx + tailwind-merge ) 優化動態樣式管理,並將今日介紹的套件應用到登入頁,讓樣本頁正式升級為可用的登入頁

表單驗證 react-hook-form + yup

一個網頁的傳輸協定與伺服器再怎麼穩固,若登入頁安全性不足,就好比《進擊的巨人》裡的那道城門,只要輕輕一踢,就能讓整個城牆的防線徹底瓦解,讓巨人們享用一場吃到飽的 buffet 💀。
https://ithelp.ithome.com.tw/upload/images/20250919/20110586jrDbza330C.png

因此,表單驗證 ( Form Validation ) 就是守住城門的第一道鎖,避免因為最外層的疏忽,導致整個系統被輕易攻破。它主要解決兩個面向的問題:

  1. 使用者體驗( UX )

    • 防止輸入錯誤:像是 Email 格式錯誤、密碼長度不足、必填欄位沒填。
    • 即時回饋:透過驗證,使用者在輸入時就能得到提示,而不是送出後才發現錯誤。
  2. 資安保護( Security )

    • 初步防止惡意輸入:攻擊者可能會輸入特殊字串 ( 如 SQL 注入、XSS 攻擊 ),透過驗證可以先擋下基本的異常輸入。
    • 保護系統穩定性:確保傳進後端的資料符合預期格式,避免系統被錯誤資料拖垮。
    • 減少暴力破解的成功機會:像是密碼至少需要大小寫與數字,可以避免弱密碼成為系統漏洞。

📌 總結來說,表單驗證就像門上的第一道鎖:
它不會阻止專業駭客入侵,但能擋下大部分的粗心與低階攻擊,確保登入系統至少有最基本的安全防護。


我們先將表單驗證所需的套件安裝到專案中,除了 react-hook-formyup 之外,@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-router-dom

在 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-mergeclsx,並將它們整合在一個 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("* 請再次輸入密碼"),
});

圖片來源:

Reddit 進擊的巨人圖源


上一篇
房門與門鎖[ 1 / 6 ]:用 Tailwind CSS v4 打造現代感登入頁
下一篇
房門與門鎖[ 3 / 6 ]:模組化設計 — 拆分 components 實踐 DRY 策略
系列文
不只是登入畫面!一起打造現代化登入系統6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言