vue3+vite+ts+pinia 后台管理项目总结

VUE3+VITE+TS

项目中的要点

  1. 使用vue3+vite+ts 构建项目基础框架,配置代码运行环境,对项目进行模块化划分,并集成element组件库、axios请求库;
  2. 利用axios拦截器实现了权限校验,对系统的登入权限进行控制,避免了无效请求;
  3. 利用vue-router路由钩子函数实现系统角色权限控制,动态路由实现页面级权限、vue指令实现按钮级权限;
  4. 封装包含PageHeader页头组件 + EpTable通用表格渲染组件 + BtnGroup通用按钮组 + EpDialog确认弹窗组件;
  5. 二次封装ElForm表单组件,根据具体的业务需求,灵活使用ElForm表单进行表单校验;
  6. 打包上线

配置路由

  • vue3创建router/index.ts
  • 写路由的时候氛围无需权限路由(比如登录)和需要权限的路由。
  • 在vite.config.ts里配置跨域  一般是proxy。
  • element 按需引入也是在vite.config.ts里通过AutoImport和Compontes去引入
// 配置element的中文
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'

权限管理:

  • 登录流程:

登录成功 -> 判断是否存在用户信息 -> 是?跳转页面 ;如果不存在该用户,那么要先创建该用户角色拥有的权限再判断能否跳转页面 总体来说就是用户关联角色、角色又关联菜单

  • 路由权限

    • 方式1:加载完整路由表,加全局守卫判断meta,无权限时都跳转到登录页
    • 方式2:先挂载白名单(公共路由表),登录后再通过addRoute动态挂载路由表
  • 页面级权限:(使用的是方式2)

    • 登录之后利用路由守卫来进行,先在根目录创建permission.ts
    • 路由前置守卫里判断,要预先配置访问路由白名单,引入pinia的权限实例usePermissionStore来获取权限内的菜单,并添加到动态路由。
    • 获取用户信息这部分放到pinia,先看后端返回的数据,一般menus返回的是菜单及页面,permissions返回的是按钮权限列表,所以只需要遍历menus判断是菜单或者页面,注意要转成树形结构。
    • 最后通过router.addRoute()方法添加到路由列表就好。
  • 按钮级权限:

    • 按钮级权限一般都是通过v-if或者封装一个公共方法来判断,这里用的是自定义指令
    • 新建一个目录存放自定义指令,去自动遍历目录内的指令文件并注册,且在main.ts中引入
    • 新建directives/modules/permission.ts来编写权限指令
    • 需要先去接收一个权限标识,首先判断用户是否是管理员,如果是不做多余的处理,如果不是,则去判断用户的权限列表是否存在该权限标识,如果不存在,那么移除按钮。
  • 接口权限

    • axios的响应拦截器里发现返回码是400/401,直接到登录页;
    • axios的所有请求均携带token,在请求拦截器里统一添加请求头,当服务端无响应时,也跳转到登录页。
  • 主页面开发(layout、侧边栏、标签栏、页面切换过渡和页面缓存)

    • 控制侧边栏折叠的按钮是通过slot的方式传入的顶部导航栏,这里用provide发送,在菜单组件用inject接收。
    • 左侧的菜单根据保存的菜单数据来渲染就可以。
    • 组件结构如下layout -> components -> Sidebar -> index.vue , SidebarMenu.vue,SidebarItem.vue
    • 先引用element的滚动条组件,然后再引入SidebarMenu组件。
    • 这个组件,先从pinia中获取菜单数据,并传递给子组件进行渲染;设置上默认展开项,遍历菜单的时候加上一层递归,如果有二级或三级菜单也可以设置上默认展开;对菜单进行排序,采用版本号排序。
    • 然后来看SidebarItem组件
    • 首先去判断该菜单是否要在菜单栏隐藏,然后判断菜单是一级菜单还是二级菜单,也支持只有一个二级菜单的一级菜单直接显示。这些都可以通过alwaysShow配置决定。
  • 标签栏:

    • 需要注意的点标签的数据是哪里赖的,监听route,在route变化时,将新的路由信息添加到标签列表。通过方法pinia封装的方法tagsViewStore.addView()添加,之后用router-link来渲染标签数据。
    • addView()方法主要根据标签的meta属性来判断是否渲染
  • 前端字典:

    • 后端应该都会维护一个叫字典项,大概知道不太懂,如果后端没有提供,自己也有过一定的摸索。
    • 使用的时候引入需要渲染的数据,el-form-item 里的 el-radio遍历数据,并可以在数据配置default,就渲染一个默认值。

