React实现注册自动登录及手动登录生成JWT存Cookie
问题背景
基于MERN栈开发聊天应用,后端已实现注册/登录时将JWT令牌存入HttpOnly Cookie,且注册后自动登录。需在React前端完成以下需求:
- 注册成功后自动登录(利用后端返回的Cookie)
- 手动登录时触发后端生成新JWT并写入Cookie
- 前端识别登录状态,实现路由保护
后端现有实现(authController.js)
后端已通过createSendToken函数处理JWT生成与Cookie设置,注册和登录接口均调用该函数完成令牌下发。
import jwt from 'jsonwebtoken' import path from 'path' import { fileURLToPath } from 'url' import User from '../models/userModel.js' import catchAsync from '../utils/catchAsync.js' import AppError from '../utils/appError.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const signToken = id => { return jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN }) } const createSendToken = (user, statusCode, req, res) => { const token = signToken(user._id) const cookieOptions = { expires: new Date(Date.now() + process.env.JWT_COOKIE_EXPIRES_IN * 24 * 60 * 60 * 1000), httpOnly: true // 防止XSS攻击,禁止前端JS访问Cookie } // 生产环境下仅通过HTTPS发送Cookie if (process.env.NODE_ENV === 'production') cookieOptions.secure = true // 发送Cookie res.cookie('jwt', token, cookieOptions) // 返回结果中隐藏密码 user.password = undefined res.status(statusCode).json({ status: 'success', token, data: { user } }) } export const signup = catchAsync(async (req, res) => { let { profilePhoto } = req.body const randomImageNumber = Math.floor(Math.random() * 5) + 1 profilePhoto = `img-${randomImageNumber}.jpg` const newUser = await User.create({ ...req.body, profilePhoto }) createSendToken(newUser, 201, req, res) }) export const login = catchAsync(async (req, res, next) => { const { userName, password } = req.body if (!userName || !password) { return next(new AppError('请提供用户名和密码', 400)) } const user = await User.findOne({ userName }).select('+password') if (!user || !(await user.correctPassword(password, user.password))) { return next(new AppError('用户名或密码错误', 401)) } createSendToken(user, 200, req, res) })
前端实现步骤
1. 全局登录状态管理(AuthContext.js)
创建全局上下文管理用户登录状态,方便各组件获取登录信息:
import { createContext, useContext, useState, useEffect } from 'react'; const AuthContext = createContext(); export const AuthProvider = ({ children }) => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); // 初始化时验证用户是否已登录(调用后端验证接口) useEffect(() => { const checkAuth = async () => { try { const res = await fetch('/api/v1/auth/me', { credentials: 'include' // 携带Cookie发送请求 }); if (res.ok) { const data = await res.json(); setUser(data.data.user); } } catch (err) { console.log('未登录或令牌失效'); } finally { setLoading(false); } }; checkAuth(); }, []); // 登录成功后更新用户状态 const login = (userData) => { setUser(userData); }; // 登出时清除用户状态(后端需清除Cookie) const logout = async () => { try { await fetch('/api/v1/auth/logout', { credentials: 'include' }); setUser(null); } catch (err) { console.error('登出失败'); } }; return ( <AuthContext.Provider value={{ user, login, logout, loading }}> {children} </AuthContext.Provider> ); }; export const useAuth = () => useContext(AuthContext);
2. 配置全局上下文(main.jsx)
在入口文件中包裹AuthProvider:
import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App.jsx"; import "./index.css"; import { BrowserRouter } from "react-router-dom"; import { AuthProvider } from "./context/AuthContext"; // 引入上下文 ReactDOM.createRoot(document.getElementById("root")).render( <React.StrictMode> <BrowserRouter> <AuthProvider> <App /> </AuthProvider> </BrowserRouter> </React.StrictMode> );
3. 完善注册逻辑(useSignup.js)
注册成功后跳转首页,并更新全局用户状态:
import { useState } from "react"; import toast from "react-hot-toast"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; const useSignup = () => { const [loading, setLoading] = useState(false); const navigate = useNavigate(); const { login } = useAuth(); const signup = async ({ name, userName, email, password, passwordConfirm, gender }) => { const success = handleInputErrors({ name, userName, email, password, passwordConfirm, gender }); if (!success) return; setLoading(true) try { const res = await fetch("/api/v1/auth/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, userName, email, password, passwordConfirm, gender }), credentials: 'include' // 确保携带Cookie }); const data = await res.json(); if (data.status !== 'success') { throw new Error(data.message || '注册失败') } // 更新全局用户状态 login(data.data.user); // 跳转首页 navigate('/'); toast.success('注册成功'); } catch (error) { toast.error(error.message) } finally { setLoading(false); } }; return { loading, signup }; }; export default useSignup; function handleInputErrors({ name, userName, email, password, passwordConfirm, gender }) { if (!name || !userName || !email || !password || !passwordConfirm || !gender) { toast.error("请填写所有字段"); return false; } if (password !== passwordConfirm) { toast.error("两次输入的密码不一致"); return false; } return true; }
4. 实现登录功能(useLogin.js)
创建登录钩子,处理登录请求并更新状态:
import { useState } from "react"; import toast from "react-hot-toast"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; const useLogin = () => { const [loading, setLoading] = useState(false); const navigate = useNavigate(); const { login } = useAuth(); const loginUser = async ({ userName, password }) => { if (!userName || !password) { toast.error("请填写用户名和密码"); return; } setLoading(true); try { const res = await fetch("/api/v1/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userName, password }), credentials: 'include' }); const data = await res.json(); if (data.status !== 'success') { throw new Error(data.message || '登录失败'); } login(data.data.user); navigate('/'); toast.success('登录成功'); } catch (error) { toast.error(error.message); } finally { setLoading(false); } }; return { loading, loginUser }; }; export default useLogin;
5. 登录页面(Login.jsx)
实现登录表单:
import { Link } from "react-router-dom"; import { useState } from "react"; import useLogin from "../../hooks/useLogin"; const Login = () => { const [inputs, setInputs] = useState({ userName: "", password: "", }); const { loading, loginUser } = useLogin(); const handleSubmit = async (e) => { e.preventDefault(); await loginUser(inputs); }; return ( <div className="flex flex-col items-center justify-center min-w-96 mx-auto"> <div className="h-full w-full bg-white-400 rounded-2xl bg-clip-padding backdrop-filter backdrop-blur-xl bg-opacity-30 border border-gray-100 p-5"> <h1 className="text-3xl font-semibold text-center text-gray-200"> 登录 <span className="text-blue-500">ChatApp</span> </h1> <form onSubmit={handleSubmit}> <div> <label className="label pb-0"> <span className="text-base text-gray-50 label-text"> 用户名 </span> </label> <input type="text" placeholder="输入用户名" className="input input-bordered input-info w-full h-8" value={inputs.userName} onChange={(e) => setInputs({ ...inputs, userName: e.target.value }) } disabled={loading} /> </div> <div className="mt-4"> <label className="label pb-0"> <span className="text-base text-gray-50 label-text"> 密码 </span> </label> <input type="password" placeholder="输入密码" className="input input-bordered input-info w-full h-8" value={inputs.password} onChange={(e) => setInputs({ ...inputs, password: e.target.value }) } disabled={loading} /> </div> <div className="text-center mt-2"> <Link to="/signup" className="text-sm text-gray-50 hover:underline hover:text-blue-600 inline-block"> 还没有账号?立即注册 </Link> </div> <div className="text-center py-3"> <button className="btn min-h-0 h-8 btn-outline btn-info" disabled={loading} > {loading ? '登录中...' : '登录'} </button> </div> </form> </div> </div> ); }; export default Login;
6. 路由保护与App.jsx优化
修改App.jsx,实现未登录时重定向到登录页:
import { Navigate, Route, Routes } from "react-router-dom"; import "./App.css"; import Home from "./pages/home/Home"; import Login from "./pages/login/Login"; import SignUp from "./pages/signup/SignUp"; import { Toaster } from "react-hot-toast"; import { useAuth } from "./context/AuthContext"; function App() { const { user, loading } = useAuth(); // 加载状态时显示加载提示 if (loading) { return ( <div className="h-screen flex items-center justify-center"> <div className="text-xl text-gray-200">加载中...</div> </div> ); } // 路由保护组件 const ProtectedRoute = ({ children }) => { if (!user) { return <Navigate to="/login" replace />; } return children; }; return ( <div className="p-4 h-screen flex items-center justify-center overflow-auto"> <Routes> <Route path="/" element={ <ProtectedRoute> <Home /> </ProtectedRoute> } /> <Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} /> <Route path="/signup" element={user ? <Navigate to="/" replace /> : <SignUp />} /> </Routes> <Toaster /> </div> ); } export default App;
7. 注册页面优化(SignUp.jsx)
添加按钮加载状态禁用:
import { Link } from "react-router-dom"; import GenderCheckbox from "./GenderCheckbox"; import { useState } from "react"; import useSignup from "../../hooks/useSignup"; const SignUp = () => { const [inputs, setInputs] = useState({ name: "", userName: "", email: "", password: "", passwordConfirm: "", gender: "", }); const { loading, signup } = useSignup(); const handleCheckboxChange = (gender) => { setInputs({ ...inputs, gender }); }; const handleSubmit = async (e) => { e.preventDefault(); await signup(inputs); }; return ( <div className="flex flex-col items-center justify-center min-w-96 mx-auto"> <div className="h-full w-full bg-white-400 rounded-2xl bg-clip-padding backdrop-filter backdrop-blur-xl bg-opacity-30 border border-gray-100 p-5"> <h1 className="text-3xl font-semibold text-center text-gray-200"> 注册 <span className="text-blue-500"> ChatApp</span> </h1> <form onSubmit={handleSubmit}> {/* 姓名输入框 */} <div> <label className="label pb-0"> <span className="text-base text-gray-50 label-text"> 姓名 </span> </label> <input type="text" placeholder="输入姓名" className="input input-bordered input-info w-full h-8" value={inputs.name} onChange={(e) => setInputs({ ...inputs, name: e.target.value }) } disabled={loading} /> </div> <div className="mt-4"> <label className="label pb-0"> <span className="text-base text-gray-50 label-text"> 用户名 </span> </label> <input type="text" placeholder="输入用户名" className="input input-bordered input-info w-full h-8" value={inputs.userName} onChange={(e) => setInputs({ ...inputs, userName: e.target.value }) } disabled={loading} /> </div> <div className="mt-4"> <label className="label pb-0"> <span className="text-base text-gray-50 label-text"> 邮箱 </span> </label> <input type="email" placeholder="输入邮箱" className="input input-bordered input-info w-full h-8" value={inputs.email} onChange={(e) => setInputs({ ...inputs, email: e.target.value }) } disabled={loading} /> </div> <div className="mt-4"> <label className="label pb-0"> <span className="text-base text-gray-50 label-text"> 密码 </span> </label> <input type="password" placeholder="输入密码" className="input input-bordered input-info w-full h-8" value={inputs.password} onChange={(e) => setInputs({ ...inputs, password: e.target.value }) } disabled={loading} /> </div> <div className="mt-4"> <label className="label pb-0"> <span className="text-base text-gray-50 label-text"> 确认密码 </span> </label> <input type="password" placeholder="确认密码" className="input input-bordered input-info w-full h-8" value={inputs.passwordConfirm} onChange={(e) => setInputs({ ...inputs, passwordConfirm: e.target.value }) } disabled={loading} /> </div> <GenderCheckbox onCheckboxChange={handleCheckboxChange}
相关产品推荐
相关产品推荐

