vite+vue3从零开始搭建项目(二)

2,430 阅读11分钟

vite+vue3从零开始搭建项目(二)

这里主要介绍各种常用插件的安装和封装。

以下是系列文章和GitHub仓库地址,点击即可跳转链接。

vite+vue3从零开始搭建项目(一)

vite+vue3从零开始搭建项目(二)

vite+vue3从零开始搭建项目(三)

github代码仓库,搭了架子,后续会填充一些内容

pinia

vue3应该没人用vuex了吧,目前都是pinia。相对于vuexpiniaStatesGettersActions三个模块设计少了Mutationsmodules,更小的体积,更好的性能。

npm i pinia

安装好之后,先在src目录下新建pinia文件夹。个人比较喜欢如下的结构,app.js用来存放侧边栏开关之类的应用型全局属性;tag.js对应导航标签;user.js对应用户信息,最后通过index.js全部导出。

├─ 📁src
│  ├─ 📁pinia
│  │  ├─ 📁modules
│  │  │  ├─ 📄app.js
│  │  │  ├─ 📄tag.js
│  │  │  └─ 📄user.js
│  │  └─ 📄index.js
src/pinia/modules/app.js
import { getAppIsCollapse, setAppIsCollapse } from '@/utils/webStorage'

const useAppStore = defineStore('app', {
  state: () => {
    return {
      isCollapse: getAppIsCollapse()
    }
  },
  actions: {
    toggleCollapse() {
      this.isCollapse = !this.isCollapse
      setAppIsCollapse('isCollapse', this.isCollapse)
    }
  }
})

export default useAppStore
src/pinia/index.js
import { createPinia } from 'pinia'
import useAppStore from './modules/app'
import useUserStore from './modules/user'
import useTagStore from './modules/tag'

const pinia = createPinia()

export { useAppStore, useUserStore, useTagStore }
export default pinia

mian.js注册

这里包含了整个项目的main.js是如何引用或者注册的。

import { createApp } from 'vue'

import pinia from './pinia' // 状态管理器
import router from './router' // 路由管理器
import plugins from './plugins' // 插件管理器

import '@/style/index.scss' // 全局样式入口

import App from './App.vue' // App入口

import './permission' // 路由权限校验

createApp(App).use(pinia).use(router).use(plugins).mount('#app')

vue-router

pinia如果还有争议的空间,vue-router应该就没有了吧,需要注意的是这里要选择4.x版本

npm i vue-router@4

同样在src目录下创建router文件夹。结构如下,其中router.config.js存放本地路由(就算是动态路由,本地开发也需要的吧)。