封装的思路

封装组件

这里以封装的 EpTable通用表格渲染组件 为例:

  • 发现这些字段的格式高度统一,sortable控制当前字段可否排序,fixed控制当前字段列是否固定,prop定义当前列要渲染数据的哪个字段,label定义列标题,width定义列宽,formatter定义当前字段的加工处理方式,default插槽定义当前列的复杂渲染方式;
  • 最终以配置的方式将表格所有字段的渲染方式处理为一个props,其值为一个数组,对每个要处理的字段以一个对象的形式进行渲染配置;
  • 将最后一列的操作按钮(编辑+删除)固定写死,也可以根据条件渲染;
  • 以上都是组件配置,还提供events事件和便捷操作API;
  • 比如当用户在特定行点击删除的时候,EpTable会给父组件发送自定义事件deleteItem,携带的载荷为当前行row,由父组件处理事件,将数据中相同id的item予以删除
  • 用户在表格中执行多选,EpTable知道用户实时选中了哪些行
  • 父组件通过refEpTable获取EpTable实例在进一步通过调用refEpTable.value.getSelectedItems()获取用户实时选中的所有行,再执行批量操作

封装指令

这里以按钮权限时封装的自定义指令v-permission为例

  • 可以在调用时先接收权限标识,判断该用户是否是管理员,如果是不做处理;如果不是,那么再去判断该用户是否有对应的权限,如果没有,那么就移除该按钮

  • 指令的使用

<el-button
    type="primary"
    @click="showMenuPop()"
    v-permission="'sys:menu:add'"
>添加菜单</el-button>
  • 指令的内部逻辑
mounted(el: any, binding: any) {
    const permissionStore = usePermissionStore()
    const staffStore = useStaffStore()
    if (staffStore.staff?.role_code === superAdminRole) {
      return
    }
    const hasPermission = permissionStore.permissions.includes(binding.value)
    if (!hasPermission) {
      el.remove()
    }
}

封装hook

以【获取页面组件的实时滚动位置钩子useScroll】为例,

  • 该函数可以实时获取页面的滚动位置,调用useScroll()会到响应式数据scrollTop
  • hook内要定义响应式输入const scrollTop = ref(0)
  • 需要将真正在滚动的元素作为参数传递给该hookconst scrollTop = useScroll(scrollingElement)
  • 组件挂载时对其根元素添加scroll事件,组件卸载时移除该DOM事件监听器,以避免内存泄露;
  • scroll事件监听器中,实时获取根元素的scrollToP,同步给ref数据scrollTop:
const scrollHandler = (e) => { scrollTop.value = root.scrollTop }
  • 最终调用该hook的组件得到的就是其根元素实时滚动的位置这一响应式数据

封装axios

  • 先讲如何跨域

    • 开发阶段:通过配置vite的热更新服务器实现跨域,其原理是访问本地的/api,开发服务器会自动代理到服务器的后端地址,服务器之间的互访是不受浏览器同源策略(CORS policy)限制的,跨域得以实现;
    • 生产阶段:使用Nginx替代开发服务器实现跨域,跨域原理与开发阶段相同;
  • 封装axios实例(包括baseUrl,timeout等基础设置)+ 实例拦截器

    • 在请求拦截器中写入通用配置,请求头中统一携带token
    • 在响应拦截器中统一过滤数据,直接提取res.data等
    • 还可以封装通过的POSTGETPUTDELETE请求,发请求只需要写入url、data等
    • 通常还可以封装通用的处理错误的方法

封装模块

