哈喽,掘金的各位全栈练习生们!👋 欢迎回到 AI 全栈项目实战 的第七天!
昨天,我们一起搭建了 Notes 应用的“门面担当”——幻灯片组件,还学会了用 Mock.js 当了一回“欺诈师”,伪造了首页的文章列表数据。感觉如何?是不是看着那个轮播图自动播放,心里美滋滋的?😎
但是,细心的同学可能发现了,我们的应用现在就像一个没锁门的豪宅,谁都能进,谁都能看。这怎么行?今天,我们要给这个豪宅装上一把高科技的“智能锁”——登录系统!
今天的任务量依旧“硬核”,我们将打通从 UI 组件 -> 状态管理 -> API 请求 -> 拦截器 -> Mock 后端 -> JWT 令牌 的全链路。这不仅仅是写个登录框那么简单,我们要学会如何像架构师一样思考数据的流动。
准备好了吗?系好安全带,我们要发车了!🏎️
🛠️ 一、 后端模拟:Mock 与 JWT 的“狼狈为奸”
在真实的全栈开发中,后端通常会提供一个接口,验证你的账号密码,验证成功后给你发一个“通行证”(Token)。以后你再请求数据,只要亮出这个通行证,后端就知道你是谁了。
这个通行证,最常用的技术就是 JWT (JSON Web Token)。
虽然我们的后端还没真正连上数据库,但这不妨碍我们用 Mock.js 先模拟出一套完美的登录流程。
1.1 Mock 登录接口:不仅是返回数据,还要“演”得像
打开 mock/login.js,让我们来看看这段“伪造”后端逻辑的代码。
// mock/login.js
import jwt from 'jsonwebtoken'; // 引入 JWT 库,用来签发和验证 Token
const secret = "ncOIBbCbcnNc"; // 🔑 密钥:这可是机密,就像保险柜的密码,绝对不能泄露!
export default [
{
// 模拟登录接口
url: '/api/auth/login',
method: 'post',
timeout: 2000, // ⏳ 重点来了:模拟网络延迟!
response: (req, res) => {
// 从请求体中获取用户名和密码
let { name, password } = req.body;
name = name.trim();
password = password.trim();
// 🚫 第一道关卡:参数校验
// 后端必须要严谨,不能前端传什么就是什么
if(name === '' || password === ''){
return {
code: 400, // Bad Request
msg: '用户名或密码错误',
}
}
// 🚫 第二道关卡:身份验证
// 这里我们硬编码模拟一下,真实场景要去数据库查
if(name !== 'admin' || password !== '123456'){
return {
code: 401, // Unauthorized
msg: '用户名或密码错误',
}
}
// ✅ 验证通过!接下来就是颁发通行证(Token)的时间了
// ... 见下文 JWT 讲解
}
},
// ... 其他接口
]
🧐 代码深度解析:
timeout: 2000:这个参数非常实用!在本地开发时,接口响应往往是毫秒级的,让你产生“我的代码跑得真快”的错觉。加上 2秒 的延迟,能让你真实地感受到 Loading 状态的重要性,也能帮你测试防抖节流是否生效。- 严谨的校验逻辑:虽然是 Mock,但我们依然写了
if判断。先判断非空,再判断账号密码是否匹配(admin/123456)。这不仅仅是写代码,更是培养一种“后端思维”——永远不要信任前端传来的数据。
1.2 JWT 的魔法:Sign (加密) 与 Decode (解密)
验证通过后,后端需要生成一个 Token 返回给前端。这个 Token 必须包含用户的信息,但又不能被篡改。这就需要 jsonwebtoken 库登场了。
✍️ 签发令牌 (Sign)
// mock/login.js 中的 response 内部
// 📝 jwt.sign(payload, secret, options)
const token = jwt.sign({
user: { // payload: 载荷,也就是你要在 Token 里存的数据
id: 1,
name: "admin",
avatar: "https://p9-passport.byteacctimg.com/img/user-avatar/..."
}
}, secret, { // secret: 上面定义的密钥,用于加密签名
expiresIn: 86400 * 7 // options: 配置项,这里设置有效期为 7 天
})
// 返回给前端
return {
token, // 拿到这个,就是拿到尚方宝剑了!
user: { ... } // 顺便把用户信息也返给前端展示用
}
- Payload (载荷):这里我们存了
user对象。注意,千万不要在 Payload 里存密码等敏感信息!因为 Token 只是被 Base64 编码签名的,别人拿到 Token 是可以解码看到里面的内容的,只是没法篡改而已。 - Secret (盐):就像炒菜要加盐一样,加密也要加“盐”。这个字符串只有后端知道。如果别人想伪造一个 Token,因为不知道这个“盐”,生成的签名就会对不上,后端一验就露馅了。
- ExpiresIn (有效期):Token 不能永久有效,否则一旦泄露就完蛋了。这里设置 7 天,既方便调试,又符合一般的 App 登录策略。
🔓 验证令牌 (Decode/Verify)
除了登录,我们还模拟了一个 /api/auth/check 接口,用于通过 Token 获取用户信息。
// mock/login.js
{
url: '/api/auth/check',
method: 'get',
response: (req, res) => {
// 从请求头的 Authorization 字段中提取 Token
// 格式通常是 "Bearer <token>",所以要用 split(' ')[1] 取后面那部分
const token = req.headers['authorization'].split(' ')[1];
try {
// 🔓 解码 Token
// 注意:真实后端验证通常用 jwt.verify(),它会验证签名和有效期
// 这里为了简单演示 Mock 数据解析,使用了 decode
const decode = jwt.decode(token, secret);
console.log(decode);
return {
code: 200,
user: decode.user // 把之前存进去的用户信息取出来
}
} catch(err) {
return {
code: 400,
message: 'token 无效'
}
}
}
}
这里我们展示了如何从 Token 中把之前塞进去的 user 数据“抠”出来。这在后端鉴权中间件中是非常经典的操作。
📡 二、 前端 API 模块化:Axios 的进阶玩法
后端 Mock 准备好了,前端怎么优雅地发送请求呢?直接在组件里写 axios.post?No No No,那是新手村的做法。我们要模块化!
2.1 定义类型与 API 函数
首先,TypeScript 大法好。我们在 src/types/index.ts 里定义好数据结构,防止传错参。
// src/types/index.ts
export interface Credentail {
name: string;
password: string;
}
// User 接口之前定义过了,这里复用
export interface User { ... }
然后,在 src/api/user.ts 中封装具体的请求函数:
// src/api/user.ts
import axios from './config'; // ⚠️ 注意:引入的是我们配置过的 axios 实例,不是默认的!
import type { Credentail } from '@/types';
export const doLogin = (data: Credentail) => {
// 只需要写相对路径,因为 baseURL 已经在 config 里配置了
return axios.post('/auth/login', data);
}
2.2 核心重头戏:Axios 拦截器 (Interceptors)
为什么要专门搞一个 src/api/config.ts?因为大型项目中,接口地址可能成百上千,如果以后后端换了域名,难道我们要去几百个文件里一个个改吗?
而且,我们希望每次请求都自动带上 Token,每次响应都自动处理错误。这就是拦截器的舞台。
// src/api/config.ts
import axios from 'axios';
import { useUserStore } from '@/store/useUserStore'
// 1️⃣ 统一配置 baseURL
// 这样以后切换开发环境、测试环境、生产环境,只需要改这一行
axios.defaults.baseURL = 'http://localhost:3000/api';
// 2️⃣ 请求拦截器 (Request Interceptor)
// 就像安检门,所有发出去的请求都要经过这里
axios.interceptors.request.use(config => {
// 💡 关键点:从 Zustand Store 中获取 Token
// 这里使用了 useUserStore.getState(),这是一个非 React Hook 的方法
// 允许我们在普通 JS 函数中获取最新的 State
const token = useUserStore.getState().token;
if(token){
// 🎫 夹带私货:如果有 Token,就把它塞到请求头的 Authorization 里
// 格式通常遵循 Bearer Token 标准
config.headers.Authorization = `Bearer ${token}`;
}
return config; // 放行
})
// 3️⃣ 响应拦截器 (Response Interceptor)
// 就像快递驿站,所有回来的数据都要先在这里拆包
axios.interceptors.response.use(res => {
// 统一处理 HTTP 状态码
if(res.status != 200){
console.log('请求失败');
return Promise.reject(res); // 抛出错误
}
// 🎁 自动解包:只返回 data 部分
// 这样在组件里拿数据时,就不用写 res.data 了,直接 res 即可
return res.data;
})
export default axios;
🛡️ 拦截器的两大好处:
- 自动化鉴权:我们在请求拦截器里统一注入 Token。这样,
api/user.ts里的doLogin函数根本不需要关心 Token 的事,它只管发请求,鉴权的工作全交给拦截器默默完成了。 - 数据净化:在响应拦截器里,我们直接返回了
res.data。这意味着组件拿到的直接就是后端返回的 JSON 数据(比如{ code: 200, user: ... }),少了一层data的嵌套,代码更清爽。
🧠 三、 状态管理:Zustand 的持久化存储
API 写好了,拿到的 Token 和 User 信息存哪儿呢?刷新页面要是丢了怎么办?
别怕,我们的老朋友 Zustand 带着 persist 中间件来了。
打开 src/store/useUserStore.ts:
// src/store/useUserStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware'; // 💾 持久化中间件
import { doLogin } from '@/api/user';
import type { User, Credentail } from '@/types';
interface UserState {
token: string | null;
isLogin: boolean;
user: User | null;
login: (credentials: Credentail) => Promise<void>;// promise代表函数是异步的
}
// 使用柯里化写法 create()(...)
export const useUserStore = create<UserState>()(
persist(
(set) => ({
token: null,
user: null,
isLogin: false,
// 🚀 登录动作
login: async({ name, password }) => {
// 1. 调用 API
const res = await doLogin({ name, password });
// 2. 更新状态
// 注意:这里我们拿到的 res 已经是经过拦截器处理过的 data 了
set({
user: res.user,
token: res.token,
isLogin: true
})
}
}),
{
name: 'user-store', // 📦 LocalStorage 中的 Key 名
// ✂️ 按需持久化 (Partialize)
// 内存资源也是资源,能省则省!
// 我们只存 token, isLogin, user,不需要存 action 函数
partialize: (state) => ({
token: state.token,
isLogin: state.isLogin,
user: state.user,
})
}
)
)
💡 知识点 Highlight:
persist:自动把 State 同步到localStorage。刷新页面时,Zustand 会自动从localStorage读取数据并恢复状态,实现“登录状态保持”。partialize:这是一个优化细节。默认情况下persist会存所有东西。但我们只需要存数据,不需要存方法。通过partialize我们可以“挑食”,只选择需要持久化的字段。
🎨 四、 登录页面:UI 与逻辑的完美融合
万事俱备,只欠东风。最后一步,画页面! 我们使用 Shadcn/ui 的 Input 和 Button 组件,配合 Tailwind CSS 快速构建一个现代化的登录卡片。
打开 src/pages/Login.tsx:
// src/pages/Login.tsx
import { useState, useEffect } from 'react';
import { useUserStore } from '@/store/useUserStore';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Loader2 } from 'lucide-react'; // 漂亮的 Loading 图标
import { useNavigate } from 'react-router-dom';
import type { Credentail } from '@/types';
export default function Login() {
const navigate = useNavigate();
const { login } = useUserStore(); // 取出 login 方法
// ⏳ 局部 Loading 状态,用于控制按钮转圈圈
const [loading, setLoading] = useState<boolean>(false);
// 📝 受控组件状态
const [formData, setFormData] = useState<Credentail>({
name: '',
password: ''
});
// ⌨️ 处理输入框变化
// TS 泛型:ChangeEvent<HTMLInputElement> 精确描述了事件类型
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { id, value } = e.target;
setFormData((prev) => ({
...prev,
[id]: value // ES6 计算属性名
}));
}
// 🚀 处理登录提交
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); // 🛑 阻止表单默认提交行为(防止刷新页面)
const name = formData.name.trim();
const password = formData.password.trim();
// 简单的前端校验
if(!name || !password) return;
setLoading(true); // 开始转圈圈
try {
// 调用 Store 中的 login action
await login({ name, password });
// ✈️ 跳转回首页
// replace: true 是个细节!意味着用新页面替换当前的历史记录
// 用户登录后按“返回键”不应该再回到登录页,这个体验必须满分!
navigate('/', { replace: true });
} catch(err) {
console.log(err, '登录失败');
// 这里以后可以加个 Toast 提示用户
} finally {
// 无论成功失败,最后都要停止转圈圈
setLoading(false);
}
}
return (
<div className="min-h-screen flex flex-col items-center justify-center p-6 bg-white">
<div className="w-full max-w-sm space-y-6">
<div className='space-y-2 text-center'>
<h1 className='text-3xl font-bold'>登录</h1>
</div>
<form onSubmit={handleLogin} className='space-y-4'>
<div className='space-y-2'>
{/* ♿ 无障碍访问:Label 的 htmlFor 与 Input 的 id 对应 */}
{/* 注意 React 中使用 htmlFor 而不是 for,因为 for 是 JS 关键字 */}
<Label htmlFor='name'>用户名</Label>
<Input
id='name'
placeholder='请输入用户名'
value={formData.name}
onChange={handleChange}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='password'>密码</Label>
<Input
id='password'
placeholder='请输入密码'
type='password'
value={formData.password}
onChange={handleChange}
/>
</div>
<Button className="w-full" disabled={loading}>
{loading ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin'/>
登录中...
</>
) : ('立即登录')}
</Button>
</form>
<Button variant="ghost" className='w-full' onClick={() => navigate('/')}>
暂不登录,回首页
</Button>
</div>
</div>
)
}
✨ 细节决定成败:
- 无障碍访问 (A11y):我们使用了
<Label htmlFor="name">和<Input id="name">。点击 Label 文字时,焦点会自动跳到对应的 Input 框。这不仅对屏幕阅读器友好,对普通用户体验也是加分项。 - TS 类型严谨:
e: React.ChangeEvent<HTMLInputElement>。TypeScript 的魅力在于它能告诉你e.target下面到底有什么属性。不同于Checkbox或Select,Input元素的类型定义让我们的代码更健壮。 - 用户体验 (UX):
- Loading 态:网络请求期间按钮变灰并转圈,防止用户疯狂点击。
- History Replace:登录成功后使用
replace: true,防止用户误触返回键又回到登录页,陷入“登录死循环”的尴尬。 - Finally 块:无论 try 中是否报错,
finally保证了setLoading(false)一定会执行,避免程序卡死在 Loading 状态。
🎬 五、 总结:数据流动的全景图
让我们最后梳理一下点击“立即登录”后,数据经历了怎样的奇幻漂流:
- UI 层:用户点击按钮,
Login组件捕获事件,开启 Loading,调用loginAction。 - Store 层:Zustand 的
login方法接收账密,调用api.doLogin。 - API 层:Axios 发起 POST 请求。
- 拦截器层 (Request):
interceptors.request检查 Store 里有没有旧 Token(虽然登录接口通常不需要,但这是一个通用机制),然后放行。 - Mock 层:请求到达 Mock.js,Mock 拦截请求,等待 2000ms(模拟延迟),校验账号密码。验证通过后,用 JWT
secret签发一个 Token,连同 User 信息打包返回。 - 拦截器层 (Response):
interceptors.response收到 Mock 返回的数据,剥离外层对象,只把核心data丢回去。 - Store 层:Zustand 拿到数据,更新
token、user和isLogin状态,并自动持久化到localStorage。 - UI 层:
Login组件 await 结束,Loading 消失,路由跳转到首页。
🎉 大功告成!
现在,你已经亲手打造了一个包含 Mock 数据、JWT 鉴权、Axios 封装、全局状态管理 的完整登录模块。这套架构,哪怕是放到真实的商业项目中,也是完全能打的!
明天的实战,我们将利用今天拿到的 Token 和 Mock 数据,实现真正的文章列表,并且添加无限滚动加载(Infinite Scroll)功能。
如果你觉得这篇文章对你有帮助,别忘了点赞、收藏、关注三连哦!我们明天见!🚀