├─ 📁src
│  ├─ 📁router
│  │  ├─ 📄index.js
│  │  └─ 📄router.config.js
src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  { // 使用layout布局(头部,侧边导航,tag导航标签)的内页都放这里
    path: '/',
    name: 'layout',
    redirect: '/login',
    component: () => import('@/layout/Index.vue'),
    children: [
      { // 有了欢迎光临,动态路由再也不怕没有首页了
        path: '/welcome',
        name: 'welcome',
        component: () => import('@/views/Welcome/Index.vue')
      },
      { // 又要keep-alive,又要刷新,只能靠这个重定向到空白页面,然后router.replace刷新了
        path: '/redirect/:pathMatch(.*)*',
        name: 'redirect',
        component: () => import('@/views/Redirect/Index.vue')
      },
      { // 404页面有了,403, 500有没必要?
        path: '/404',
        name: 'notfound',
        component: () => import('@/views/Error/NotFound.vue')
      }
      // { // 为什么写了要注释掉。写是因为404需要动态匹配并重定向才行。注释掉是因为动态路由的原因,把这个路由用router.addRoute的方式引入进来了。
      //   path: '/:pathMatch(.*)',
      //   name: '404',
      //   redirect: '/404',
      //   component: () => import('@/views/Error/NotFound.vue')
      // }
    ]
  },
  { // 非内页,不需要layout布局的放外面,如登录,大屏等
    path: '/login',
    name: 'login',
    component: () => import('@/views/Login/Index.vue')
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

权限校验

其实这里应该还有一个permission.js的文件来校验路由权限。这里是我的玩法,不同项目、不同团队间的权限校验方式应该是不一样的。这里只需要参考一下。

// src/permission.js
import router from '@/router'
import { getToken } from '@/utils/webStorage'
import { useUserStore } from '@/pinia'

// 白名单
const whiteList = ['/login']
// 是否已加载路由
let routerIsLoaded = false
// 是否加载本地路由
const isLocal = import.meta.env.VITE_API_ISLOCAL === 'true'

// 加载路由
export const loadRoute = async () => {
  const userStore = useUserStore()
  await userStore.getUserInfo(isLocal)
  routerIsLoaded = true
}

// 路由导航
router.beforeEach(async (to, from, next) => {

  // 设置页面标题
  if (to.meta.title) {
    document.title = to.meta.title
  }

  // 跳转到登录页时,重置加载路由
  if (to.path === '/login') {
    routerIsLoaded = false
  }

  // 白名单内直接跳转直接进入
  if (whiteList.includes(to.path)) {
    next()
    return
  }

  // 判断是否登录
  if (getToken() || isLocal) {
    // 判断路由是否加载
    if (routerIsLoaded) {
      // 路由已加载时,匹配到路由 ? 则直接进入 :跳转到404
      if (to.matched.length) {
        next()
      } else {
        next('/404')
      }
    } else {
      // 路由未加载时则等待路由加载完成后,进行下一步
      await loadRoute()
      next({ ...to, replace: true })
    }
  } else {
    // 未登录跳转到登录页
    next({ path: '/login' })
  }
})

路由加载通过pinia里的user模块的getUserInfo接口做统一处理。

import { getUserInfo as getUserInfoStorage, setUserInfo as setUserInfoStorage } from '@/utils/webStorage'
import { getUserInfo as apiGetUserInfo } from '@/api/user'

import router from '@/router'
import routes from '@/router/router.config'
import { flattenTree } from '@/utils/dealData'

const useUserStore = defineStore('user', {
  state: () => ({
    userInfo: getUserInfoStorage(),
    buttons: [],
    routes: []
  }),
  actions: {
    getUserInfo(isLocal) {
      return new Promise((resolve, reject) => {
        if (isLocal) {
          // 本地模式下,不使用接口,直接记载本地路由
          this.userInfo = {}
          this.buttons = []
          this.routes = [...routes]
          flattenTree(routes).forEach(item => {
            router.addRoute('layout', item)
          })
          router.addRoute({
            path: '/:pathMatch(.*)',
            name: '404',
            redirect: '/404',
            component: () => import('@/views/Error/NotFound.vue')
          })
          resolve()
        } else {
          // 非本地模式下,通过接口获取路由权限
          getInfo().then(res => {
            this.userInfo = res.data.userInfo
            this.buttons = res.data.buttons
            this.routes = res.data.routes
            flattenTree(routes).forEach(item => {
              router.addRoute('layout', item)
            })
            router.addRoute({
              path: '/:pathMatch(.*)',
              name: '404',
              redirect: '/404',
              component: () => import('@/views/Error/NotFound.vue')
            })
            resolve()
          })
        }
      })
    },
    logout() {
      this.userInfo = {}
      this.buttons = []
      this.routes = []
      sessionStorage.clear()
      router.push('/login')
    }
  }
})

export default useUserStore

页面刷新(组件刷新)

因为是单页面应用,使用reload式的刷新体验非常不好。会导致整体页面重新渲染,并丢失状态管理器的数据,这里尤其是在使用导航标签栏的时候,整个导航标签栏都会重置。

目前使用的思路是通过动态路由参数匹配到专门定制好的空白中转页面,然后在该页面做replace式的刷新。大致理解为A页面空白页面A页面。假设A页面的路由是/pageA,先跳转到/redirect/pageA,因为中转页面的路由是动态的/redirect/:,这时会进入到中转页面,然后携带参数跳回到/pageA

动态路由匹配可以查看上面src/router/index.js文件里的内容。跳转到中转页面的方法如下:

router.replace({
  path: '/redirect' + val.fullPath // 这里需要携带完整路径,如果有参数也可以携带过去
})

下面是中转页面的内容。

<template>
  <div></div>
</template>

<script setup>
const route = useRoute()
const router = useRouter()

onBeforeMount(() => {
  const { params, query } = route
  const { pathMatch } = params
  router.replace({ path: '/' + pathMatch, query })
})
</script>

axios

axios我一直都有在用,很黑,很亮,很油...然后我用完是这样,大家用完也是这样...

npm i axios

新建如下文件结构。api文件夹里存放各个模块的api信息,个人喜欢对应views里面的页面结构来把api文件也对应上;request.js是对axios的封装方法。

├─ 📁src
│  ├─ 📁api
│  │  └─ 📄user.js
│  ├─ 📁utils
│  │  ├─ 📄request.js

配置拦截

src/utils/request.js
import axios from 'axios'

import { getToken } from '@/utils/webStorage'
import qs from 'qs'

const service = axios.create({
  baseURL: '/api',
  withCredentials: false, // 是否携带cookie
  timeout: 60000 // 超时响应
})

// 字节流下载flag
let isBlob = false

// axios拦截器 - 请求头拦截
service.interceptors.request.use(
  config => {
    // 根据请求头判断是否为字节流
    isBlob = config.responseType === 'blob'

    // 处理头部信息:添加token
    config.headers.Authorization = getToken()

    // 请求为表单时
    if (config.contentType === 'form') {
      config.headers['Content-Type'] = 'application/x-www-form-urlencoded'
    }

    // get请求包含数组时,参数进行qs序列化
    if (config.method === 'get') {
      config.paramsSerializer = params => {
        return qs.stringify(params, { arrayFormat: 'repeat' })
      }
    }

    // 请求参数为空时默认给个空对象,undefined和null有一些后端会说报错。
    config.data = config.data || {}
    config.params = config.params || {}
    return config
  },
  error => {
    return Promise.reject(error)
  }
)

// axios拦截器 - 响应拦截
service.interceptors.response.use(
  response => {
    // 如果为字节流,直接抛出字节流,不走code验证
    if (isBlob) {
      return response
    }

    const res = response.data

    // 后台返回非200处理
    if (res.code !== 200) {
      ElMessage({
        message: res.msg || 'Error',
        type: 'error',
        duration: 5 * 1000
      })

      // 401: token失效
      // 这里需要跟后端大佬约定好,一般来说存在几个状态: 
      // 1.登录时间过长导致的本地token过期
      // 2.异地同账号登录导致的本地token失效(单点登录)
      // 3.账号封禁或者其他状态本地token校验失败
      if (res.code === 401) {
        ElMessageBox.confirm('登录失效,请重新登录', '提示', {
          confirmButtonText: '重新登录',
          cancelButtonText: '取消',
          type: 'warning'
        }).then(() => {
          const router = useRouter()
          router.push('/login') // token的问题都这里处理,并跳转到登录页
        })
      }
      return Promise.reject(new Error(res.msg || 'Error'))
    } else {
      return res
    }
  },
  error => {
    // 异常抛出错误并弹个消息
    ElMessage({
      message: error,
      type: 'error',
      duration: 5 * 1000
    })
    return Promise.reject(error)
  }
)

export default service

这里使用了qs做url参数序列化处理。

npm i qs

接口示例

下面是我写的接口示例,需要注意的是参数在哪里就需要选择不同的参数,还有字节流返回是不走code校验的。

src/api/user.js
import request from '@/utils/request'

// 登录
export function login(data) {
  return request({
    url: '/auth/login',
    method: 'post',
    data // 参数在body用data
  })
}

// 获取用户信息
export function getUserInfo(params) {
  return request({
    url: '/auth/getUserInfo',
    method: 'get',
    params // 参数在url上用params
  })
}

// 导出表格
export function exportTable(params) {
  return request({
    url: '/xxx/xxxx',
    method: 'get',
    responseType: 'blob', // 自己封装的下载
    params
  })
}

使用示例

import { login } from '@/api/user

const baseInfo = reactive({
  form: {
    username: '',
    password: ''
  },
  rules: {},
  loading: false
})

login(baseInfo.form).then(res => {
   console.log(res)
})

mockjs

这里如果后端接口长期跟不上,建议是使用mockjs去模拟数据。是否使用完全根据自己团队的现状,除非是演示型demo,我一般不怎么使用(后端大佬都比较给力,先夸,夸不了就用武力屈服之)。

由于是vite,这里使用了vite-plugin-mock,可以查看mock官方文档vite-plugin-mock官方文档,其中mock官方文档包含模拟数据该如何编写,vite-plugin-mock官方文档包含了如何安装搭建mock,这里就不做赘述了。

echarts

echart如果普通地去用,也没啥问题,我这里只是做了一点点处理。

├─ 📁src
│  ├─ 📁echart
│  │  └─ 📄index.js
│  │  ├─ 📁theme
│  │  │  ├─ 📄customed.json
│  │  │  └─ 📄customed.project.json
│  ├─ 📁hooks
│  │  ├─ 📄echarts.js

按需引入

这里没有使用echart的全局安装,主要是很多组件都用不到,整体也太大了。

src/echart/index.js
// 引入 echarts 核心模块,核心模块提供了 echarts 使用必须要的接口。
import * as echarts from 'echarts/core'
import customedTheme from './theme/customed.json'

// 引入柱状图图表,图表后缀都为 Chart
import { BarChart, LineChart, PieChart } from 'echarts/charts'
// 引入提示框,标题,直角坐标系,数据集,内置数据转换器组件,组件后缀都为 Component
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent, GraphicComponent, DataZoomComponent } from 'echarts/components'
// 标签自动布局、全局过渡动画等特性
import { LabelLayout, UniversalTransition } from 'echarts/features'
// 引入 Canvas 渲染器,注意引入 CanvasRenderer 或者 SVGRenderer 是必须的一步
import { CanvasRenderer } from 'echarts/renderers'