以添加用户一条换电订单为例

  • 用户在订单列表头部点击新增按钮,进入新增页;
  • 新增页加载一个空白的订单数据表单,预先按照指定的空白订单数据JSON模板;
  • 用户修改该表单中的各个数据项,表单的所有数据项通过双向数据绑定的方式,将电影的所有字段同步到一个响应式对象filmForm中;
  • 对于电影海报和电影演职人员头像,采用立即上传的方式,使用的是ElementPlus中提供的Upload组件;
  • 最后用户点击提交,将表单数据格式化为后台所需要的数据格式,具体细节为:将演职人员头像由[{name,url}]格式化为[{name,role,avtarAddress}],将数据由[{name,url}]格式化为poster-string形式,再通过ajax的POST请求将表单数据发送服务端,等待服务端返回;
  • 如果数据提交成功,导航调回列表页;

打包上线、优化配置

先说一个插件的使用 视图分析工具 rollup-plugin-visualizer

  • 在项目中使用 rollup-plugin-visualizer 插件可以生成可视化的代码分析报告,看看哪些模块占用了空间,帮助我们更好地了解构建过程中的文件大小、依赖关系等信息
  • vite.config.ts中引入 rollup-plugin-visualizer 插件,并将其添加到插件列表中。
  • 执行命令yarn build打包出来,视图会自动跳出,保存在项目根目录下
  • stats.html这个就是的视图文件,可以直观的看到各个模块占据空间的大小

配置打包文件分类输出

将js,css,图片等资源分别打包到对应的文件夹下,这种方式适合小型项目或者需要快速搭建原型的项目。Vite的默认配置能够很好地满足这些项目的需求,我们就不需要花费太多时间在打包配置上。

js最小拆包

通过最小化拆分包,我们可以将项目代码划分为多个模块或块,每个模块只包含当前页面或功能所需的代码。当用户访问特定页面时,只需加载该页面所需的模块,而无需加载整个项目代码。

这可以减少初始加载时间和资源消耗,适合较大型的项目或者对打包配置有特殊需求的项目

  • vite.config.ts
export default defineConfig({
  // 其他配置项...
   build: {
    rollupOptions: {
      output: {
        // 最小化拆分包
        manualChunks(id) {
          if (id.includes("node_modules")) {
            // 通过拆分包的方式将所有来自node_modules的模块打包到单独的chunk中
            return id
              .toString()
              .split("node_modules/")[1]
              .split("/")[0]
              .toString();
          }
        },
        // 设置chunk的文件名格式
        chunkFileNames: (chunkInfo) => {
          const facadeModuleId = chunkInfo.facadeModuleId
            ? chunkInfo.facadeModuleId.split("/")
            : [];
          const fileName1 =
            facadeModuleId[facadeModuleId.length - 2] || "[name]";
          // 根据chunk的facadeModuleId(入口模块的相对路径)生成chunk的文件名
          return `js/${fileName1}/[name].[hash].js`;
        },
        // 设置入口文件的文件名格式
        entryFileNames: "js/[name].[hash].js",
        // 设置静态资源文件的文件名格式
        assetFileNames: "[ext]/[name].[hash:4].[ext]",
      },
    },
  },
});

剔除console和debugger

  1. vite 4.X 版本已经不集成 terser,需要自行下载。
  2. 在vite.config.ts中去配置
  • vite.config.ts
import { defineConfig } from "vite";  
export default defineConfig({  
    build: {  
        minify: 'terser'// 启用 terser 压缩  
        terserOptions: {  
            compress: {  
                pure_funcs: ['console.log'], // 只删除 console.log 
                //drop_console: true, // 删除所有 console
                drop_debugger: true// 删除 debugger  
            }  
        }  
    }  
})  

图片资源压缩

  • 安装vite-plugin-imagemin插件,由于这个插件在国内不好安装,所以需要先改配置
  • 使用yarn安装需要在package.json添加以下配置
"resolutions": {
    "bin-wrapper""npm:bin-wrapper-china"
}
  • 安装
yarn add vite-plugin-imagemin -D
  • vite.config.ts
