用 vite + vue3 + ts + pinia 搭建一套自己的后台管理系统 (1)
前言
一直想着要去写点东西,去记录一下,对自己也是一个提高,但是想是一回事,做是一回事,然后就拖,拖,拖,就到了现在。直到最近看到一本大佬(冴羽)
推荐的书《认知觉醒》《认知驱动》
,这两本书目前还在看。记得很深刻的一个观点输入与输出要平衡,如果只是一味的去输入,但是从来不进行输出,总结与思考,那也终究是流水而过,并不能让我们很快的提高,只有输入和输出有效平衡,达到知行合一,才能成长。有句话叫做最好的种树时间是十年前,其次是在现在,也不算晚,撸起袖子加油干。那就从今天起,摆脱焦虑,从零出发!
技术选型
公司用的技术是 vue2 ,要做新项目,react 与 vue3 二选一,之前的一个项目也用过 react 不过是用 class 那种方式。算是比较早一点了。最后选择了 vue3,如果有时间可以用 react Hooks方式重构一下,当时是这样想的,现在也在推进中(因为不是特别忙,周末有时间也可以搞一搞)于是就选了 vue3 + ts + vite 脚手架
1 初始化项目
当时用的 vite3 现在已经到 vite4 了,应该差别不大,具体不同跟着api来就行。下面是我的项目配置选项 npm create vite@latest
- 根据提示 npm install npm run dev 运行项目(这个是vite4,为了截图演示,和vite3差别不大)
2 路由
- 脚手架已经帮我们配置好了路由,需要注意的一个点,就是使用 History 模式时打包时的一个配置要加上,不然会出现路由找不到的问题,这个和发布时 nginx 配置没关系
export default defineConfig({
base: '/', // 开发或生产环境服务的公共基础路径。合法的值包括以下几种:
// 绝对 URL 路径名,例如 /foo/
// 完整的 URL,例如 https://foo.com/
// 空字符串或 ./(用于开发环境)
})
- 然后来说动态路由的实现,我们的路由权限时前后端协调,前端定义路径,路由名称相关的东西,具体的字段定义根据自己实际项目来就好,路由权限存入数据库,超管给用户分配权限,权限精细到按钮级别。左侧菜单用路由返回渲染,按钮进行自定义指令来实现。废话不多说,继续看代码实现,后台返回的是这样格式的权限,分为三级,外层布局,页面路由,按钮路由(如果只是到页面路由,无精确到按钮,或者不用动态赋值权限可以二级,不然后台数据返回无法成树结构)
const routerMap = [
{
authorityName: '一级',
component: 'layout',
icon: '',
label: '一级',
parentId: null,
routeName: 'home',
url: '/home',
children: [
{
authorityName: '我的',
component: 'home/index',
icon: '',
label: '我的',
routeName: 'my',
url: 'my',
children: [
{
authorityName: '查看',
component: 'home/my/index',
icon: '',
label: '查看',
routeName: 'my-query',
url: '',
children:[]
},
{
authorityName: '添加,编辑渠道',
component: 'home/my/index-edit',
icon: '',
label: '查看',
routeName: 'my-edit',
showInConfig: 1,
url: 'my-edit',
children: []
}
]
}
]
- 和使用 vue-cli 脚手架不同的的点,webpack 里可以直接使用 import 进行路由导入,但是在 vite 里面,虽然也可是使用动态import但是文件路径变为深层的就出问题了(支持动态但又不全支持,不好用)
所以我们选择 使用另一种方式 import.meta.glob 具体详细使用查看官网
这个 import.meta.glob 返回的是一个 modules ,这里面存放的是按照你给的路径的所有匹配文件,我们需要根据这个匹配文件和后台返回的路径进行对比,拿到权限,进行动态加载。定义promission.ts 在main.ts内引入
- 动态路由添加:根据接口返回的路由权限,进行递归添加路由处理。如果路由名称为 'layout' 说明是我们的第一层级,就用我们引入的 layout 布局组件,直接通过 addRouter 进行路由添加(router4已经废弃掉addRouters)。如果不是,则定义通过 modules 匹配查找,这里的定义的 obj 就是需要动态添加的路由。(meta属性根据自己的需求来添加,如果不需要,则不要加该属性不影响流程)。后面 item.children 判断内的循环主要作用是,给不是 layout 组件的路由添加父级路由名称,不然无法找到子级路由的父级。
(leveL和lable属性根据自己的业务看判断是否要加)
然后进行一层递归处理就完成动态路由的实现。 - 路由拦截:每次路由跳转都会进入该拦截器里面,所以我们可以在这里面进行逻辑处理,如果没有 token(因为业务逻辑中登录分为两步,所以加了用户数据的判断),说明用户未登录,无论进入哪些页面(路由过滤页除外,比如修改密码之类的)都会重新进入登录页。如果已经登录,判读路由是否添加完成(如果获取的路由长度大于你定义的基础路由,则说明动态路由已经添加完成)如果路由已经添加完成,判读进入的路径是否是登录页,如果不是则放行,如果是则进入首页。如果路由未加载,则需要进行动态加载后再判断进行相同的判断。如果未登录,任何路径都调转到登录页(需要过滤的路由除外)到这一步动态路由已经全部完成。
promission.ts
import router from './router'
import layout from './views/layout/index.vue'
const modules = import.meta.glob('@/views/**/*.vue')
import pinia from '@/stores'
import { userStore } from './stores/users'
// 在router中使用pinia https://blog.csdn.net/youyacoder/article/details/127244318?utm_medium=distribute.pc_relevant.none-task-blog-2~default~baidujs_baidulandingword~default-0-127244318-blog-124769206.pc_relevant_multi_platform_whitelistv3&spm=1001.2101.3001.4242.1&utm_relevant_index=3
const filterRouter = (promissionList: any) => {
// console.log(promissionList)
promissionList = JSON.parse(JSON.stringify(promissionList)) //接口返回的权限数据
const accessedRouters = promissionList.map((item: any) => {
if (item.component === 'layout') {
item.componentVue = layout
} else {
// 如果有component 则需要渲染路由,否则则不需要(如弹窗类新增操作,删除操作,接口返回树结构进行按钮权限判断)
item.componentVue = item.component ? modules[`/src/views/${item.component}.vue`] : ''
}
const obj = {
path: item.url,
name: item.routeName,
component: item.componentVue,
meta: {
level: item.component === 'layout' ? 1 : item.level, // 如果为一级菜单则取值level 1 否则取值level
label: item.label
}
}
if (item.component === 'layout') {
// 如果component 为layout则该组件为根路由
router.addRoute(obj)
} else if (item.component) {
// 无component 无需动态添加路由
router.addRoute(item.parent, obj)
}
if (item.children && item.children.length > 0) {
item.children = item.children.map((ele: any) => {
ele.level = ele.url ? obj.meta.level + 1 : obj.meta.level // 左侧菜单栏默认为查询路由,url为空,level等级为父级等级
ele.parent = item.routeName
ele.label = item.component && ele.url === '' ? obj.meta.label : ele.label
return ele
})
item.children = filterRouter(item.children)
}
return item
})
return accessedRouters
}
路由拦截:
const routerFilters: Array<string> = ['/resetpassword'] // 路由过滤,去除不需要校验的路由
router.beforeEach(async (to, from, next) => {
const { users } = userStore(pinia) // beforeEach 之前store 还未u挂载,不能获取实例,所以在beforeEach 内部获取
const token: string | null = localStorage.getItem('token')
if (token && users.authorityTrees && users.authorityTrees.length > 0) {
const routerPath = `${users.authorityTrees[0].url}/${users.authorityTrees[0].children[0].url}`
if (router.getRoutes().length <= 4) {
await filterRouter(users.authorityTrees)
if (to.path !== '/login') {
next({ ...to, replace: true })
} else {
next(routerPath) // 无权限页面跳转到首页
}
} else if (to.path !== '/login') {
next()
} else {
next(routerPath)
}
} else if (to.path === '/login') {
localStorage.removeItem('token')
next()
} else if (routerFilters.includes(to.path)) {
localStorage.removeItem('token')
next()
} else {
localStorage.removeItem('token')
next('/login')
}
})
3 全局状态管理(pinia)
- 脚手架已经帮我们初始化完成了 pinia, 我们可以稍微改造一下,因为后面有几个地方需要用到暴露出来的 pinia。我们在 store 文件夹下新建 index.ts ,然后在 main.ts 里面直接使用我们暴露出来的 pinia
store/index.ts
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia
mian.ts
import { createApp } from 'vue'
import pinia from './stores'
import App from './App.vue'
import router from './router'
import './promission'
import { permission } from '@/utils/directive/index'
const app = createApp(App)
app.directive('permission', permission)
app.use(pinia)
app.use(router)
app.mount('#app')
-
有个点需要注意一下,就是非 vue 文件,比如我们的路由拦截器里面,如果使用我们的全局状态数据,按照vue文件里面写法第一次是拿不到值的,beforeEach 之前store 还未挂载,不能获取实例,所以需要在 beforeEach 内部获取,一个是我们暴露出来的 pinia,一个是我们暴露出来的单个module(vue2可以这么叫,vue3可能不太准确),我们在 router.beforeEach里面使用
const { users } = userStore(pinia)
就可以拿到数据 -
pinia数据持久化处理,使用起来也比较简单,安装完成 pinia-plugin-persist 后直接在 main.ts 使用,在我们的modules里面直接开启设置就可以使用了
import { createApp } from 'vue'
import pinia from './stores'
import piniaPersist from 'pinia-plugin-persist'
import App from './App.vue'
import router from './router'
import './promission'
const app = createApp(App)
app.directive('permission', permission)
pinia.use(piniaPersist)
app.use(pinia)
app.use(router)
app.mount('#app')
store/nav.ts
import { defineStore } from 'pinia'
export const navStore = defineStore('navKeys', {
state: () => ({
list:[]
}),
actions: {
setNav() {
....
}
},
persist: {
enabled: true, // 开启存储
// **strategies: 指定存储位置以及存储的变量都有哪些,该属性可以不写,不写的话默认是存储到sessionStorage里边,默认存储state里边的所有数据**
strategies: [
{ storage: localStorage }
// storage 存储到哪里 sessionStorage/localStorage
// paths是一个数组,要存储缓存的变量,当然也可以写多个
// paths如果不写就默认存储state里边的所有数据,如果写了就存储指定的变量
]
}
})
4 项目配置
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// ant-design-vue
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
// gzip 压缩
import viteCompression from 'vite-plugin-compression'
import { visualizer } from 'rollup-plugin-visualizer' // 打包模块数据分析 在根目录生成stats.html
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
//vue vue-router自动引入,我们在vue文件中使用ref reactive 这样的vue hooks就不用去引入,直接可以使用
AutoImport({
imports: ['vue', 'vue-router']
}),
// antd组件自动引入 dirs 设置全局组件自动引入
Components({
resolvers: [AntDesignVueResolver()],
dirs: ['src/components']
}),
// gzip压缩
viteCompression({
verbose: true,
disable: false,
threshold: 10240,
algorithm: 'gzip',
ext: '.gz'
}),
//打包文件大小查看,在根目录生成stats.html
visualizer()
],
base: '/', // 开发或生产环境服务的公共基础路径。合法的值包括以下几种:
// 绝对 URL 路径名,例如 /foo/
// 完整的 URL,例如 https://foo.com/
// 空字符串或 ./(用于开发环境)
build: {
minify: 'terser',
terserOptions: {
compress: {
keep_infinity: true, // 防止 Infinity 被压缩成 1/0,这可能会导致 Chrome 上的性能问题
drop_console: true, // 生产环境去除 console
drop_debugger: true // 生产环境去除 debugger
}
},
//打包分类以及超大资源拆分
rollupOptions: {
output: {
chunkFileNames: 'static/js/[name]-[hash].js',
entryFileNames: 'static/js/[name]-[hash].js',
// assetFileNames: 'static/[ext]/[name]-[hash].[ext]' 开启会出现异常bug
manualChunks(id) {
if (id.includes('node_modules')) {
// 超大静态资源拆分
return id.toString().split('node_modules/')[1].split('/')[0].toString()
}
}
}
},
chunkSizeWarningLimit: 1500 // chunk 大小警告的限制(以 kbs 为单位)
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
//全局css
css: {
preprocessorOptions: {
javascriptEnabled: true,
less: { additionalData: '@import "@/assets/style/var.less";' }
}
},
//环境变量
envDir: path.resolve(__dirname, './env'),
server: {
hmr: true,
proxy: {
'/apis': {
target: 'https://xxx-xxx.com/',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/apis/, '')
}
}
}
})
还有一点需要注意的是我们在 tsconfig.json 文件里面配置自动生成(vue,antd自动引入配置)的 auto-imports.d.ts components.d.ts
文件
tsconfig.json
"include": ["env.d.ts", "src/**/*", "src/**/*.vue","./auto-imports.d.ts","./components.d.ts"],
开启gzip需要在nginx里面做相应的配置
gzip on; gzip_min_length 1k; gzip_buffers 4 16k; gzip_http_version 1.1; gzip_comp_level 9; gzip_types text/plain application/x-javascript text/css application/xml text/javascript application/x-httpd-php application/javascript application/json; gzip_disable "MSIE [1-6]."; gzip_vary on;
环境变量的话我们在根目录创建env文件夹,,里面配置我们的接口请求域名
.env 开发环境使用默认
.uat 测试环境
.proud 生产环境
我们需要在 package.json 里面更新运行命令
"scripts": {
"dev": "vite --port 8888 --open",
"dev:uat": "vite --mode uat",
"dev:prod": "vite --mode prod",
"build": "run-p type-check build-only",
"build:uat": "run-p type-check && vite build --mode uat",
"build:prod": "run-p type-check && vite build --mode prod",
"preview": "vite preview --open"
},
我们需要在 axios 实例里面配置环境变量
5 axios接口封装
- 创建aios实例,如上图
- require 配置 配置请求头,以及登录成功请求头添加token
- Response 配置 正常返回,接口异常处理等。出参数据类型我们可以进行定义,这样我们在请求完成后就能拿到定义这层的代码提示,具体的返回数据 data 我们可以在 api 中定义,然后在 catch 里面我们根据自己的业务来统一处理异常,如401 403 500等
// 引入axios
import axios from 'axios'
import type { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
import { userStore } from '@/stores/users'
import { Message } from './../utils/index'
import router from '../router'
// 使用指定配置创建axios实例
const instance = axios.create({
baseURL: import.meta.env.VITE_BASE_API,
timeout: 30000
// ....其他配置
})
instance.interceptors.request.use(
(config: AxiosRequestConfig) => {
const store = userStore()
// console.log(store)
const token: string | null = localStorage.getItem('token')
config.headers = {
...{ 'Content-Type': 'application/json', 'Accept-Language': 'zh-CN' },
...config.headers
}
if (token) {
config.headers['Authorization'] = `bearer ${token}`
config.headers['userId'] = store.users.userId
}
// console.log(config)
return config
},
(err: AxiosError) => Promise.reject(err)
)
// 后台给我们的数据类型如下
// 泛型T指定了Response类型中result的类型,默认为any
type Response<T = any> = {
// 描述
desc: string
message: string
msgParam: string
status: string
data: T
}
// AxiosRequestConfig从axios中导出的,将config声明为AxiosRequestConfig,这样我们调用函数时就可以获得TS带来的类型提示
// 泛型T用来指定Reponse类型中result的类型
export default <T>(config: AxiosRequestConfig) =>
// 指定promise实例成功之后的回调函数的第一个参数的类型为Response<T>
new Promise<Response<T>>((resolve, reject) => {
// console.log(config)
// instance是我们在上面创建的axios的实例
// 我们指定instance.request函数的第一个泛型为Response,并把Response的泛型指定为函数的泛型T
// 第二个泛型AxiosResponse的data类型就被自动指定为Response<T>
// AxiosResponse从axios中导出的,也可以不指定,TS会自动类型推断
instance
.request<Response<T>>(config)
.then((response: AxiosResponse<Response<T>>) => {
// console.log(response.data)
// response类型就是AxiosResponse<Response<T>>,而data类型就是我们指定的Response<T>
// 请求成功后就我们的数据从response取出并使返回的promise实例的状态变为成功的
resolve(response.data)
})
.catch((err: AxiosError) => {
swith(){
case....;
case....;
}
})
有个点需要注意一下就是类似 antd 里面Message 这样的方法,我们使用时是需要自己再引入的,因为它和组件还是有点区别的
引入封装好的请求 request 暴露出我们的接口就可以使用了
// 登录相关接口
import request from '../axios'
// 登录
export interface ILogin {
username: string
password: string
}
// 登录
export const Login = (data: ILogin) =>
// request中的出参类型根据我们实际的业务来定义
request<any>({
url: 'xxx/login',
method: 'POST',
data
})
6 代码格式化
采用 eslint + prettier 进行代码格式化 .eslintrc.cjs 如下
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
extends: ['plugin:vue/vue3-essential', 'eslint:recommended', '@vue/eslint-config-typescript', '@vue/eslint-config-prettier', 'vue-global-api'],
parserOptions: { parser: '@typescript-eslint/parser', ecmaVersion: 'latest', sourceType: 'module' },
rules: {
'no-inferrable-types': 'off', // **此处为修改*
'vue/multi-word-component-names': ['error', { ignores: ['index'] }],
// --以下是Possible Errors JS代码中的逻辑错误相关
'no-extra-parens': 'error', // 禁止不必要的括号
// "no-console": "error" // 不允许打印console.log
'no-template-curly-in-string': 'error', // 禁止在常规字符串中出现模板字符串语法${xxx}
// --以下是Best Practices 最佳实践
'default-case': 'error', // 强制switch要有default分支
'dot-location': ['error', 'property'], // 要求对象的点要跟属性同一行
eqeqeq: 'error', // 要求使用 === 和 !==
'no-else-return': 'error', // 禁止在else前有return,return和else不能同时存在
'no-empty-function': 'error', // 禁止出现空函数,有意而为之的可以在函数内部加条注释
'no-multi-spaces': 'error', // 禁止出现多个空格,如===前后可以有一个空格,但是不能有多个空格
'no-multi-str': 'error', // 禁止出现多行字符串,可以使用模板字符串换行
'no-self-compare': 'error', // 禁止自身比较
'no-unmodified-loop-condition': 'error', // 禁止一成不变的循环条件,如while条件,防止死循环
'no-useless-concat': 'error', // 禁止没有必要的字符串拼接,如'a'+'b'应该写成'ab'
'require-await': 'error', // 禁止使用不带await的async表达式
// --以下是Stylistic Issues 主观的代码风格
'array-element-newline': ['error', 'consistent'], // 数组元素要一致的换行或者不换行
'block-spacing': 'error', // 强制函数/循环等块级作用域中的花括号内前后有一个空格(对象除外)
'brace-style': ['error', '1tbs', { allowSingleLine: true }], // if/elseif/else左花括号要跟if..同行,右花括号要换行;或者全部同一行
'comma-dangle': ['error', 'only-multiline'], // 允许在对象或数组的最后一项(不与结束括号同行)加个逗号
'comma-spacing': 'error', // 要求在逗号后面加个空格,禁止在逗号前面加一个空格
'comma-style': 'error', // 要求逗号放在数组元素、对象属性或变量声明之后,且在同一行
'computed-property-spacing': 'error', // 禁止在计算属性中出现空格,如obj[ 'a' ]是错的,obj['a']是对的
'eol-last': 'error', // 强制文件的末尾有一个空行
'func-call-spacing': 'error', // 禁止函数名和括号之间有个空格
'function-paren-newline': 'error', // 强制函数括号内的参数一致换行或一致不换行
// 'implicit-arrow-linebreak': 'error', // 禁止箭头函数的隐式返回 在箭头函数体之前出现换行
// indent: ['error', 2], // 使用一致的缩进,2个空格
'key-spacing': 'error', // 强制对象键值冒号后面有一个空格
'lines-around-comment': 'error', // 要求在块级注释/**/之前有一个空行
'newline-per-chained-call': ['error', { ignoreChainWithDepth: 2 }], // 链式调用长度超过2时,强制要求换行
'no-lonely-if': 'error', // 禁止else中出现单独的if
'no-multiple-empty-lines': 'error', // 限制最多出现两个空行
'no-trailing-spaces': 'error', // 禁止在空行使用空白字符
'no-unneeded-ternary': 'error', // 禁止多余的三元表达式,如a === 1 ? true : false应缩写为a === 1
'no-whitespace-before-property': 'error', // 禁止属性前有空白,如console. log(obj['a']),log前面的空白有问题
'nonblock-statement-body-position': 'error', // 强制单行语句不换行
'one-var-declaration-per-line': 'error', // 强制变量初始化语句换行
'operator-assignment': 'error', // 尽可能的简化赋值操作,如x=x+1 应简化为x+=1
'semi-spacing': 'error', // 强制分号后面有空格,如for (let i=0; i<20; i++)
'semi-style': 'error', // 强制分号出现在句末
'space-before-blocks': 'error', // 强制块(for循环/if/函数等)前面有一个空格,如for(...){}是错的,花括号前面要空格:for(...) {}
'space-infix-ops': 'error', // 强制操作符(+-/*)前后有一个空格
'spaced-comment': 'error', // 强制注释(//或/*)后面要有一个空格
// // --以下是ECMAScript 6 ES6相关的
'arrow-body-style': 'error', // 当前头函数体的花括号可以省略时,不允许出现花括号
// 'arrow-parens': ['error', 'as-needed'], // 箭头函数参数只有一个时,不允许写圆括号
'arrow-spacing': 'error', // 要求箭头函数的=>前后有空格
'no-confusing-arrow': 'error', // 禁止在可能与比较操作符混淆的地方使用箭头函数
'no-duplicate-imports': 0, // 禁止重复导入
'no-useless-computed-key': 'error', // 禁止不必要的计算属性,如obj3={['a']: 1},其中['a']是不必要的,直接写'a'
'no-var': 'error', // 要求使用let或const,而不是var
'object-shorthand': 'error', // 要求对象字面量使用简写
'prefer-const': 'error', // 要求使用const声明不会被修改的变量
'prefer-template': 'error', // 使用模板字符串,而不是字符串拼接
'rest-spread-spacing': 'error', // 扩展运算符...和表达式之间不允许有空格,如... re1错误,应该是...re1
'template-curly-spacing': 'error', // 禁止模板字符串${}内前后有空格
'vue/comment-directive': 'off'
}
}
.prettierrc
{
"semi": false,
"singleQuote": true,
"useTabs": false,
"tabWidth": 2,
"printWidth": 200,
"trailingComma": "none",
"endOfLine": "auto"
}
最后
差不多配置已经完成了,代码规范,公共组件之类的根据自己项目业务来处理就可以了!组件封装,开发规范会在下一篇文章整理出来(第一次写文章,如果有不对的地方,请大佬指点!)
转载自:https://juejin.cn/post/7204998680066555962