// 注册必须的组件
echarts.use([
  TitleComponent,
  TooltipComponent,
  LegendComponent,
  GridComponent,
  GraphicComponent,
  DataZoomComponent,
  BarChart,
  LineChart,
  PieChart,
  LabelLayout,
  UniversalTransition,
  CanvasRenderer
])

echarts.registerTheme('customed', customedTheme)
export default echarts

自定义主题

注意上一个按需引用文件,我使用了自定义主题,这样就不需要对每一个图标都去设置公用的属性。自定义主题是由官网提供的能力。

image.png

这里需要注意的是三个按钮,下载主题是主题文件,直接导入。剩下的导入配置和导出配置是把你的配置项到出来,下次如果在此基础上修改,导出配置之后再次导入就会回滚到你上次用的配置项。

image.png

官网提供的能力一般情况下是够用了,但是针对特殊的情况,可能还不够。这个时候可以打开主题文件,进行修改。如下我给line折线图加了一些自己的属性,这里可以多去尝试一下,哪些属性可以在这里配置全局我也不太清楚,目前99%都是可以的。只是这样之后,导出的配置项基本没啥用了,配置项只会导入主题默认支持的功能。

{
  ...
  "line": {

      "itemStyle": {

          "borderWidth": 1

      },

      "lineStyle": {

          "width": 2

      },

      "symbolSize": 4,

      "symbol": "none",

      "smooth": true

  },
  ...
}