import viteImagemin from 'vite-plugin-imagemin';
plugin: [
  viteImagemin({
    gifsicle: {
      optimizationLevel: 7// 设置GIF图片的优化等级为7
      interlaced: false // 不启用交错扫描
    },
    optipng: {
      optimizationLevel: 7 // 设置PNG图片的优化等级为7
    },
    mozjpeg: {
      quality: 20 // 设置JPEG图片的质量为20
    },
    pngquant: {
      quality: [0.80.9], // 设置PNG图片的质量范围为0.8到0.9之间
      speed: 4 // 设置PNG图片的优化速度为4
    },
    svgo: {
      plugins: [
        {
          name: 'removeViewBox' // 启用移除SVG视图框的插件
        },
        {
          name: 'removeEmptyAttrs',
          active: false // 不启用移除空属性的插件
        }
      ]
    }
  })
]

路由懒加载

  • 路由懒加载的实现方式通常是使用动态导入
  • 比如在 Vue 项目中使用 import() 来导入需要懒加载的组件。当用户访问到对应的路由时,该组件才会被异步加载,实现了按需加载的效果。
const routes = [
  {
    path'/',
    name'Home',
    component() => import('../views/Home.vue')
  },
  {
    path'/about',
    name'About',
    component() => import('../views/About.vue')
  }
]

使用 gzip 压缩

gzip压缩是一种常用的数据压缩算法,它可以减小文件的大小,从而减少文件的传输时间和占用空间。gzip压缩算法基于DEFLATE算法,使用了哈夫曼编码和LZ77算法来实现高效的数据压缩。

当使用gzip压缩文件时,文件会被转换为一种经过压缩和编码的格式。这种格式可以通过减少冗余数据和使用更紧凑的编码方式来降低文件的大小。压缩后的文件通常以.gz为扩展名。

  • 安装插件
yarn add vite-plugin-compression2 -D
  • vite.config.ts
import { defineConfig } from 'vite';
import compression from 'vite-plugin-compression';
export default defineConfig({
  // 其他配置项...
  plugins: [
    // 其他插件...
    compression({
      algorithm: "gzip"// 指定压缩算法为gzip,[ 'gzip' , 'brotliCompress' ,'deflate' , 'deflateRaw']
      ext: ".gz"// 指定压缩后的文件扩展名为.gz
      threshold: 10240// 仅对文件大小大于threshold的文件进行压缩,默认为10KB
      deleteOriginFile: false// 是否删除原始文件,默认为false
      filter: /.(js|css|json|html|ico|svg)(?.*)?$/i// 匹配要压缩的文件的正则表达式,默认为匹配.js、.css、.json、.html、.ico和.svg文件
      compressionOptions: { level: 9 }, // 指定gzip压缩级别,默认为9(最高级别)
      verbose: true//是否在控制台输出压缩结果
      disable: false//是否禁用插件
    }),
  ],
});

typescript

个人对ts浅薄的理解:就是type + javascript ,js 有的 ts 都有,所有js 代码都可以在 ts 里面运行。

ts是js的超集。

ts的基本类型

  • Boolean
  • Number
  • String
  • Symbol
  • undefined  是所有类型的子类型
  • null  是所有类型的子类型
  • any  任何类型都可以被归为 any 类型
  • never  永不存在的值的类型

函数类型

  • 函数的类型实际上指的是:函数参数返回值的类型
  • 为函数指定类型的两种方式:
    1. 单独指定参数、返回值的类型
    2. 同时指定参数、返回值的类型
type AddFn = (num1: number, num2: number) => number
const add: AddFn = (num1, num2) => {
  return num1 + num2
}
void类型

如果函数没有返回值,那么,函数返回值类型为:void

function greet(name: string): void {
  console.log('Hello', name)
}


// 如果什么都不写,此时,add 函数的返回值类型为:void
const add = () => {}
// 这种写法是明确指定函数返回值类型为 void,与上面不指定返回值类型相同
const add = (): void => {}
// 但,如果指定 返回值类型为 undefined,此时,函数体中必须显示的 return undefined 才可以
const add = (): undefined => {
  // 此处,返回的 undefined 是 JS 中的一个值
  return undefined
}

对象类型

JS 中的对象是由属性和方法构成的,而 TS 对象的类型就是在描述对象的结构

// 空对象
let person: {} = {}

// 有属性的对象
let person: { name: string } = {
  name: '同学'
}

// 既有属性又有方法的对象
// 在一行代码中指定对象的多个属性类型时,使用 `;`(分号)来分隔
let person: { name: string; sayHi(): void } = {
  name: 'jack',
  sayHi() {}
}

