基于Vue3 + Vite + Element-plus 来构建一个常见的后台,做这个的原因还是在于理清技术细节,虽然这玩意到处都是,但对于前端经验不算多的我而言,还是有必要自己捣鼓一次的,此外本次使用JavaScript来开发,而不适用TypeScript,核心原因是,遇到一些问题时,请教周围的前端朋友发现他们都不太熟悉TypeScript,所以一些小问题因为其类型推断系统搞了很久,后面和解了,个人项目用JavaScript也没啥问题,下面我们开始吧。
本文会提供完整的代码,请放心食用。
使用yarn构建vue3项目:
yarn create vite DashboardFrameWork --template vue
yarn && yarn dev
然后安装相应的依赖:
yarn add element-plus --save
yarn add @element-plus/icons-vue --save
yarn add axios --save
yarn add sass --save
yarn add vue-router@next --save
yarn add vuex@next --save
我将依赖都安装到dependencies(--save)中。
就我个人理解,开发时使用的工具向的东西,比如vite、webpack、mockjs等,不需要在上线时依赖,那么就将其安装到devDependencies(--dev)中,反之则安装到dependencies中。
上述安装的依赖中,element-plus是UI框架,element-plus/icons-vue是UI框架的图标相关的支撑库,axios是HTTP请求库、sass是用于编写样式的CSS超集语言、vue-router用于实现单页面路由,vuex用于实现状态存储,很常见的Vue3全家桶。
为了方便开发和代码模块化,多数项目在开始前会进行一些基本的封装并构建出项目的骨架。
首先,我们对axios库进行封装,如果单纯的使用axios库,其用法已经足够简单了,但结合后台业务情况,还是有必要对它进行二次封装,实现请求拦截、响应拦截。
创建src/utils/request.js,用于实现封装JavaScript的逻辑,先创建axios对象:
// 创建axios实例对象,添加全局配置
const service = axios.create({
baseURL: config.baseApi,
timeout: 8000,
});
接着实现请求拦截,实现每次请求前身份的校验:
// 请求拦截
service.interceptors.request.use((req) => {
const headers = req.headers;
const { token } = storage.getItem("userInfo") || {};
if (token) {
if (!headers.Authorization) headers.Authorization = "Bearer " + token;
}
return req;
});
上述代码中,从storage中获取userInfo的数据,我们可以通过chrome的开发者工具Application查看到storage中存储的数据:
我们在login时,将用户基础信息写入其中,每次请求前都会通过请求拦截做一次登录校验。
以类似的方式,我们可以实现响应拦截:
// 响应拦截
service.interceptors.response.use((res) => {
const { code, data, msg } = res.data;
if (code === 200) {
return data;
} else if (code === 500001) {
ElMessage.error(TOKEN_INVALID);
// 让错误信息展示一下,再跳转
setTimeout(() => {
router.push("/login");
}, 1500);
// 抛出异常
return Promise.reject(TOKEN_INVALID);
} else {
ElMessage.error(msg || NETWORK_ERROR);
return Promise.reject(msg || NETWORK_ERROR);
}
});
如果响应的code是200,则将数据正常返回,如果code不为200,则通过ELMessage给用户展示相关的错误信息,并通过路由方法跳转到login页面,一个小技巧是,失败时,不要立刻跳转到login页面,因为我们希望用户看到相关的报错信息,最后返回Promise.reject对象。
拦截相关的方法实现好后,再封装一下请求方法就好了:
function request(options) {
options.method = options.method || "get";
if (options.method.toLowerCase() === "get") {
options.params = options.data;
}
let isMock = config.mock;
// 兼容局部Mock的用法
if (typeof options.mock != "undefined") {
isMock = options.mock;
}
service.defaults.baseURL = isMock ? config.mockApi : config.baseApi;
return service(options);
}
上述代码中封装了request方法,这里的核心在于,请求的URL是Mock地址还是真实的后台地址。
通过Mock,前端可以在不需要后台提供出完整的API的情况下进行开发,很多前端开源项目会使用Mockjs来构建一个单独的serve来为前端项目提供数据,而这里我直接使用了在线的Mock服务(后文介绍)。
至此,我们可以通过如下方式来实现http请求:
request({
url: "/users/login",
method: "post",
data: {
username: "ayuliao",
pwd: "123"
}
}).then((res) => {
console.log(res);
});
request.js github位置:https://github.com/ayuLiao/DashboardFrameWork/blob/master/src/utils/request.js
多数项目中,都会使用配置文件来管理相关的配置,我们也不例外。
创建src/config/config/index.js,代码如下:
/**
* 环境配置封装
*/
// import.meta.env.MODE 当前项目环境
const env = import.meta.env.MODE
const EnvConfig = {
development:{
baseApi:'/api',
mockApi:'https://www.fastmock.site/mock/xxx/api'
},
production:{
baseApi:'//xxx.com/api',
mockApi:'https://www.fastmock.site/mock/xxx/api'
}
}
export default {
env,
// 是否开启Mock
mock:true,
namespace:'manager',
...EnvConfig[env]
}
通过env.MODE来判断当前的环境,在Vue3中,默认情况下,开发服务器 (dev 命令) 运行在 development (开发) 模式,而 build 命令则运行在 production (生产) 模式(更多可看:https://cn.vitejs.dev/guide/env-and-mode.html#intellisense)。
配置中,提供mockApi来请求在线Mock,这里使用fastmock这个在线mock服务,当然我们可以通过mock.js来构建本地的Mock服务,这里图方便,就使用了fastmock。
返回的值,给一个JSON则可,通常直接使用后端给的API对接文档中的内容则可:
我们使用vuex来进行状态管理,但只要我们一刷新浏览器,vuex中的数据便会丢失,为了避免这种情况,我们可以配合着浏览器的localStorage来存储数据,实现数据的持久化。
创建 src/utils/storage.js,对localStorage的增删改查进行封装,思考一个问题:通过window.localStorage对象已经可以实现增删改查,为啥还要封装一层?
主要是因为localStorage不能直接存储Object对象,只能存储字符串,所以常规的做法就是将通过JSON字符串的形式来存储数据,存入将对象转成JSON字符串,取出则从JSON字符串解码成对象,代码如下:
/**
* Storage二次封装
*/
import config from '@/config'
export default {
setItem(key,val){
let storage = this.getStroage();
storage[key] = val;
window.localStorage.setItem(config.namespace,JSON.stringify(storage));
},
getItem(key){
return this.getStroage()[key]
},
getStroage(){
return JSON.parse(window.localStorage.getItem(config.namespace) || "{}");
},
clearItem(key){
let storage = this.getStroage()
delete storage[key]
window.localStorage.setItem(config.namespace,JSON.stringify(storage));
},
clearAll(){
window.localStorage.clear()
}
}
创建src/store/目录,在其中创建index.js和mutations.js,其中index.js中写vuex中state相关的逻辑,而mutations.js自然实现mutations相关逻辑,先看index.js,代码如下:
/**
* Vuex状态管理
*/
import { createStore } from 'vuex'
import mutations from './mutations'
import storage from './../utils/storage'
const state = {
// Vuex配合storage使用,Vuex强刷的话,数据会丢失,所以配合storage使用
userInfo: "" || storage.getItem("userInfo") // 获取用户信息
}
export default createStore({
state,
mutations
})
mutation是vuex中的概念,是修改Vuex store中状态的唯一方法,简单理解就是定义方法,通过这些方法才能修改存储在vuex中的事件,项目通常会将state和mutations分开来实现:
/**
* Mutations业务层数据提交
*
*/
import storage from './../utils/storage'
export default {
saveUserInfo(state,userInfo){
state.userInfo = userInfo;
storage.setItem('userInfo',userInfo)
}
}
使用vue-router来实现路由,创建src/router/index.js。创建路由的逻辑是很机械化的,代码如下:
import { createRouter, createWebHashHistory } from "vue-router";
import Home from "@/components/Home.vue";
const routes = [
{
name: "home",
path: "/",
meta: {
title: "首页",
},
component: Home,
redirect: "/welcome",
children: [
{
name: "welcome",
path: "/welcome",
meta: {
title: "Welcome use Dashboard Framework",
},
component: () => import("@/views/Welcome.vue"),
},
],
},
{
name: "login",
path: "/login",
meta: {
title: "登录",
},
component: () => import("@/views/Login.vue"),
}
];
const router = createRouter({
history: createWebHashHistory(),
routes,
});
export default router;
路由需要配合router-view来使用,在App.vue中,直接使用router-view则可,router-view会渲染出一级路由的内容,对上述路由而言,便是home和login的内容。
home的子路由有welcome,要渲染welcome,就需要在home中使用router-view,router-view的嵌套模式与路由中一致。
创建components/Home.vue,其template如下:
DashBoard
面包屑
用户信息
整个骨架我们使用div一块块搭建处理,接着来写CSS,我们使用scss来实现:
在使用scss编写css时,为了避免命名冲突,通常会通过一个div将组件或模块包裹起来,这里便是basic-layout,不同组件根div的class不同,再利用scss的语法来写css,就不会出现css命名冲突的问题了。
上述css中,使用了fixed定位,借此记录一下编写css时,我们常用的relative定位与fixed定位。
relative定位会相对于默认位置(static定位)进行偏移:
在浏览器中,每个元素默认通过static的形式来定(position的默认值),static定位下,元素会按HTML源码的顺序来排列,每个块级元素占据自己的位置,元素与元素之间不会重叠。
当我们使用relative时,它会相对于static进行偏移,static即它原本的正常位置,改成relative后,配合top、bottom、left、right这四个属性来实现偏移:
接着聊fixed定位,我们后台的首页布局中使用了fixed定位来固定侧边栏和顶部栏,fixed会基于浏览器窗口定义,其效果就是元素不会随着页面滚动而变化,如同固定在页面上一样。
天下苦布局久已,自从display出来后,一切便简单起来了,这里记录几种display中最常用的布局。
先说居中布局:
.box {
display: flex;
// 水平居中
justify-content: center;
// 垂直居中
align-items: center;
}
上述CSS会让box中的元素水平、垂直都居中,如果以骰子为例,效果为:
然后再说一下两端对其:
.box {
display: flex;
justify-content: space-between;
}
效果为:
我们可以借助在线布局演示网站(https://xluos.github.io/demo/flexbox/)来体验flex布局的效果,从而理解flex其他样式:
首页中,有如下一段HTML,用于渲染子路由的页面,根据router/index.js配置的路由,这里会渲染Welcome.vue
在常见的后台中,侧边栏是按登录者的权限来展示的,不同的用户登录时,侧边中的内容有所不同,具体而言,权限控制由后端权限管理相关逻辑实现,而前端只需要通过后端返回的内容,动态渲染出侧边栏则可。
我们将侧边栏相关的逻辑放在class为nav-side的div中:
DashBoard
首先,我们使用element-plus中的el-menu来包裹出侧边栏,el-menu元素可使用的属性可自行读一下文档,而侧边栏真正的实现逻辑是TreeMenu子组件,其传入参数为menuList,TreeMenu.vue代码如下:
0 &&
menu.children[0].menuType == 1
"
:index="menu.path"
>
{{ menu.menuName }}
{{ menu.menuName }}
这个项目中,我使用了最新的setup语法,TreeMenu子组件在接收父组件传参时,需要通过defineProps方法来实现参数的接收。
TreeMenu子组件核心逻辑在template中,因为侧边栏有嵌套的情况,比如下图这种情况:
对于嵌套情况,可以通过递归的方式来,通过v-if判断是否当前元素是否嵌套有子结构,有的话就再通过TreeMenu来构建出新的新的节点。
TreeMenu代码中有一个小技巧,就是多个template节点的嵌套使用,template节点本身不会被渲染出相应的DOM,利用template节点来放置v-for、v-if等操作是很合适的做法。
另外一个技巧是,element-plus展示图标的方式有所改变,我们需要通过动态组件的方式来放置合适的图标:
当然,要在项目中随意使用element-plus图标,需要在入口文件main.js中全局挂载一下:
app.use(router).use(store).use(ElementPlus)
// 全局挂载icon,方便icon在项目各处使用
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
此外,在构建目录时,使用了Vue中的插槽:
{{ menu.menuName }}
我们希望目录中父级目录可以显示图标和名字,而子级目录,不需要显示图标了,但Element-plus提供的el-menu并不直接支持,此时我们就需要通过Vue插槽动态的将template中的代码替换到el-menu中,el-menu组件提供了title插槽。
常见的后台其顶部栏会展示面包屑和用户相关信息:
0 ? true : false"
class="notice"
type="danger"
>
{{ userInfo.userName }}
邮箱:{{ userInfo.userEmail }}
退出
整体结构通过div构建,先看面包屑,它是一个独立的组件,通过BreadCrumb子组件实现。
这里面包屑的主要效果是暂时当前用户访问页面其访问路径,vue-router中的属性可以让我们轻松实现效果效果:
{{item.meta.title}}
{{item.meta.title}}
这里通过computed获得路由路径,computed会构建一层缓存,当对象发生改变时,缓存会更新。
通过router.currentRoute.value.matched可以获得当前路由以及访问当前路由的完整路由路由,这是vue-router提供的功能。
要找到这种功能,最好的方式是使用debug大法,参考element-plus-admin源码剖析一文,debug起来,效果如下:
对router对象,一层层看里面的属性,便可以找到需要的内容了,随后便通过el-breadcrumb标签展示出来则可。
面包屑完成了,将注意力移动到顶部栏右侧的下拉按钮:
0 ? true : false"
class="notice"
type="danger"
>
{{ userInfo.userName }}
邮箱:{{ userInfo.userEmail }}
退出
核心的下拉后展示相关内容的逻辑,主要通过el-dropdown的dropdown插槽实现。
登录是个常见的功能,通过Element-plus提供的表单组件,来构建登录页的基础骨架:
火星
登录
阅读el-form文档可知,表单数据会通过model绑定到user对象中,表单数据的前端验证会通过rules绑定到rules对象中,而el-form标签本身,我们通过ref将其绑定了userForm变量中,方便我们直接通过userForm变量来调用校验方法,相关JS如下:
import { reactive, ref, getCurrentInstance } from 'vue';
import api from '../api';
import router from '../router';
import store from '../store';
// 表单提交数据
const user = reactive({
userName: "",
userPwd: ""
})
// 表单对象
const userForm = ref();
// 校验规则
const rules = {
userName: [
{
required: true,
message: "请输入用户名",
trigger: "blur",
},
],
userPwd: [
{
required: true,
message: "请输入密码",
trigger: "blur",
},
],
}
// 登录方法
function login() {
// 通过userForm表单对象调用validate方法,实现前端校验
userForm.value.validate((valid) => {
if (valid) {
api.login(user).then((res) => {
store.commit("saveUserInfo", res);
router.push("/welcome");
});
} else {
return false;
}
});
}
el-form标签对象与userForm变量关联,当用户点击登录时,会调用login方法,login方法首先会通过userForm变量调用其中的validate方法(el-form提供的校验方法),基于校验规则(rules变量)对前端内容进行校验,校验通过后,再请求后端登录api,如果登录成功,则将数据记录下来并访问welcome页面。
这里,还有个细节,使用el-form-item时,要让element-plus帮我们验证,需要通过prop关联一下user对象中的属性:userName和userPwd。
虽然是一个很常见的后台,但对于我这种前端比较薄弱的后端同学,还是踩了一些坑的。
项目代码:https://github.com/ayuliao/DashboardFrameWork