手摸手用 vite + vue3 + ts 搭建一套自己的后台管理系统 (2)

前言

完犊子了,刚写完发布上去的文章被我给当草稿给误删了,真的服了。可能自己都觉得这篇文章写的不太好吧,那就换个思路重写吧!上篇文章写了基础的配置,如代码格式化,项目打包等相关,本篇文章继续深入开发细节,对开发规范,动态路由,pinia 状态管理器,axiosts 二次封装等进行讲述!

1 开发规范

每个团队都有自己的开发规范,能保持代码的一致性,后面更好维护。当然有的项目比较久远,各写各的,也就无法形成有效的规范。所以从开始搭建项目框架时就要配置一套行之有效的规则。下文规范 仅供参考

# 1 目录命名 
  采用小写方式, 以中划线分隔,有复数结构时,要采用复数命名法, 缩写不用复数
# 2 文件名称 采用小写方式, 以中划线分隔 (JS、CSS、SCSS、HTML、PNG)
  例 render-dom.js / signup.css / index.vue / company-logo.png
# 3 参数名、变量都统一使用 lowerCamelCase 风格,采用小写驼峰命名 lowerCamelCase 代码中的命名均不能以下划线,也不能以下划线或美元符号结束 
  例 userName,userAge (后台接口需要字段除外) 如登录 params:{username:'',password:''}
# 4 方法名  method 方法命名必须是 动词 或者 动词+名词 形式**
 例 saveShopCarData /openShopCarInfoDialog
**增删查改,详情统一使用如下 5 个单词,不得使用其他(目的是为了统一各个端)**
 create / delete / update / query
 例 addUser updateCompany queryUserInfo deleteCompany
# 5条件判断和循环最多三层
 条件判断能使用三目运算符和逻辑运算符解决的,就不要使用条件判断,但是谨记不要写太长的三目运算符。
 如果超过 3 层请抽成函数,并写清楚注释

## vue相关 ##

#1 组件名为多个单词
例  export default {
      name: 'TodoItem'
      // ...
    }
#2 组件文件名为 pascal-case 格式 index除外 禁止使用小驼峰格式
正例 components/my-component.vue

#3 全局组件在文件夹components内定义,以App开头 使用完整单词而不是缩写
命名 components/AppaArea.vue 
使用 <AppaArea /> (更好的与局部组件区分)
#4 和父组件紧密耦合的子组件应该以父组件名作为前缀命名
  components/
  |- todo-list.vue
  |- todo-list-item.vue
  |- todo-list-item-button.vue
#5 在 Template 模版中使用组件,应使用 PascalCase 模式,并且使用自闭合组件。
  例:
  <my-component :data="data" />
  
  常量用大写字母定义,下划线进行字段间隔
  例:BUSINESS_MODEL
  
#6 路由路径  使用 pascal-case 格式 (vite 使用热更新时,如果路径使用PascalCase 可能会导致热更新出现问题)
const router=[
  name:'home',
  url:'home',
  children:[
      {
        name:'myCompany',
        url:'my-company',
      }
  ]
]

2 动态路由

1 基础路由与打包指向注意事项

我们把默认的路由配置全部删除,只定义了登录路由

手摸手用 vite + vue3 + ts 搭建一套自己的后台管理系统 (2)
这里面 import.meta.env.BASE_URL 默认为’/’,如果使用默认,怎我们在打包时也要加下配置。这个发布路径根据我们自己项目来,如果是域名根目录就为 ‘/’, 如果是想要再指向某个路径,我们再加上同样的配置就行

手摸手用 vite + vue3 + ts 搭建一套自己的后台管理系统 (2)

2 动态路由的实现

我们先来定义一下数据,当做后台返回的权限字段,拿到数据后进行路由渲染,实现效果为根据权限对左侧菜单进行渲染,以及按钮层级的渲染。

export const asyncRoute = [
  {
    name: 'home',
    path: 'home',
    component: 'layout',
    children: [
      {
        name: 'myCompany',
        path: 'my-company',
        component: 'home/index',
        children: [
          {
            name: 'myCompanyViews',
            path: '',
            component: 'home/company/index' //主页面 也是查询按钮权限
          },
          {
            name: 'myCompanyAdd',
            path: '',
            component: '' // 如果是按钮级别,该component 设置为空,不添加路由 通过自定义指令来实现按钮的显示与隐藏
          },
          {
            name: 'myCompanyDelete',
            path: '',
            component: ''
          }
        ]
      }
    ]
  }
]

1 动态导入前置工具

我们采用 import.meta.glob 的方式进行文件的动态导入。(为什么不使用 import + 箭头函数直接导入呢,在 vite 里面不支持动态字符这种方式导入)
const modules = import.meta.glob('@/views/**/*.vue') 导入该规则下的所有文件,然后我们进行对应匹配

2 递归方式导入

我们在 src 下定义一个 promission.ts,在 main.ts 中引用,来进行路由拦截和动态路由的实现。
对路由数据进行遍历,如果 component'layout' 则说明它是第一层级,直接使用我们的 'layout'组件,如果不是,则我们对 component 进行对比匹配,拿到对应的文件,或者无需加载文件,通过 addRoute (router4)已经无法使用 addRouters 直接进行数组添加)

addRoute(parentName: RouteRecordName, route: RouteRecordRaw): () => void;
addRoute(route: RouteRecordRaw): () => void;

addRouter 接收一个或者两个参数,如果是一个对象则为第一层路由,如果是路由名称和路由对象则在对应路由名称下添加该路由。子级对路由依次进行递归遍历实现动态路由的添加!

