在上一篇,我們加入了「社群登入」,讓使用者不用每次都輸入帳密,就能快速登入。而這一篇會是「 房門與門鎖 」的最後一個篇章🤩,我們要把整體流程補齊,並加點小巧思,讓整體體驗更流暢。
本篇重點整理:
在真實專案裡,我們通常需要一個後端伺服器來處理登入、註冊、存取資料… 但問題來了:
開發初期後端還沒建好怎麼辦🤔?
別擔心,這時候就可以靠 JSON Server 來頂上。
它的功能很簡單:
安裝方式:
npm i -D json-server
在 package.json 裡加上啟動指令:
"scripts": {
"server": "json-server --watch db.json --port 3001"
}
然後建立 db.json:
{
"users": []
}
跑起來後,你就能透過 http://localhost:3001/users
,像跟真正的後端一樣拿資料。對前端練習來說,會是個非常方便的「假後端」。
想像一下工地現場,工人不可能只靠「 眼神交流 」來溝通吧?這時候 對講機 就很重要了。
前端和後端的關係也差不多:
這個呼叫的方式就是 AJAX
fetch
為瀏覽器內建,但錯誤處理與功能比較陽春。Axios
則更加強大,支援更多功能 ( 像是錯誤攔截、請求取消、全域設定 ),在專案中更方便維護。安裝也很簡單:
npm i axios
搭配前面提到的 JSON-server
做一個簡單範例,假設我們後端有個 /users
API,那麼就能用 Axios 來發送請求。
import { useEffect, useState } from "react";
import axios from "axios";
export default function App() {
const [users, setUsers] = useState([]);
// 載入時自動去抓 users
useEffect(() => {
axios.get("http://localhost:3001/users")
.then((res) => {
setUsers(res.data);
})
.catch((err) => {
console.error("發生錯誤:", err);
});
}, []);
// 新增一個 user
const addUser = async () => {
try {
const res = await axios.post("http://localhost:3001/users", {
name: "小明",
email: "ming@example.com"
});
setUsers((prev) => [...prev, res.data]); // 更新 state
} catch (err) {
console.error("新增失敗:", err);
}
};
return (
<div>
<h1>使用者清單</h1>
<ul>
{users.map((u) => (
<li key={u.id}>{u.name} - {u.email}</li>
))}
</ul>
<button onClick={addUser}>新增使用者</button>
</div>
);
}
這段程式做了兩件事:
/users
的資料並顯示當你登入成功,或是密碼打錯時,如果畫面完全沒有反應,是不是很不安心🫣?
這時候就需要 Toast 來幫忙,它會在角落跳出一個小小訊息,讓使用者立刻知道發生什麼事。
市面上常見的選擇有:
安裝方式:
npm i react-hot-toast
最基本的用法長這樣:
import toast, { Toaster } from "react-hot-toast";
export default function App() {
return (
<>
<button onClick={() => toast.success("登入成功!")}>
點我測試
</button>
<Toaster position="top-right" />
</>
);
}
只要在專案裡放上一個 Toaster
,之後你就能在任何地方呼叫 toast.xxx()
( 像是 toast.success
, toast.error
) 來彈出提示。
這樣一來,登入成功的時候馬上就能在畫面右上角蹦出一個「 登入成功! 」的小通知,讓使用者清楚知道操作有沒有成功。
在進入程式碼之前,先補充一個實用小工具 — concurrently。
因為我們現在專案裡除了要跑 vite
,還要額外開一個 json-server
,這代表你得開兩個終端機分別下指令:
npm run dev
npm run json-server
光是想像就覺得有點麻煩🤯。
這時候 concurrently 就能幫忙,把兩個指令「合併」成一個。只要安裝:
npm i -D concurrently
然後在 package.json
裡調整 script:
"scripts": {
"dev": "concurrently \"vite\" \"json-server --watch src/lib/db.json --port 3001\"",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"json-server": "json-server --watch src/lib/db.json --port 3001"
這樣在執行 npm run dev
時,會一併將 json-server 給開啟。
authApiCall.ts
import axios from "axios";
export interface User {
id: number;
email: string;
username: string;
password?: string;
}
export interface AuthData {
username: string;
email?: string;
password?: string;
recaptcha: string;
}
// --- json-server API ---
export const jsonLoginApiCall = async (
data: AuthData
): Promise<Omit<User, "password">> => {
const { username, password, recaptcha } = data;
const response = await axios.get<User[]>(`/api/users?username=${username}`);
const users = response.data;
if (users.length === 0) {
throw new Error("帳號或密碼不一致");
}
const user = users[0];
if (user.password !== password) {
throw new Error("帳號或密碼不一致");
}
if (!recaptcha) {
throw new Error("reCAPTCHA 驗證失敗,請重試。");
}
// 從 user 物件中移除 password 屬性再返回
const { password: _, ...userToReturn } = user;
return userToReturn;
};
export const jsonRegisterApiCall = async (
data: AuthData
): Promise<Omit<User, "password">> => {
const { username, email, password, recaptcha } = data;
if (!recaptcha) {
throw new Error("reCAPTCHA 驗證失敗,請重試。");
}
const checkResponse = await axios.get<User[]>(
`/api/users?username=${username}`
);
if (checkResponse.data.length > 0) {
throw new Error("此使用者名稱已被註冊");
}
// json-server 會自動生成 id
const newUserResponse = await axios.post<User>("/api/users", {
username,
email,
password,
});
// 從 newUserResponse.data 中移除 password 屬性再返回
const { password: _, ...registeredUser } = newUserResponse.data;
return registeredUser;
};
LoginForm.tsx
import { useRef } from "react";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import toast from "react-hot-toast";
import ReCAPTCHA from "react-google-recaptcha";
import { loginSchema, type LoginInputs } from "@/utils/validSchema";
import { jsonLoginApiCall } from "@/api/authApiCall";
import InputField from "@components/ui/InputField";
import RecaptchaField from "@/components/ui/RecaptchaField";
export default function LoginForm() {
const {
register,
handleSubmit,
setValue,
formState: { errors, isSubmitting },
trigger,
} = useForm<LoginInputs>({
resolver: yupResolver(loginSchema),
mode: "onBlur",
});
const recaptchaRef = useRef<ReCAPTCHA | null>(null);
const onRecaptchaChange = (value: string | null) => {
setValue("recaptcha", value ?? "");
trigger("recaptcha");
};
const onSubmit = async (data: LoginInputs) => {
console.log("表單提交資料:", data);
let loadingToastId: string | undefined;
try {
loadingToastId = toast.loading("登入中...");
const loggedInUser = await jsonLoginApiCall(data);
toast.success("登入成功!", { id: loadingToastId });
console.log("登入成功:", loggedInUser);
} catch (error: unknown) {
let errorMessage = "操作失敗,請稍後再試。";
if (error instanceof Error) {
errorMessage = error.message;
}
toast.error(errorMessage, { id: loadingToastId });
console.error("操作失敗:", error);
// 重置 reCAPTCHA
if (recaptchaRef.current) {
recaptchaRef.current.reset();
}
setValue("recaptcha", "", { shouldValidate: false });
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col space-y-8">
{/* 使用者名稱 */}
<InputField
id="username"
label="使用者名稱"
icon="user"
type="text"
autoComplete="username"
register={register}
errors={errors}
/>
{/* 密碼 */}
<InputField
id="password"
label="密碼"
icon="lock"
type="password"
autoComplete="current-password"
register={register}
errors={errors}
showPwdBtn={true}
showForgotPwdLink={true}
/>
{/* reCAPTCHA 核取方塊 */}
<RecaptchaField
onChange={onRecaptchaChange}
error={errors.recaptcha?.message}
ref={recaptchaRef}
/>
{/* 登入按鈕 */}
<div className="flex justify-center">
<button
type="submit"
disabled={isSubmitting}
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"
>
{isSubmitting ? "登入中..." : "登入"}
</button>
</div>
</form>
);
}
main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { Toaster } from "react-hot-toast";
import "./index.css";
import "@lib/fontawesome";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
<Toaster
position="bottom-center"
reverseOrder={false}
gutter={8}
containerStyle={{ bottom: 100 }}
toastOptions={{
className: "",
duration: 3000,
style: {
background: "#e0e0e0",
},
success: {
duration: 3000,
style: {
background: "#91E586", // 淺綠
color: "#fff",
},
},
error: {
duration: 5000,
style: {
background: "#E6858C", // 淺紅
color: "#fff",
},
},
loading: {
duration: Infinity,
style: {
background: "#e0e0e0",
},
},
}}
/>
</BrowserRouter>
</StrictMode>
);