// 对象中如果有多个类型,可以换行写:
// 通过换行来分隔多个属性类型,可以去掉 `;`
let person: {
  name: string
  sayHi(): void
} = {
  name: 'jack',
  sayHi() {}
}

// 方法的类型也可以使用箭头函数形式
{
    greet(name: string):string,
    greet: (name: string) => string
}

interface类型

当一个对象类型被多次使用时,一般会使用接口(interface)来描述对象的类型,达到复用的目的

  • 使用 interface 关键字来声明接口
  • 声明接口后,可以直接使用接口名称作为变量名称
  • 每行只有一个属性,不能使用分号
interface IPerson {
  name: string
  age: number
  sayHi(): void
}
let person: IPerson = {
  name: 'jack',
  age: 19,
  sayHi() {}
}
接口继承
  • 如果两个接口之间有相同的属性或方法,可以将公共的属性或方法抽离出来,通过继承来实现复用
  • 比如,这两个接口都有 x、y 两个属性,重复写两次,可以,但很繁琐
interface Point2D { x: number; y: number }
// 继承 Point2D
interface Point3D extends Point2D {
  z: number
}
interface 和 type的区别
  • 相同点:都可以给对象指定类型
  • 不同点:
    • 不仅可以为对象指定类型,实际上可以为任意类型指定别名
    • 可以使用&运算符实现继承效果
    • 多个同名的type会报错
    • 只能为对象指定类型
    • 可以使用extends继承
    • 多个同名的interface会合并
    • interface
    • type

联合类型

  • 解释:|(竖线)在 TS 中叫做联合类型,即:由两个或多个其他类型组成的类型,表示可以是这些类型中的任意一种
let arr: (number | string)[] = [1'a'3'b']

ts中calss类的关键字

  • extends  通过extends关键字来实现继承
  • super  子类没有定义自己的属性,可以不写super,如果子类有自己属性,可以用super把父类的属性继承过来
  • public  共有的,一个类里默认所有的方法和属性都是public
  • private  私有的,只属于类自己,实例和继承都访问不到
  • static  是静态属性,类的常量,实例不能访问

类型断言

需要更加明确的值时,用到断言

const aLink = document.getElementById('link'as HTMLAnchorElement
// 或者用 <> 语法
const aLink = <HTMLAnchorElement>document.getElementById('link')

泛型

泛型是可以在保证类型安全前提下,让函数等与多种类型一起工作,从而实现复用。

例如:定义泛型函数

需求:创建一个 id 函数,传入什么数据就返回该数据本身(也就是说,参数和返回值类型相同)

function id<Type>(value: Type): Type return value }

function id<T>(value: T): T return value }
/**
语法:在函数名称的后面添加 `<>`(尖括号),尖括号中添加类型变量,比如此处的 Type
类型变量 Type,是一种特殊类型的变量,它处理类型而不是值
该类型变量相当于一个类型容器,能够捕获用户提供的类型(具体是什么类型由用户调用该函数时指定)
因为 Type 是类型,因此可以将其作为函数参数和返回值的类型,表示参数和返回值具有相同的类型
类型变量 Type,可以是任意合法的变量名称
**/
// 调用函数  输入什么值 那么就返回什么类型的值
const num = id<number>(10const str = id<string>('a')
  • 泛型接口
interface IdFunc<Type> {
  id: (value: Type) => Type
  ids: () => Type[]
}

let obj: IdFunc<number> = {
  id(value) { return value },
  ids() { return [135] }
}

解释:

  • 接口名称的后面添加 <类型变量>,那么,这个接口就变成了泛型接口
  • 接口的类型变量,对接口中所有其他成员可见,也就是接口中所有成员都可以使用类型变量
  • 用泛型接口时,需要显式指定具体的类型(比如,此处的 IdFunc)
  • 此时,id 方法的参数和返回值类型都是number ; ids 方法的返回值类型是 numbe

本文荟萃自,只做学术交流学习使用,不做为临床指导,本文观点不代表数字重症立场。

(0)
打赏 微信扫一扫 微信扫一扫
Chu的头像Chu
上一篇 2024年1月19日 下午10:25
下一篇 2024年1月20日 下午11:38

相关推荐