const filterRouter = (promissionList: Irouter) => {
 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,
    }
    if (item.component === 'layout') {  // 如果component 为layout则该组件为根路由
      router.addRoute(obj)
    } else if (item.component) {
      router.addRoute(item.parent, obj)  // 无component 无需动态添加路由
    }
    if (item.children && item.children.length > 0) {
      item.children = item.children.map((ele: any) => {
        ele.parent = item.routeName
      })
      item.children = filterRouter(item.children)
    }
    return item
  })
  return accessedRouters
}

3 添加路由拦截配置

我们在 router.beforeEach 里面进行路由拦截,如果无 token 则直接跳转登录,否则,可以通过 router.getRoutes()的长度判断是否已经加载了路由,如果无添加路由,则通过我们上面定义的方法进行添加,如果此时跳转的登录页,我们取值路由首页路由路径进行跳转,如果不是登录,直接放行通过即可!

router.beforeEach(async (to, from, next) => {
  const token: string | null = localStorage.getItem('token')
  if (token) {
    const routerPath = `首页路由路径`
    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 {
    localStorage.removeItem('token')
    next('/login')
  }
})

pinia 状态管理与数据持久化

pinia 配置与使用

我们通过脚手架创建项目时已经帮我们安装配置了 pinia store/users.ts

import { defineStore } from 'pinia'
export const useCounterStore = defineStore('users', {
  state: () => ({
    user: {
      name: '李白',
      age: 18888
    }
  }),
  actions: {
    setUser(obj: any) {
      this.$state.user = { ...this.$state.user, ...obj }
    }
  }
})

vue文件中的几种使用方式

<template>
  <div>我是定义的 pinia值 {{ countPinia }}</div>
  <a-button @click="handelPinia" type="primary">修改Pinia的值</a-button>
</template>
<script lang="ts" setup>
import { useCounterStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const counter = useCounterStore()
// 使 pinia数据实现响应式
// const countPinia = computed(() => counter.user) //第一种
const countPinia = storeToRefs(counter) // 第二种
// 修改pinia数据
const handelPinia = () => {
  // 第一种 直接修改state 中的值
  // counter.user = {
  //   name: '甄姬',
  //   age: 8888
  // }
  // 第二种,直接替换state
  // counter.$state = {
  //   user: {
  //     name: '妲己',
  //     age: 8888
  //   }
  // }
  // 第四种,调用$patch方法
  // counter.$patch({
  //   user: {
  //     name: '妲己',
  //     age: 8888
  //   }
  // })

  // 第四种  使用store 暴露出来的方法 如 vuex 中 dispatch
  counter.setUser({
    name: '妲己',
    age: 8888
  })
}
</script>

pinia 数据持久化

使用 pinia-plugin-persist 插件进行

npm install pinia-plugin-persist

我们创建 store/index.ts 将 pinia 处理一下暴露出来,因为不只是一个地方要使用

// store/index.ts action 后添加配置
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia

// main.ts 代码更新

import pinia from './stores'  
import piniaPersist from 'pinia-plugin-persist' 
pinia.use(piniaPersist)            
app.use(pinia) 

// store/users.ts
persist: {
    enabled: true, // 开启存储
    // **strategies: 指定存储位置以及存储的变量都有哪些,该属性可以不写,不写的话默认是存储到sessionStorage里边,默认存储state里边的所有数据**
    strategies: [
      { storage: localStorage }
      // storage 存储到哪里 sessionStorage/localStorage
      // paths是一个数组,要存储缓存的变量,当然也可以写多个
      // paths如果不写就默认存储state里边的所有数据,如果写了就存储指定的变量
    ]
}

非 vue 文件使用 pinia 比如在路由拦截里面需要使用权限数据

import pinia from '@/stores'
import { userStore } from './stores/users'
// router.beforeEach 
 router.beforeEach(async (to, from, next) => {
  const { users } = userStore(pinia) 
  // beforeEach 之前 store 还未挂载,不能获取实例,所以在beforeEach 内部获取 
})

axios 配置

src/api/index.ts 主要是对出参数据加类型配置

// 引入axios
import axios from 'axios'
import type { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
// 使用指定配置创建axios实例
const instance = axios.create({
  baseURL: import.meta.env.VITE_BASE_API, //env 文件中定义 VITE_BASE_API 接口域名
  timeout: 30000
  // ....其他配置
})
instance.interceptors.request.use(
  (config: AxiosRequestConfig) => {
    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}`
    }
    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) => {
        switch(err.response?.status){
          ...
        }
      })
  })

src/api/login.ts 中引入,将我们封装好的 login 接口方法暴露出去使用

// 登录相关接口
import request from './index'
// 登录
interface ILogin {
  username: string
  password: string
}
interface Ires{
  userId:string
  token:string
  ....
}
// 登录
export const login = (data: ILogin) =>
  // 指定我们封装的request函数的第一个泛型的类型为Category[],也就是指定 Response<T> 中T的类型
  request<Ires>({  //any换为我们自定义的出参类型
    url: 'xxx/login',
    method: 'POST',
    data
  })

最后

到这里配置完成以后,我们的项目基本完成了整体流程的配置。后续文章会进行常用组件的封装, antd Ui 框架的使用以及遇到的问题整理记录

原文链接:https://juejin.cn/post/7216990794942644281 作者:南岸月明

(0)
上一篇 2023年4月2日 下午4:41
下一篇 2023年4月2日 下午4:51

相关推荐

发表回复

登录后才能评论