全局hooks

这里还处理过网站主题变化时样式修改调用的问题,tab标签切换下样式问题等。hooks真的好用。

// src/hooks/echarts
import echarts from '@/echarts'
import { debounce } from 'lodash-es'

const useEchartHooks = id => {
  let chart = null
  let options = null

  const renderChart = val => {
    return new Promise((resolve, reject) => {
      options = val
      if (!chart) {
        chart = echarts.init(document.getElementById(id), 'customed')
        resolve(chart)

        // nextTick防止样式未组装完成就开始渲染页面
        nextTick(() => {
          chart.setOption(options, true)
        })
      }

      // nextTick防止样式未组装完成就开始渲染页面
      nextTick(() => {
        chart.setOption(options)
      })
    })
  }

  const state = reactive({
    resize: null
  })

  const sidebarResize = e => {
    if (e.propertyName === 'width') {
      state.resize()
    }
  }

  const initListener = () => {
    state.resize = debounce(() => {
      resize()
    }, 100)
    window.addEventListener('resize', state.resize)

    // 监听侧边菜单栏-宽度
    state.sidebarEle = document.querySelector('.g-sider')
    state.sidebarEle && state.sidebarEle.addEventListener('transitionend', sidebarResize)
  }
  const destroyListener = () => {
    window.removeEventListener('resize', state.resize)
    state.resize = null

    state.sidebarEle && state.sidebarEle.removeEventListener('transitionend', sidebarResize)
  }

  const resize = () => {
    chart && chart.resize()
  }
  onMounted(() => {
    initListener()
  })

  onActivated(() => {
    if (!state.resize) {
      initListener()
    }
  })

  onBeforeUnmount(() => {
    destroyListener()
    chart && chart.dispose()
    chart = null
  })

  onDeactivated(() => {
    destroyListener()
  })
  return { renderChart }
}

export default useEchartHooks

使用示例

