基于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的内容。
- <!-- src/App.vue -->
- <template>
- <router-view></router-view>
- </template>
-
- <script setup>
-
- </script>
-
- <style lang="scss">
- @import "./assets/style/reset.css";
- @import "./assets/style/index.scss";
- </style>
home的子路由有welcome,要渲染welcome,就需要在home中使用router-view,router-view的嵌套模式与路由中一致。
创建components/Home.vue,其template如下:
- <template>
- <div class="basic-layout">
- <div class="nav-side">
- <div class="logo">
- <img src="./../assets/logo.png" alt="" class="src" />
- <span>DashBoard</span>
- </div>
- </div>
- <div class="content-right">
- <div class="nav-top">
- <div class="nav-left">
-
- <div class="bread">面包屑</div>
- </div>
- <div class="user-info">
- 用户信息
- </div>
- </div>
- <div class="wrapper">
- <router-view></router-view>
- </div>
- </div>
- </div>
- </template>
整个骨架我们使用div一块块搭建处理,接着来写CSS,我们使用scss来实现:
- <style lang="scss">
- .basic-layout {
-
- // 侧边栏
- .nav-side {
- position: fixed;
- width: 200px;
- // 浏览器可见窗口的百分比
- height: 100vh;
- background-color: #001529;
- color: #fff;
- // 超出y轴部分
- overflow-y: auto;
- // 宽度变化时,带动画效果
- transition: width 0.5s;
-
- .logo {
- display: flex;
- align-items: center;
- font-size: 18px;
- height: 50px;
- img {
- margin: 0 16px;
- width: 32px;
- height: 32px;
- }
- }
-
- .nav-menu {
- height: calc(100vh - 50px);
- border-right: none;
- }
- }
-
- .content-right {
- // 因为父元素position:relative,所以直接移200px
- margin-left: 200px;
-
- .nav-top {
- height: 50px;
- line-height: 50px;
- display: flex;
- // 利用flex,将元素排到两端
- justify-content: space-between;
- border-bottom: 1px solid #ddd;
- padding: 0 20px;
-
- .nav-left {
- display: flex;
- align-items: center;
- .menu-fold {
- margin-right: 15px;
- font-size: 18px;
- }
- }
-
- .user-info {
- .notice {
- line-height: 30px;
- margin-right: 15px;
- }
-
- .user-link {
- // 在鼠标指针悬停在元素上时显示相应样式
- cursor: pointer;
- color: #409eff;
- }
- }
-
-
- }
- .wrapper {
- background: #eef0f3;
- padding: 20px;
- height: calc(100vh - 50px);
- }
- }
- }
- </style>
在使用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
- <div class="wrapper">
- <router-view></router-view>
- </div>
在常见的后台中,侧边栏是按登录者的权限来展示的,不同的用户登录时,侧边中的内容有所不同,具体而言,权限控制由后端权限管理相关逻辑实现,而前端只需要通过后端返回的内容,动态渲染出侧边栏则可。
我们将侧边栏相关的逻辑放在class为nav-side的div中:
- <div class="nav-side">
- <div class="logo">
- <img src="./../assets/logo.png" alt="" class="src" />
- <span>DashBoard</span>
- </div>
- <!-- 导航菜单 -->
- <!-- https://element-plus.org/zh-CN/component/menu.html#menu-%E5%B1%9E%E6%80%A7 -->
- <el-menu
- class="nav-menu"
- background-color="#001529"
- text-color="#fff"
- router
- >
- <TreeMenu :menuList="menuList.data" />
- </el-menu>
- </div>
首先,我们使用element-plus中的el-menu来包裹出侧边栏,el-menu元素可使用的属性可自行读一下文档,而侧边栏真正的实现逻辑是TreeMenu子组件,其传入参数为menuList,TreeMenu.vue代码如下:
- <script setup>
- // https://v3.cn.vuejs.org/api/sfc-script-setup.html#defineprops-%E5%92%8C-defineemits
- const props = defineProps({
- menuList: Array,
- });
- </script>
-
- <template>
- <template v-for="menu in menuList" :key="menu._id">
- <el-sub-menu
- v-if="
- menu.children &&
- menu.children.length > 0 &&
- menu.children[0].menuType == 1
- "
- :index="menu.path"
- >
- <!-- 目录父级 -->
- <template #title>
- <!-- https://segmentfault.com/q/1010000040569967 -->
- <el-icon><component :is="menu.icon" /></el-icon>
- <span>{{ menu.menuName }}</span>
- </template>
- <TreeMenu :menuList="menu.children" />
- </el-sub-menu>
- <!-- 目录子级 -->
- <el-menu-item
- v-else-if="menu.menuType == 1"
- :index="menu.path"
- >{{ menu.menuName }}</el-menu-item
- >
- </template>
- </template>
这个项目中,我使用了最新的setup语法,TreeMenu子组件在接收父组件传参时,需要通过defineProps方法来实现参数的接收。
TreeMenu子组件核心逻辑在template中,因为侧边栏有嵌套的情况,比如下图这种情况:
对于嵌套情况,可以通过递归的方式来,通过v-if判断是否当前元素是否嵌套有子结构,有的话就再通过TreeMenu来构建出新的新的节点。
TreeMenu代码中有一个小技巧,就是多个template节点的嵌套使用,template节点本身不会被渲染出相应的DOM,利用template节点来放置v-for、v-if等操作是很合适的做法。
另外一个技巧是,element-plus展示图标的方式有所改变,我们需要通过动态组件的方式来放置合适的图标:
<el-icon><component :is="menu.icon" /></el-icon>
当然,要在项目中随意使用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中的插槽:
- <!-- 目录父级 -->
- <template #title>
- <!-- https://segmentfault.com/q/1010000040569967 -->
- <el-icon><component :is="menu.icon" /></el-icon>
- <span>{{ menu.menuName }}</span>
- </template>
我们希望目录中父级目录可以显示图标和名字,而子级目录,不需要显示图标了,但Element-plus提供的el-menu并不直接支持,此时我们就需要通过Vue插槽动态的将template中的代码替换到el-menu中,el-menu组件提供了title插槽。
常见的后台其顶部栏会展示面包屑和用户相关信息:
相关的HTML如下:
- <div class="nav-top">
- <div class="nav-left">
- <div class="bread">
- <BreadCrumb />
- </div>
- </div>
- <div class="user-info">
- <!-- 信息红点通知 -->
- <el-badge
- :is-dot="noticeCount > 0 ? true : false"
- class="notice"
- type="danger"
- >
- <el-icon><bell /></el-icon>
- </el-badge>
- <!-- command 点击菜单项触发的事件回调 -->
- <el-dropdown @command="handleLogout">
- <span class="user-link">
- {{ userInfo.userName }}
- <el-icon><right /></el-icon>
- </span>
- <template #dropdown>
- <el-dropdown-menu>
- <el-dropdown-item command="email"
- >邮箱:{{ userInfo.userEmail }}</el-dropdown-item
- >
- <el-dropdown-item command="logout">退出</el-dropdown-item>
- </el-dropdown-menu>
- </template>
- </el-dropdown>
- </div>
- </div>
整体结构通过div构建,先看面包屑,它是一个独立的组件,通过BreadCrumb子组件实现。
这里面包屑的主要效果是暂时当前用户访问页面其访问路径,vue-router中的属性可以让我们轻松实现效果效果:
- <script setup>
- import { computed } from '@vue/reactivity';
- import router from '../router';
- import { ArrowRight } from '@element-plus/icons-vue'
-
- const breadList = computed(() => router.currentRoute.value.matched)
-
- </script>
-
- <template>
- <el-breadcrumb :separator-icon="ArrowRight">
- <el-breadcrumb-item v-for="(item, index) in breadList" :key="item.path">
- <router-link to="/welcome" v-if="index==0">{{item.meta.title}}</router-link>
- <span v-else>{{item.meta.title}}</span>
- </el-breadcrumb-item>
- </el-breadcrumb>
- </template>
这里通过computed获得路由路径,computed会构建一层缓存,当对象发生改变时,缓存会更新。
通过router.currentRoute.value.matched可以获得当前路由以及访问当前路由的完整路由路由,这是vue-router提供的功能。
要找到这种功能,最好的方式是使用debug大法,参考element-plus-admin源码剖析一文,debug起来,效果如下:
对router对象,一层层看里面的属性,便可以找到需要的内容了,随后便通过el-breadcrumb标签展示出来则可。
面包屑完成了,将注意力移动到顶部栏右侧的下拉按钮:
- <div class="user-info">
- <!-- 信息红点通知 -->
- <el-badge
- :is-dot="noticeCount > 0 ? true : false"
- class="notice"
- type="danger"
- >
- <el-icon><bell /></el-icon>
- </el-badge>
- <!-- command 点击菜单项触发的事件回调 -->
- <el-dropdown @command="handleLogout">
- <span class="user-link">
- {{ userInfo.userName }}
- <el-icon><right /></el-icon>
- </span>
- <template #dropdown>
- <el-dropdown-menu>
- <el-dropdown-item command="email"
- >邮箱:{{ userInfo.userEmail }}</el-dropdown-item
- >
- <el-dropdown-item command="logout">退出</el-dropdown-item>
- </el-dropdown-menu>
- </template>
- </el-dropdown>
- </div>
核心的下拉后展示相关内容的逻辑,主要通过el-dropdown的dropdown插槽实现。
登录是个常见的功能,通过Element-plus提供的表单组件,来构建登录页的基础骨架:
- <template>
- <div class="login-wrapper">
- <div class="modal">
- <!-- https://juejin.cn/post/7033953300731035655 -->
- <!-- ref绑定变量, :ref绑定函数 -->
- <el-form ref="userForm" :model="user" status-icon :rules="rules">
- <div class="title">火星</div>
- <el-form-item prop="userName">
- <el-input
- type="text"
- prefix-icon="user"
- v-model="user.userName"
- />
- </el-form-item>
- <el-form-item prop="userPwd">
- <el-input
- type="password"
- prefix-icon="view"
- v-model="user.userPwd"
- />
- </el-form-item>
- <el-form-item>
- <el-button type="primary" class="btn-login" @click="login"
- >登录</el-button
- >
- </el-form-item>
- </el-form>
- </div>
- </div>
- </template>
阅读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。
- <el-form-item prop="userName">
- <el-input
- type="text"
- prefix-icon="user"
- v-model="user.userName"
- />
- </el-form-item>
- <el-form-item prop="userPwd">
- <el-input
- type="password"
- prefix-icon="view"
- v-model="user.userPwd"
- />
- </el-form-item>
这样就可以实现实时校验的效果了。
虽然是一个很常见的后台,但对于我这种前端比较薄弱的后端同学,还是踩了一些坑的。
项目代码:https://github.com/ayuliao/DashboardFrameWork