🔐 AI 全栈项目第七天:继续Notes实战——JWT 登录鉴权与 Axios 拦截器的“双重奏”

86 阅读11分钟

哈喽,掘金的各位全栈练习生们!👋 欢迎回到 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 讲解
        }
    },
    // ... 其他接口
]

🧐 代码深度解析:

  1. timeout: 2000:这个参数非常实用!在本地开发时,接口响应往往是毫秒级的,让你产生“我的代码跑得真快”的错觉。加上 2秒 的延迟,能让你真实地感受到 Loading 状态的重要性,也能帮你测试防抖节流是否生效。
  2. 严谨的校验逻辑:虽然是 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;

🛡️ 拦截器的两大好处:

  1. 自动化鉴权:我们在请求拦截器里统一注入 Token。这样,api/user.ts 里的 doLogin 函数根本不需要关心 Token 的事,它只管发请求,鉴权的工作全交给拦截器默默完成了。
  2. 数据净化:在响应拦截器里,我们直接返回了 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>
    )
}

✨ 细节决定成败:

  1. 无障碍访问 (A11y):我们使用了 <Label htmlFor="name"><Input id="name">。点击 Label 文字时,焦点会自动跳到对应的 Input 框。这不仅对屏幕阅读器友好,对普通用户体验也是加分项。
  2. TS 类型严谨e: React.ChangeEvent<HTMLInputElement>。TypeScript 的魅力在于它能告诉你 e.target 下面到底有什么属性。不同于 CheckboxSelectInput 元素的类型定义让我们的代码更健壮。
  3. 用户体验 (UX)
    • Loading 态:网络请求期间按钮变灰并转圈,防止用户疯狂点击。
    • History Replace:登录成功后使用 replace: true,防止用户误触返回键又回到登录页,陷入“登录死循环”的尴尬。
    • Finally 块:无论 try 中是否报错,finally 保证了 setLoading(false) 一定会执行,避免程序卡死在 Loading 状态。

🎬 五、 总结:数据流动的全景图

让我们最后梳理一下点击“立即登录”后,数据经历了怎样的奇幻漂流:

  1. UI 层:用户点击按钮,Login 组件捕获事件,开启 Loading,调用 login Action。
  2. Store 层:Zustand 的 login 方法接收账密,调用 api.doLogin
  3. API 层:Axios 发起 POST 请求。
  4. 拦截器层 (Request)interceptors.request 检查 Store 里有没有旧 Token(虽然登录接口通常不需要,但这是一个通用机制),然后放行。
  5. Mock 层:请求到达 Mock.js,Mock 拦截请求,等待 2000ms(模拟延迟),校验账号密码。验证通过后,用 JWT secret 签发一个 Token,连同 User 信息打包返回。
  6. 拦截器层 (Response)interceptors.response 收到 Mock 返回的数据,剥离外层对象,只把核心 data 丢回去。
  7. Store 层:Zustand 拿到数据,更新 tokenuserisLogin 状态,并自动持久化到 localStorage
  8. UI 层Login 组件 await 结束,Loading 消失,路由跳转到首页。

🎉 大功告成!

现在,你已经亲手打造了一个包含 Mock 数据、JWT 鉴权、Axios 封装、全局状态管理 的完整登录模块。这套架构,哪怕是放到真实的商业项目中,也是完全能打的!

明天的实战,我们将利用今天拿到的 Token 和 Mock 数据,实现真正的文章列表,并且添加无限滚动加载(Infinite Scroll)功能。

如果你觉得这篇文章对你有帮助,别忘了点赞、收藏、关注三连哦!我们明天见!🚀