<template>
  <div id="trendLine" style="width: 100%; height: 200px"></div>
</template>

<script setup>
import { getTrendData } from '@/api/dasheboard'
import useEchartHooks from '@/hooks/echarts'

const echart = reactive({
  data: [
    { name: '当前', type: 'bar', data: [] },
    { name: '环比', type: 'line', data: [] },
    { name: '同比', type: 'line', data: [] }
  ]
})

const onSearch = () => {
  getTrendData(search.form).then(res => {
    echart.data = res.data
    initChart()
  })
}

const { renderChart } = useEchartHooks('earningLine')
const initChart = () => {
  const options = {
    tooltip: {
      trigger: 'axis'
    },
    xAxis: {
      type: 'category',
      data: echart.data.map(item => item.name)
    },
    yAxis: {
      type: 'value'
    },
    series: echart.data.map(item => {
      return {
        name: item.name,
        type: item.type,
        data: item.data
      }
    })
  }
  renderChart(options)
}
</script>

工具类

工具类可以使用开源的lodash-esvueuse这两个,其他不推荐。小型项目其实可以自己封装,取决于自己。

本地缓存

// src/utils/webStorage.js
export function getToken() {
  return sessionStorage.getItem('token') ?? ''
}
export function setToken(token) {
  sessionStorage.setItem('token', token)
}
export function getUserInfo() {
  const data = sessionStorage.getItem('userInfo') ?? '{}'
  return JSON.parse(data)
}
export function setUserInfo(data) {
  const dataStr = JSON.stringify(data || {})
  sessionStorage.setItem('userInfo', dataStr)
}

// 侧边栏开关
export function setSiderCollapse(collapse) {
  sessionStorage.setItem('siderCollapse', collapse)
}
export function getSiderCollapse() {
  const collapse = sessionStorage.getItem('siderCollapse')
  return collapse === 'true'
}

export function clearStore() {
  sessionStorage.clear()
}

数据处理

// src/utils/dealData.js
/**
 * 将树结构数据扁平化处理。
 * @param {Array} tree 原始树
 * @param {string} childrenKey 子节点的键名
 * @returns {Array} 返回扁平化的数组
 */
export function flattenTree(tree, childrenKey = 'children') {
  const result = []

  // 遍历树结构的递归函数
  function traverse(arr) {
    arr.forEach(item => {
      const { [childrenKey]: children, ...data } = item // 解构分离
      result.push(data)
      if (children && children.length) {
        traverse(children)
      }
    })
  }

  traverse(tree)

  return result
}

/**
 * 清洗tree,提取有效字段
 * @param {Array} tree 原始树
 * @param {Function} callback 节点数据处理回调
 * @param {String} childrenKey 子节点原始key值
 * @param {String} newChildrenKey 子节点新key值
 * @returns {Array} 清洗后的树
 */
export function cleanTree(tree, callback, childrenKey = 'children', newChildrenKey = 'children') {
  const traverse = tree => {
    const result = []
    tree.forEach(node => {
      const { [childrenKey]: children, ...item } = node
      const newNode = callback(item)
      if (children && children.length) {
        newNode[newChildrenKey] = traverse(children)
      }
      result.push(newNode)
    })
    return result
  }

  return traverse(tree)
}

文件下载

// src/utils/download.js
/**
 * 导出文件-字节流
 * @param {Object} res 字节流
 * @param {string} fileName 文件名
 */
export function exportFile(res, fileName) {
  const file = res.data
  const name = decodeURI(res.headers.filename)
  // 兼容IE浏览器
  if (!!window.ActiveXObject || 'ActiveXObject' in window) {
    const blob = new Blob([file])
    window.navigator.msSaveOrOpenBlob(blob, fileName)
  } else {
    const objectUrl = window.URL.createObjectURL(new Blob([file]))
    const link = document.createElement('a')
    link.download = fileName || name
    link.href = objectUrl
    link.click()
    setTimeout(() => {
      document.body.removeChild(link)
    }, 3000)
  }
}
/**
 * 下载文件-链接下载
 * @param {string} url 下载地址
 * @param {string} fileName 文件名
 */
export function downFile(url, fileName) {
  const link = document.createElement('a')
  link.style.display = 'none'

  link.href = url
  link.download = fileName

  document.body.appendChild(link)
  link.click()
  setTimeout(() => {
    document.body.removeChild(link)
  }, 3000)
}