本篇文章純前端不含後端 API 的部分,如果需要撰寫 API 可以看我之前發的文章:
準備工具
- Vite: 用於快速構建 React 項目 (React ^18.0.0)
- Jotai: 狀態管理工具 (^1.7.3)
- React-router: 應用路由管理 (^6.3.0)
- Antd: UI 組件庫 (^4.21.4)
- Axios: AJAX請求
初始化 React 項目

推薦使用 Vite 快速建構 React 項目,之所以不用 create react app 的原因是因為 vite 初始化以及啟動…等速度較快,不會亂幫你裝一些不需要的套件,項目當然速度越快越好。
$ npm create vite@latest // 1. 輸入 Project name: react-jotai // 2. 選擇 react // 3. variant 選擇 react $ cd react-jotai $ npm install $ npm run dev
在 src 底下新建 components
, pages
兩個資料夾,並把 App.jsx
和 App.css
中的內容和 logo.svg
刪除。
安裝 Jotai

什麼是 Jotai
你可以把 Jotai 理解為更精簡的 Recoil,一樣是通過組合原子來構建狀態的狀態管理函式庫,並且優化了渲染的機制,可以解決 React context 額外重新渲染的問題。
如果對 Jotai 誕生的背景有興趣可以看一下官方的文檔,有專門提到:Concepts
安裝 jotai
npm install jotai // 或 yarn add jotai
初始化頁面路由
安裝 react-router:
npm install react-router-dom@6
在 src/pages 底下新建 Login.jsx
及 Register.jsx
:
// pages/Login.jsx import React from "react"; export default function Login() { return <div>Login</div>; }
// pages/Register.jsx import React from "react"; export default function Register() { return <div>Register</div>; }
接著將這兩個頁面引入到 main.jsx
中,並配置好 login 跟 register 頁面的路由:
import React from "react"; import ReactDOM from "react-dom/client"; import { HashRouter as Router, Routes, Route } from "react-router-dom"; import App from "./components/App"; import Login from "./pages/Login"; import Register from "./pages/Register"; import "antd/dist/antd.css"; import "./index.css"; ReactDOM.createRoot(document.getElementById("root")).render( <Router> <Routes> <Route path="/" element={<App />} /> <Route path="/login" element={<Login />} /> <Route path="/register" element={<Register />} /> </Routes> </Router> );
登入註冊頁面 UI
現在我們來挑選一個 UI 組件庫,這個項目我使用 Antd,因為組件比較全,不想花太多時間在折騰 UI 上。

安裝 antd:
npm install antd --save
在 main.jsx
中引入 antd 樣式:
import "antd/dist/antd.css";
接著在 pages 中新建 Login.jsx
和 Register.jsx
,大概做出個登入登出頁面的雛型,這邊我有裝 styled-components 來輔助撰寫 CSS。


