Vue3项目实战:从0到1开发企业级中后台系统(3):架构核心!手把手封装Axios、Pinia、Router
专栏介绍
- “本专栏将手把手带你实战一个标准的企业级中后台管理系统,你学到的不是零散的知识点,而是一套完整的、可复用的开发流程和解决方案。”
- “从项目搭建、架构设计到核心业务模块开发,我会分享每一步的最佳实践和十年总结的避坑心得。更重要的是,在专栏最后,我会教你如何从‘程序员’思维转变为‘工程师’思维,抽象封装属于自己的业务组件,这才是你职场进阶的关键。”
- “无论你是想深入学习Vue3生态,还是渴望在工作中独立承担项目,这个专栏都将为你提供巨大的帮助。”
技术选型
要打造一个企业级项目,选择合适的“装备”至关重要。我们的选择是:
- 构建工具: Vite (进行项目构建)
- 语言:TypeScript (开发语言)
- 样式: less (编写样式 )
- 状态管理: pinia(vuex的进化版,更简单,更友好)
- 路由:vue-router(单页应用的导航系统,不可或缺)
- HTTP客户端:axios (数据请求)
- 代码规范: CommitLint + ESLint + StyleLint + Prettier + LintStage 进行团队项目规范
- UI库: ElementPlus 组件库(由饿了么技术团队开发)
封装axios
/src/utils/status.ts
我们先来创建一个status.ts文件,文件路径 /src/utils/status.ts ,我们一会儿会用到它,在这个文件中我们封装一个getMessageInfo 方法,用来集中处理错误信息。我们会频繁使用这些信息,将它封装起来统一调度。内容如下:
export const getMessageInfo = (status: number | string): string => {let msg = "";switch (status) {case 400:msg = "请求错误(400)"; // 请求语法错误,服务器无法解析(如 JSON 格式错误)break;case 403:msg = "拒绝访问(403)"; // 用户已认证,但无权访问该资源(才是“没权限”)break;case 401:msg = "未授权(401)"; // 用户未登录或认证失败(注意:不是“没权限”)break;case 500:msg = "服务器错误(500)"; // 服务器内部错误,未处理的异常break;case 503:msg = "服务不可用(503)"; // 服务器暂时过载或维护,无法处理请求break;default:msg = `连接出错(${status})!`; // 其他未知错误}return msg;
};
状态码分类
这里不想看的,可以调过,直接看request,不懂的再回来看。
- 1xx - 信息性状态码:表示请求已被接收,需要继续处理。
- 2xx - 成功状态码:表示客户端请求成功。
- 3xx - 重定向状态码:表示客户端需要采取进一步操作才能完成请求,通常是跳转。
- 4xx - 客户端错误状态码:请求包含语法错误或无法完成,错误在客户端,比如请求资源不存在或格式不对。
- 5xx - 服务端错误状态码:服务器在处理请求时出错。
常见状态码
- 200 - 成功:请求成功,响应体包含请求结果。
- 201 - 新建成功:用于 POST/PUT 请求,表示资源创建成功。
- 204 - 无内容:请求成功,但响应体为空(如 DELETE 成功)。
- 301 - 永久重定向:资源已永久迁移,浏览器会缓存该跳转。
- 302 - 临时重定向:资源临时迁移,浏览器自动跳转,不缓存。
- 304 - 资源未被修改:用于缓存协商(配合 If-None-Match 或 If-Modified-Since),节省带宽。
- 400 - 请求错误:请求语法错误,服务器无法解析(如 JSON 格式错误)。
- 401 - 未认证:用户未登录或认证失败(注意:不是“没权限”)。
- 403 - 禁止访问:用户已认证,但无权访问该资源(才是“没权限”)。
- 404 - 资源未找到:服务器找不到请求的资源(接口错误,检查接口路径是否正确)。
- 405 - 方法不允许:请求方法(如 POST)不被允许用于该资源。
- 429 - 请求过多:限流场景,客户端发送请求过于频繁。
- 500 - 服务器错误:服务器内部错误,未处理的异常。
- 502 - 网关错误:服务器作为网关或代理时,从上游服务器收到无效响应。
- 503 - 服务不可用:服务器暂时过载或维护,无法处理请求。
- 504 - 网关超时:服务器作为网关或代理时,未能及时从上游服务器获得响应。
/src/utils/request.ts
我们创建一个request.ts文件,文件路径 /src/utils/request.ts ,在这个文件中我们对响应码进行判断并给出提示。内容如下:
// 导入依赖
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { getMessageInfo } from './status';
import { ElMessage } from 'element-plus';
// 定义响应数据接口, 假设后端返回的数据格式是统一的
interface BaseResponse<T = any> {code: number | string;message: string;data: T;
}
// 创建 axios 实例 service
const service = axios.create({// 从 Vite 环境变量中读取 API 基地址(如 `/api` 或 `https://example.com/api`)baseURL: import.meta.env.VITE_APP_API,// 请求超时设置,防止页面卡死timeout: 15000
});
// 请求拦截器
service.interceptors.request.use((config: InternalAxiosRequestConfig) => {// config.headers.Authorization = `Bearer ${getToken()}`; // 后期这里可以统一处理tokenreturn config;},(error: AxiosError) => {return Promise.reject(error);}
);
// 响应拦截器
service.interceptors.response.use((response: AxiosResponse) => {if (response.status >= 200 && response.status < 300) {return response;}ElMessage({message: getMessageInfo(response.status),type: 'error'});return response;},// 请求失败(error: any) => {const { response } = error;// 如果 response 存在(说明请求发出去了,但服务器返回错误)if (response) {// 请求已发出,但是不在2xx的范围ElMessage({message: getMessageInfo(response.status),type: 'error'});// 将错误继续抛出 这里返回后,后面的代码将不会执行return Promise.reject(response.data);}// 如果 response 不存在(网络异常、DNS 失败、断网)ElMessage({message: '网络异常,请稍后再试!',type: 'error'});}
);// 自定义请求函数 requestInstance(二次处理响应)
const requestInstance = <T = any>(config: AxiosRequestConfig): Promise<T> => {const conf = config;return new Promise((resolve, reject) => {service.request<any, AxiosResponse<BaseResponse>>(conf).then((res: AxiosResponse<BaseResponse>) => {const data = res.data;// 用于处理业务层面的成功/失败if (data.code !== 0) {ElMessage({message: data.message,type: 'error'});// 自定义错误对象,便于捕获更多信息reject(data);} else {ElMessage({message: data.message,type: 'success'});resolve(data.data as T);} });});
};// 封装 get/post/put/del 方法
export function get<T = any, U = any>(config: AxiosRequestConfig, url: string, params?: U): Promise<T> {return requestInstance({ ...config, url, method: 'GET', params: params });
}export function post<T = any, U = any>(config: AxiosRequestConfig,url: string,data: U
): Promise<T> {return requestInstance({ ...config, url, method: 'POST', data: data });
}export function put<T = any, U = any>(config: AxiosRequestConfig,url: string,params?: U
): Promise<T> {return requestInstance({ ...config, url, method: 'PUT', params: params });
}export function del<T = any, U = any>(config: AxiosRequestConfig,url: string,data: U
): Promise<T> {return requestInstance({ ...config, url, method: 'DELETE', data: data });
}
// 导出默认实例和具名方法
export default service;
axios:核心 HTTP 客户端。AxiosError:错误类型,用于错误处理。AxiosRequestConfig/InternalAxiosRequestConfig:请求配置类型(后者包含内部字段)。AxiosResponse:响应类型。getMessageInfo:自定义函数,根据状态码返回友好提示信息(如 404 → “资源未找到”)。ElMessage:Element Plus 的消息提示组件,用于弹出提示。
/src/api
在/src/api这个文件夹下对接口进行分类管理,将不同模块的接口放在一起。在/src/api下建立不同的文件夹代表不同类模块的API,在index.ts中编写接口配置,在types.ts中编写接口所需的请求参数类型以及响应类型。
以user模块为例,我们将建立下面两个文件。
/src/api/user/types.ts
// 登录所需的参数
export type LoginRequest = {username: string;password: string;
};// 刷新登录信息需要的参数
export type reLoginRequest = {accessToken: string;
};// 登录后返回的响应信息
export type LoginResponse = {username: string;roles: Array<string>;accessToken: string;
};
/src/api/user/index.ts
import { post } from '@/utils/request';
// 导入类型
import { LoginRequest, LoginResponse, reLoginRequest } from '@/api/user/types';// post 请求直接传入一个 data 即可 url 我们直接在此处封装好
// 需要更改时也只需在此处更改
export const userLogin = async (data?: LoginRequest) => {return post<LoginResponse>({}, '/login', data);
};export const refreshUserInfo = async (data?: reLoginRequest) => {return post<LoginResponse>({}, '/getUserInfo', data);
};
使用的时候我们可以直接在组件中引用,也可将其封装在store的action中,将相关的store与接口关联起来。
在组件中调用
import { userLogin } from "@/api/user";
const submit = () => {const params = {userName: 'admin',password: '123456'}userLogin(params).then(res => {console.log('登录成功')})
}
封装Pinia
/src/store/user.ts
当数据需要在整个系统中使用的时候,我们可以将它放到store中存储,这样不用每次都调接口,用的时候直接从store中取就行了。
import { defineStore } from "pinia";
import { userLogin, userLoginPhone, getUserInfo, refreshToken, userRegistry } from "@/api/user";
interface IUserInfo {userId: numbernickName: stringuserName: stringphone: stringtype: number[key:string]: any
}
interface UserState {accessToken: string | nullrefreshToken: string | nulluser: IUserInfo | null
}export const useUserStore = defineStore('userInfo', {state: (): UserState => {const storedUser = localStorage.getItem('user')return {accessToken: localStorage.getItem('accessToken') || null,refreshToken: localStorage.getItem('refreshToken') || null,user: storedUser ? JSON.parse(storedUser) : null // ← 解析成对象}},getters: {},actions: {storeUserLogin (data) {return userLogin(data).then((res: any) => {this.accessToken = res.accessTokenthis.refreshToken = res.refreshTokenlocalStorage.setItem('accessToken', res.accessToken)localStorage.setItem('refreshToken', res.refreshToken)this.storeGetUserInfo()// return res})},storeUserLoginPhone (data) {return userLoginPhone(data).then((res: any) => {this.accessToken = res.accessTokenthis.refreshToken = res.refreshTokenlocalStorage.setItem('accessToken', res.accessToken)localStorage.setItem('refreshToken', res.refreshToken)this.storeGetUserInfo()// return res})},storeUserRegistry (data) {return userRegistry(data).then((res: any) => {this.accessToken = res.accessTokenthis.refreshToken = res.refreshToken})},storeGetUserInfo () {return getUserInfo().then((res: any) => {this.user = reslocalStorage.setItem('user', JSON.stringify(res))})},storerefreshToken () {return refreshToken().then((res: any) => {this.accessToken = res.access_tokenthis.refreshToken = res.refresh_token})},storeLogout () {this.accessToken = nullthis.refreshToken = nullthis.user = nulllocalStorage.removeItem('accessToken')localStorage.removeItem('refreshToken')localStorage.removeItem('user')}},persist: true // 开启持久化
});
/src/store/index.ts
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
const pinia = createPinia();
// 使用pinia数据持久化插件
pinia.use(piniaPluginPersistedstate);
export default pinia;
在组件中使用store数据
import { useUserStore } from '@/store/user'
const userStore = useUserStore()
const { user } = storeToRefs(userStore);// <span v-if="!user"><a @click="gotoLogin">登录</a>/<a @click="gotoRegistry">注册</a></span> 在模板中直接使用user
// 在js中需要用user.value
watch(() => user.value,(newUser) => {console.log('user changed:', newUser)if (newUser) {loadChartUrl()}},{ immediate: true }
)
封装路由router
模块化路由管理 - 让导航更有条理
与其把所有路由堆在一个文件里(就像把整个城市的道路都画在一张纸上),不如按模块分开:
创建登录页面的路由配置 (/src/router/modules/login.ts):
import { RouteRecordRaw } from 'vue-router';export default {path: '/login', // 路径:就像街道地址name: 'LoginPage', // 路由名称:就像门牌号component: () => import('@/views/login/index.vue'), // 对应的页面组件meta: {role: ['common', 'admin'], // 元信息:比如哪些角色可以访问},children: [], // 子路由:就像这条街上的小巷子
} as RouteRecordRaw;
在主路由文件中统一导入 (/src/router/index.ts):
import { createRouter, createWebHashHistory } from 'vue-router';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
// 自动导入所有模块路由(就像把各个区的地图合并成全市地图)
const modules = import.meta.glob('./modules/*.ts', { eager: true });
const routeModuleList: RouteRecordRaw[] = [];Object.keys(modules).forEach((key) => {const mod = (modules[key] as any).default || {};routeModuleList.push(mod);
});const router = createRouter({history: createWebHashHistory(),routes: routeModuleList
});const whiteList = ['/login', '/registry', '/', '/home', '/news', '/history', '/relate'];router.beforeEach(async (_to, _from, next) => {NProgress.start();// 除白名单外 其余接口需要tokenconst token = localStorage.getItem('accessToken')if (token && token !== 'undefined') {// 如果已经登陆且当前是login页面 跳到登入页if (_to.path === '/login') {next({path: '/'})} else {next()}} else {// 白名单内if (whiteList.some(route => _to.path === route || _to.path.startsWith('/home/detail/'))) {next()} else {try {await ElMessageBox.confirm('您还没有登录,请先登录!','提示',{confirmButtonText: '去登录',cancelButtonText: '取消',type: 'warning'});// 用户点击了“确认”按钮next({path: '/login',query: {redirect: _to.fullPath}});} catch (error) {// 用户点击了“取消”按钮ElMessage({message: '已取消',type: 'info'});if (_from.path !== '/home') {next({path: '/'})} else {next(false)}}}}
});router.afterEach((_to) => {NProgress.done();
});export default router;
总结
本篇我们完成了
- Axios封装 - 项目的"外交系统",负责与服务器通信
- 路由封装 - 项目的"导航系统",管理页面跳转和权限
- Pinia封装 - 项目的"记忆系统",管理全局数据和状态
这三大支柱就像房子的承重墙,奠定了整个项目的稳定架构。现在我们的项目已经具备了企业级应用的基础架构能力!
记得动手实践一下哦,光看不练假把式!遇到问题欢迎在评论区留言或文章末尾右下角联系哦!备注"csdn"免费答疑~
