iT邦幫忙

2025 iThome 鐵人賽

DAY 9
0
Modern Web

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

房門與門鎖[ 6 / 6 ]:完整流程 — Axios + JSON Server + Toast

  • 分享至 

  • xImage
  •  

在上一篇,我們加入了「社群登入」,讓使用者不用每次都輸入帳密,就能快速登入。而這一篇會是「 房門與門鎖 」的最後一個篇章🤩,我們要把整體流程補齊,並加點小巧思,讓整體體驗更流暢。

本篇重點整理:

  • Axios + JSON Server:簡單介紹兩者的角色與作用,並做一個簡單範例
  • Toast:簡單講述其作用並附上範例講解
  • 完整流程實作:將這些工具串接在一起,製作一個完整的登入驗證流程

JSON-server

在真實專案裡,我們通常需要一個後端伺服器來處理登入、註冊、存取資料… 但問題來了:

開發初期後端還沒建好怎麼辦🤔?

別擔心,這時候就可以靠 JSON Server 來頂上。

它的功能很簡單:

  • 把一個 JSON 檔案(例如 db.json)當作資料庫使用
  • 自動生成 CRUD API(新增、讀取、更新、刪除)
  • 讓前端開發者能快速模擬「有後端」的環境

安裝方式:

npm i -D json-server

在 package.json 裡加上啟動指令:

"scripts": {
  "server": "json-server --watch db.json --port 3001"
}

然後建立 db.json:

{
  "users": []
}

跑起來後,你就能透過 http://localhost:3001/users,像跟真正的後端一樣拿資料。對前端練習來說,會是個非常方便的「假後端」。


Axios — 前端的對講機📡

想像一下工地現場,工人不可能只靠「 眼神交流 」來溝通吧?這時候 對講機 就很重要了。

前端和後端的關係也差不多:

  • 後端就像是倉庫,裡面放著各種資料 ( 使用者、任務清單、產品列表… )。
  • 前端就是工人,需要什麼東西,就要「呼叫」後端拿來。

這個呼叫的方式就是 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>
  );
}

這段程式做了兩件事:

  • GET → 一開始載入,就去抓 /users 的資料並顯示
  • POST → 按下按鈕,會新增一個使用者到「 假後端 」

React-hot-toast

當你登入成功,或是密碼打錯時,如果畫面完全沒有反應,是不是很不安心🫣?
這時候就需要 Toast 來幫忙,它會在角落跳出一個小小訊息,讓使用者立刻知道發生什麼事。

市面上常見的選擇有:

  • react-toastify:功能齊全、客製化程度高,但檔案體積較大
  • react-hot-toast:更輕量、支援漂亮的 loading / success / error 狀態,適合快速開發

安裝方式:

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"

https://ithelp.ithome.com.tw/upload/images/20250923/20110586OSh5T4R5SB.png
這樣在執行 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>
);

上一篇
房門與門鎖[ 5 / 6 ]:OAuth 實戰 — 用 Firebase 實作 Google 登入
系列文
不只是登入畫面!一起打造現代化登入系統9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言