這邊就不分享程式碼了,最後我會附上 github repo。
建立 axios 實例
現在我們可以在 src 底下新增 api.js
,使用 axios.create({})
建立一個 axios 實例,並將所有 API 寫入檔案中統一管理。
// src/api.js import axios from "axios"; const baseURL = 'http://127.0.0.1:8080'; export const request = axios.create({ baseURL, headers: { 'Content-Type': 'application/json' }, }); // login & logout export const postUserLogin = (data) => request.post("/login", data); export const postUserRegister = (data) => request.post("/register", data);
撰寫登入方法
現在準備工作已齊全,可以來接登入 API 了,將 postUserLogin API 引入到 Login.jsx 中,並完成登入方法。
import { request, postUserLogin } from "../api"; // ... const onFinish = (values) => { const { account, password } = values; const payload = { account, password }; postUserLogin(payload) .then((res) => { const { account, authority } = res.data.user; const { token } = res.data; // 將用戶資料及 token 保存到本地 localStorage localStorage.setItem("account", account); localStorage.setItem("authority", authority); localStorage.setItem("token", token); // 將 token 放入請求頭中 request.defaults.headers['Authorization'] = 'Bearer ' + token; }) .catch((e) => console.log(e)); };
現在我們是將所有資料都保存到 localStorage 中,這樣做的優點是方便,如果用戶資料是不會變動的項目其實是沒什麼影響的,但是當我們需要監聽及實時改變用戶資料時,放在 localStorage 就會變得非常不方便,因為瀏覽器無法監聽 localStorage 變化。
那麼今天我會使用 Jotai 這個函式庫,用於管理應用的全局狀態,來達到實時改變用戶資料的效果。
使用 Jotai 管理全局狀態
最開始我們已經安裝過 Jotai(如果還沒安裝可以使用指令快速安裝:npm i jotai --save
),現在我們需要先初始化一下。
在 src 底下新增 store.js:
// @src/store.js import { atom } from "jotai"; export const UserAtom = atom({ account: null, authority: 3, // 1: 系統管理員, 2: 一般會員, 3: 遊客 token: null });
回到 Login.jsx
中引入 useAtom hooks 及剛剛新增的 UserAtom,我們可以利用 useAtom 這個 hooks 來讀取何改變 atom:
import { Link, useNavigate } from "react-router-dom"; import { useAtom } from "jotai"; import { UserAtom } from "../store"; // ... export default function Login() { const navigate = useNavigate(); const [state, setState] = useAtom(UserAtom); // 獲取及管理全局 User 狀態 // 登入方法 const onFinish = (values) => { const { account, password } = values; const payload = { account, password }; postUserLogin(payload) .then((res) => { const { account, authority } = res.data.user; const { token } = res.data; // 將用戶資料及 token 保存到全局狀態中 setState({ account, authority, token }); // 將 token 放入請求頭中 request.defaults.headers['Authorization'] = 'Bearer ' + token; // 跳轉至首頁 navigate("/"); }) .catch((e) => console.log(e)); };
錯誤處理
在 src 底下新增一個 functions.js
,並在裡面加上一個 handleError
方法,當捕獲到錯誤時彈出訊息提示框提示使用者:
import { notification } from 'antd'; const handleError = (error) => { const { data } = error.response; // 捕獲到錯誤時彈出錯誤通知提醒框 if (!data?.success) { notification.error({ placement: 'top', message: '錯誤', description: data.message }); } else { console.log(data); } } export { handleError };
將 handleError 引入到 Login.jsx
中,一旦登入失敗時就跳出提示框提示使用者錯誤原因:
import { handleError } from "../functions"; // ... export default function Login() { // ... const onFinish = (values) => { const { account, password } = values; const payload = { account, password }; postUserLogin(payload) .then((res) => { const { account, authority } = res.data.user; const { token } = res.data; // 將用戶資料及 token 保存到全局狀態中 setState({ account, authority, token }); // 將 token 放入請求頭中 request.defaults.headers['Authorization'] = 'Bearer ' + token; // 跳轉至首頁 navigate("/"); }) .catch((e) => handleError(e)); }; }

完成登入登出跳轉
剛剛已經完成了登入跟錯誤處理的方法,現在我們需要將登入後獲取到的用戶資訊顯示在頁面上:
import { Link } from "react-router-dom"; import { useAtom } from "jotai"; import { Button } from "antd"; import { UserAtom } from "../store"; function App() { const [state, setState] = useAtom(UserAtom); return ( <div className="column h-100 jc-center al-center"> {/* 登入後顯示用戶資訊 */} {state.token && ( <div className="column jc-center"> <p className="t-center"> 歡迎 {state.account}!您的權限為:{state.authority}。 </p> <Button type="primary">登出</Button> </div> )} {/* 未登入時顯示登入及註冊的連結 */} {!state.token && ( <div> <p className="jc-center">您尚未登入!</p> <Link to="/login" className="mr-1">登入</Link> <Link to="/register">註冊</Link> </div> )} </div> ); } export default App;

接著加上登出方法,登出時將全局狀態中的用戶資料清空,並且將請求頭的 authorization 改為 null:
const logout = () => { // 將用戶資料及 token 清除 setState({ account: null, authority: 3, token: null }); // 將 token 清除 request.defaults.headers['Authorization'] = null; } // ... <Button type="primary" onClick={logout}>登出</Button>
而因為登出後 state.token 為 null,所以畫面上會顯示您尚未登入,如此一來就完成了最基本的登入登出跳轉。

撰寫註冊方法
基本寫法和登入一模一樣,只不過多了一個權限的下拉選單而已,所以就不再多加篇幅。
import { Typography, Button, Form, Input, Select } from "antd"; import { Link, useNavigate } from "react-router-dom"; import { useAtom } from "jotai"; import { UserAtom } from "../store"; import { request, postUserRegister } from "../api"; import { handleError } from "../functions"; const { Option } = Select; const authorityList = [ { value: 1, label: '系統管理員' }, { value: 2, label: '一般會員' } ]; // ... const navigate = useNavigate(); const [state, setState] = useAtom(UserAtom); const [authority, setAuthority] = useState(2); // 選擇權限 const handleChange = (value) => { setAuthority(value); }; const onFinish = (values) => { const { account, password } = values; const payload = { account, password, authority }; postUserRegister(payload) .then((res) => { const { account, authority } = res.data.user; const { token } = res.data; // 將用戶資料及 token 保存到全局狀態中 setState({ account, authority, token }); // 將 token 放入請求頭中 request.defaults.headers["Authorization"] = "Bearer " + token; // 跳轉至首頁 navigate("/"); }) .catch((e) => handleError(e)); }; // ... <Form.Item className="mb-2" label="權限" name="authority" initialValue={2} rules={[{ required: true, message: "請選擇權限!" }]} > <Select onChange={handleChange}> {authorityList.map(({ value, label }) => ( <Option value={value}>{label}</Option> ))} </Select> </Form.Item>

永久性存儲全局狀態
至此登入登出及註冊功能都已完成,並且路由跳轉也很正常,但是當你一刷新頁面時就會發現全局狀態中的值被初始化了,所以又變成了尚未登入的狀態。
在 main.jsx
中將整個應用包裹在 Provider 之中:
// main.jsx import { Provider } from 'jotai'; // ... ReactDOM.createRoot(document.getElementById("root")).render( <Provider> <BrowserRouter> <Routes> <Route path="/" element={<App />} /> <Route path="login" element={<Login />} /> <Route path="register" element={<Register />} /> </Routes> </BrowserRouter> </Provider> );
Jotai 在 utils 包中有一個 atomWithStorage
函數用於持久化,支持在sessionStorage、localStorage、AsyncStorage或 URL hash中持久化狀態。
- 函數:
atomWithStorage(key, initialValue)
- key(必需):在與 localStorage、sessionStorage 或 AsyncStorage 同步狀態時用作鍵的唯一字串。
- initialValue(必需):atom 的初始值。
- storage(可選):一個物件。
- 用於存儲(setItem)/獲取(getItem)持久狀態的方法,默認為
localStorage
。
- 用於存儲(setItem)/獲取(getItem)持久狀態的方法,默認為
現在我們將 store.js
中的 atom 改為永久性存儲:
import { atom } from "jotai"; import { atomWithStorage } from "jotai/utils"; const _userAtom = atom({ account: null, authority: 3, // 3 為遊客 token: null }); const UserAtom = atomWithStorage('user', _userAtom); export { UserAtom };
完成之後登入試試看,會發現登入之後,Jotai 自動將用戶資料儲存到 localStorage 了:

路由鑒權
登入後無法再訪問登入及註冊頁
現在有一個問題是,當我們登入跳轉至首頁後,我們卻還可以透過修改 url 回到登入、註冊頁面,很明顯這個是不正確的,因此我們會需要在路由跳轉前先檢查 user atom 中是否有 Token,如果有就不能訪問登入及註冊頁面。
// pages/Login.jsx import React, { useEffect } from "react"; import { Link, useNavigate, useLocation } from "react-router-dom"; import { useAtom } from "jotai"; import { UserAtom } from "../store"; // ... export default function Login() { const { pathname } = useLocation(); const navigate = useNavigate(); const [state, setState] = useAtom(UserAtom); useEffect(() => { // 已登入無法再訪問登入頁面 if (state.token) { navigate("/"); } }, [pathname]); // ... }
同樣註冊頁面也要判斷,這邊就不再演示了。
未登入無法訪問首頁
前面我們寫的是無論有沒有登入都可以到首頁,查看登入狀態,但現在我們要將它改為未登入的話無法訪問首頁,會跳轉至登入頁面。
方法和剛剛差不多,只不過這次是判斷無 Token 的情況:
// components/App.jsx import React, { useEffect } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { useAtom } from "jotai"; import { Button } from "antd"; import { UserAtom } from "../store"; import { request } from "../api"; function App() { const navigate = useNavigate(); const { pathname } = useLocation(); const [state, setState] = useAtom(UserAtom); useEffect(() => { if (!state.token) navigate("/login"); }, [pathname]); const logout = () => { // 將用戶資料及 token 清除 setState({ account: null, authority: 3, token: null }); // 將 token 清除 request.defaults.headers["Authorization"] = null; // 跳轉至登入頁 navigate("/login"); }; return ( <div className="column h-100 jc-center al-center"> <div className="column jc-center"> <p className="t-center"> 歡迎 {state.account}!您的權限為:{state.authority}。 </p> <Button type="primary" onClick={logout}> 登出 </Button> </div> </div> ); } export default App;
本篇文章所有程式碼我都已經 push 到我的 github repo,有需要的話可以看一下:React-Express-Mongoose-Login