Vue3+TS+Vite 项目搭建笔记(更新中)

发表于:2023-04-03
字数统计:21k 字
阅读时长:52 分钟
阅读量:516

介绍

本章会教你在真实项目中如何搭建 VueRouter、Vuex、pinia、axios、主题切换等,你会见证一个后台管理系统的详细搭建过程。

效果图:

功能:

  • 后台管理系统常用模块
  • 登录加密
  • 多标签页
  • 全局面包屑
  • 国际化
  • 异常处理
  • Utils工具包
  • 可配置的菜单栏徽标
  • 亮色 / 暗色 侧边栏
  • 浅色主题 / 暗黑主题
  • 丰富的个性化配置
  • 可折叠侧边栏
  • 支持内嵌页面
  • 重载当前页面
  • 动态路由支持自动重载
  • 支持多级路由嵌套及菜单栏嵌套
  • 分离路由与菜单设置
  • 富文本编辑器
  • 优秀的持久化存储方案
  • 自定义登录重定向

创建项目

npm init vue@latest

这一指令将会安装并执行 create-vue,它是 Vue 官方的项目脚手架工具。你将会看到一些诸如 TypeScript 和测试支持之类的可选功能提示:

✔ Project name: … vue-ts-vite-demo1
✔ Add TypeScript? … No / Yes
✔ Add JSX Support? … No / Yes
✔ Add Vue Router for Single Page Application development? … No / Yes
✔ Add Pinia for state management? … No / Yes
✔ Add Vitest for Unit Testing? … No / Yes
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? … No / Yes

如果不确定是否要开启某个功能,你可以直接按下回车键选择 No

运行项目

安装依赖

npm install
or
yarn install

启动项目

npm run dev

准备工作

Vite 配置路径别名

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from "path"

export default defineConfig({ 
  plugins: [vue()],
  resolve: {
    alias: {
      "@": resovePath("src"),
      "@views": resovePath("src/views"),
      "@comps": resovePath("src/components"),
      "@imgs": resovePath("src/assets/img"),
      "@icons": resovePath("src/assets/icons"),
      "@utils": resovePath("src/utils"),
      "@plugins": resovePath("src/plugins"),
      "@styles": resovePath("src/assets/styles"),
    }
  },
})

function resovePath(paths) {
  return path.resolve(__dirname, paths)
}

安装Sass

yarn add sass

配置 Sass variable and mixin

export default defineConfig({ 
  plugins: [vue()],
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@import "@styles/variables.scss"; @import "@styles/mixin.scss";`
      }
    }
  }
}

创建 reset.scss 文件(重置HTML样式)

路径:/assets/styles/reset.scss

下载地址:https://www.qiniu.lingchen.kim/reset.scss

然后在 main.js 中 导入 reset.scss 文件

import '@/styles/reset.scss'

VsCode

配置 vue3 代码片段

点击左下角设置->配置用户代码片段->新建全局代码片段->输入名称->填入代码片段

代码片段模版

{
  "Vue3快速生成模板": {
    "prefix": "v3",
    "body": [
      "<template>",
      "\t<div class=\"\">",
      "\t\t$3",
      "\t</div>",
      "</template>\n",
      "<script setup lang='ts'>",
      "import {} from 'vue'",
      "\t$2",
      "</script>\n",
      "<style lang=\"scss\" scoped>",
      "\t$4",
      "</style>"
    ],
    "description": "Vue3.2"
  }
}

最后新建一个空的vue文件,输入 v3 回车就能自动生成模版了,这里只生成了一个最简单的模版,有其他需要新增的可以自己补充。

<template><div class="">
    
  </div>
</template>

<script setup lang='ts'>
import {} from 'vue'
  
</script>

<style lang="scss" scoped>
  
</style>

安装 volar(vue3配套插件)

在 vscode 插件市场里面安装 volar 插件

删除项目不需要的文件

把 assets、components、views 下面没有用的文件删除

VueRouter(路由)

创建 home、login、user 三个页面

配置路由表

import { createRouter, createWebHistory } from 'vue-router'
import Home from '@views/home/index.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'Home',
      component: Home
    }, {
      path: '/user',
      name: 'User',
      component: () => import('@views/user/index.vue')
    }, {
      path: '/login',
      name: 'Login',
      component: () => import('@views/login/index.vue')
    }
  ]
})

export default router

home.vue,其他两个页面一样

<template><div class=""><h1>Home</h1></div>
</template>

<script setup lang='ts'>
import {} from 'vue'
  
</script>

<style lang="scss" scoped>
  
</style>

创建 Navigation 组件

components/layout/Navigation/index.vue

<template><div class="navigation"><ul><li><router-link to="/">Home</router-link></li><li><router-link to="/user">User</router-link></li><li><router-link to="/login">Login</router-link></li></ul></div>
</template>

<script setup lang='ts'>
import {} from 'vue'
  
</script>

<style lang="scss" scoped>
  .navigation {
    ul {
      display: flex;

      li {
        font-size: 18px;
        margin-right: 20px;
      }
    }
  }
</style>

然后在 App.vue 中导入

<template><div class="app"><Navigation></Navigation><RouterView /></div>
</template>

<script setup lang="ts">
import { RouterView } from 'vue-router'
import Navigation from './components/layout/Navigation.vue'
</script>

<style lang="scss" scoped>
.app {
  min-height: 100vh;
}
</style>

到现在就是这样的了


Vuex(状态管理)

如何优雅的编写 Vuex

安装 vuex

yarn add vuex@next --save

使用

在 vue3.0 中 使用 vuex 体验的并不是很好,下面我们会使用 pinal 这个库来帮助我们更优雅的使用 vuex

如果不使用 pinal,可参考官方文档来使用 vuex:

https://next.vuex.vuejs.org/zh/guide/

我这里选择使用 pinal:

介绍

Pinia 是一个用于 Vue 的状态管理库,类似 Vuex, 是 Vue 的另一种状态管理方案

Pinia 始于2019 年 11 月左右,通过 Composition API重新设计 Vue Store 的外观。从那时起,初始原则仍然相同,但Pinia适用于Vue 2和Vue 3,不需要您使用组合API。除了安装和SSR外,API都是一样的,这些文档针对Vue 3,必要时提供有关Vue 2的注释,以便Vue 2和Vue 3用户可以阅读!

为什么使用 pinia

Pinia 是 Vue 的存储库,它允许您跨组件/页面共享状态。如果您熟悉 Composition API,您可能认为您已经可以共享具有简单 export const state = reactive({}) 的全局状态。

关于 Pinia 的其他问题,我在这里不再做过多赘述,如果有其他问题,可以点击下面的链接访问官网

官网:https://pinia.esm.dev

安装 pinia

yarn add pinia

搭建

创建下面的文件

src/store/index.ts

import type { App } from 'vue'
import { createPinia } from 'pinia'
export const store = createPinia()

export function initStore(app: App<Element>) {
  app.use(store)
}

main.ts

import { initStore } from './store'
const app = createApp(App)
initStore(app)
app.mount('#app')

模块化:

store/modules/user.ts

import { defineStore } from 'pinia'
import { LanguageEnum } from '../../enums/appEnum'
import { UserInfo } from '../../types/store';
import { useSettingStore } from './setting'
import { useWorktabStore } from './worktab'

interface UserState {
  language: LanguageEnum     // 语言
  isLogin: boolean           // 是否登录
  info: Partial<UserInfo>    // 用户信息
}

export const useUserStore = defineStore({
  id: 'userStore',
  state: (): UserState => ({
    language: LanguageEnum.CN,
    isLogin: false,
    info: {}
  }),
  getters: {
    getUserInfo(): Partial<UserInfo> {
      return this.info
    },
    getSettingState() {
      return useSettingStore().$state
    },
    getWorktabState() {
      return useWorktabStore().$state
    }
  },
  actions: {
    setUserInfo(info: UserInfo) {
      this.info = info
    }
  }
})

使用:

import { useUserStore } from "../../store/modules/user"
const userStore = useUserStore()

// 获取数据
const isLogin = userStore.isLogin

const language = computed(() => userStore.language)

// 修改 state
userStore.setUserInfo(...)

// 或者使用 $patch 修改
userStore.$patch({
  language: 'CN',
  isLogin: true,
  info: {
    name: 'jack',
    age: '22'
  }
})

// getter
userStore.getUserInfo()

如何操作其他模块?

import { useSettingStore } from './setting'

getters: {
  getSettingState() {
    return useSettingStore().$state
  }
},
actions: {
  initState() {
    let setting = this.getSettingState
  }
}

通过 useSettingStore().glopThemeType 获取 setting 模块中的单个数据

通过 useSettingStore().$state 获取 setting 模块中的所有数据

通过 useSettingStore().setLanguage() 调用 setting 模块中的方法

.env 文件

根目录创建 .env、.env.development、.env.production 三个文件

.env 全局默认配置文件,不论什么环境都会加载合并

.env.development 开发环境下的配置文件

.env.production 生产环境下的配置文件

.env.test 测试环境

我这边暂时不需要使用测试环境,所以 env.test 暂时不创建了

.env

# 端口号
VITE_PORT = 5137

# 网站前缀
VITE_BASE_URL = /art_design_pro/

# API
VITE_API_URL = http://127.0.0.1:8080

.env.development

# 网站前缀
VITE_BASE_URL = /any_design_pro/

# API
VITE_API_URL = http://127.0.0.1:8080

# Delete console
VITE_DROP_CONSOLE = false

.env.production

# 网站前缀
VITE_BASE_URL = /any_design_pro/

# API
VITE_API_URL = http://127.0.0.1:8080

# Delete console
VITE_DROP_CONSOLE = true

env 智能提示

根目录下 env.d.ts,在其他地方使用的时候就能智能提示出我们上面定义的变量了

/// <reference types="vite/client" />


// .env ts 智能提示
interface ImportMetaEnv {
  readonly VITE_APP_TITLE: stringreadonly VITE_REQUEST_BASE_UR: string
}
 
interface ImportMeta {
  readonly env: ImportMetaEnv
}
 
declare module '*.vue' {
  import { DefineComponent } from 'vue'const component: DefineComponent<{}, {}, any>
  export default component
}

.vue文件使用,现在我们可以看到有智能提示了

axios(网络请求)

安装

yarn add axios

封装

src/utils/http/index.ts

import axios, { type AxiosRequestConfig, type AxiosResponse, type AxiosError } from 'axios';
import { getMessage } from './msg';

// 创建 axios 实例
const axiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_URL,  // 请求地址前缀部分timeout: 30000, // 请求超时时间(毫秒)headers: {get: {
      'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8'
    },
    post: {
      'Content-Type': 'application/json;charset=utf-8'
    }
  },
  withCredentials: true,  // 异步请求携带cookietransformRequest: [(data) => {
    data = JSON.stringify(data)
    return data
  }],
  validateStatus() {
    return true
  },
  transformResponse: [(data) => {
    if (typeof data === 'string' && data.startsWith('{')) {
      data = JSON.parse(data)
    }
    return data
  }]
})

// request 拦截器
axiosInstance.interceptors.request.use((config) => {
  //获取token,并将其添加至请求头中let token = localStorage.getItem('token')if (token) {
    config.headers.Authorization = `${token}`
  }
  return config
}, (error) => {
  // 错误抛到业务代码
  error.data = {}
  error.data.msg = '服务器异常,请联系管理员!'return Promise.resolve(error)
})

// response 拦截器
axiosInstance.interceptors.response.use((response: AxiosResponse) => {
  let msg = ''const status = response.status

  if (status < 200 || status >= 300) {
    msg = getMessage(status)
    if (typeof response.data === 'string') {
      response.data = { msg }
    } else {
      response.data.msg = msg
    }
  }
  return response
}, (error) => {
  if (axios.isCancel(error)) {
    console.log('repeated request: ' + error.message)
  } else {
    error.data = {}
    error.data.msg = '请求超时或服务器异常,请检查网络或联系管理员!'// ElMessage.error(error.data.msg)  // ElementMessage 抛出错误
  }
  return Promise.reject(error)
})

// 请求
function request<T = any>(config: AxiosRequestConfig): Promise<T> {
  return new Promise((resolve, reject) => {
    axiosInstance.request({ ...config }).then((
      res: AxiosResponse
    ) => {
      try {
        resolve(res.data);
      } catch (err) {
        reject(err || new Error('request error!'));
      }
    }).catch((e: Error | AxiosError) => {
      if (axios.isAxiosError(e)) {

      }
      reject(e);
    });
  })
}

function get<T = any>(config: AxiosRequestConfig): Promise<T> {
  return request({ ...config, method: 'GET' });
}

function post<T = any>(config: AxiosRequestConfig): Promise<T> {
  return request({ ...config, method: 'POST' });
}

function put<T = any>(config: AxiosRequestConfig): Promise<T> {
  return request({ ...config, method: 'PUT' });
}

function del<T = any>(config: AxiosRequestConfig): Promise<T> {
  return request({ ...config, method: 'DELETE' });
}

export default {
  get,
  post,
  put,
  del
}

src/utils/http/msg.ts

export const getMessage = (status: number) => {
  let msg = ''switch (status) {
    case 400:
      msg = '请求错误(400)'breakcase 401:
      msg = '未授权,请重新登录(401)'breakcase 403:
      msg = '拒绝访问(403)'breakcase 404:
      msg = '请求出错(404)'breakcase 408:
      msg = '请求超时(408)'breakcase 500:
      msg = '服务器错误(500)'breakcase 501:
      msg = '服务未实现(501)'breakcase 502:
      msg = '网络错误(502)'breakcase 503:
      msg = '服务不可用(503)'breakcase 504:
      msg = '网络超时(504)'breakcase 505:
      msg = 'HTTP版本不受支持(505)'breakdefault:
      msg = `连接出错(${status})!`
  }
  return `${msg},请检查网络或联系管理员!`
}

api

src/types/axios.d.ts

// 接口返回最外层数据结构
export interface Result<T = any> {
  code: number;
  data: T;
  error: string,
  msg: string;
}

src/api/loginApi.ts

import request from '@/utils/http'
import type { Result } from '../types/axios'
import type { captchaType } from './model/captchaModel'

// login
export class LoginService {
  static getCaptcha() {
    return request.post<Result<captchaType>>({ 
      url: '/api/captcha' 
    })
  }
}

/src/api/model/captchaModel.ts

export interface captchaType {
  captchaId: string
  picPath: string
}

Vite 配置代理

import { defineConfig, loadEnv } from 'vite'

export default ({ mode }: { mode: string }) => {
  const root = process.cwd()
  const env = loadEnv(mode, root)
  const { VITE_PORT, VITE_API_URL } = env

  return defineConfig({
    server: {
      port: parseInt(VITE_PORT),  // 端口号
      host: true,
      proxy: {
        '/api': {
          target: VITE_API_URL,
          changeOrigin: true,
          rewrite: path => path.replace(/^\/api/, '')
        }
      }
    }
  })
}

页面调用

import { reactive } from 'vue'
import { LoginService } from "../../api/loginApi"
import { onMounted } from 'vue'

const captcha = reactive({
  picPath: '',
  captchaId: ''
})

const getCaptcha = async () => {
  const res = await LoginService.getCaptcha()
  let { picPath, captchaId } = res.data
  captcha.picPath = picPath
  captcha.captchaId = captchaId
  console.log(res.data)
}

onMounted(() => {
  getCaptcha()
})

使用 iconfont

1.下载 iconfont

https://www.iconfont.cn/

2.导入到项目中

将下载的文件导入到项目下/assets/icons/icons目录下

我这边需要多种图标所以在 icons 目录下也创建了一个icons目录,后面会有 element、system 等图标

3.main.ts导入

import "@icons/icons/iconfont.css"                  // 系统提供的图标库
import "@icons/icons/iconfont.js"                   // 彩色图标

4.使用

// 普通图标
<span class="iconfont"></span>

// 彩色图标
<svg class="svg-icon" aria-hidden="true"><use xlink:href="#icon-zhaopian-copy"></use>
</svg>

ElementPlus

安装

yarn add element-plus
yarn add @element-plus/icons-vue

main.ts

import ElementPlus from 'element-plus'              // Element ui
import 'element-plus/dist/index.css'                // Element css
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

app.use(ElementPlus, { locale: zhCn, size: 'default', zIndex: 3000 })

for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

构建导航

之前的配置都是开发项目要准备的东西,从这里开始就进入了项目的正真开发工作了

这一章我们来开发导航栏

TopBar(导航栏)

效果图

创建两个文件

components/layout/TopBar/index.vue

components/layout/TopBar/style.scss

TopBar/index.vue

<template><div class="top-bar"><div class="bar"><div class="left-bar"><i class="menu-btn btn iconfont-sys"></i><i class="refersh-btn btn iconfont-sys"></i><Breadcrumb></Breadcrumb></div><div class="right-bar"><div class="search-wrap"><el-input v-model="searchVal" placeholder="搜索" clearable /></div><div class="user-wrap"><img class="cover" src="@imgs/user/avatar.png" style="float: left" @click="goPage('/user/user')" />

          <el-dropdown @command="goPage"><div class="name"><span>John Snow</span></div><template #dropdown><el-dropdown-menu><el-dropdown-item command="/user/user"><i class="menu-icon iconfont-sys"></i><span class="menu-txt">个人中心</span></el-dropdown-item><el-dropdown-item command="loginOut"><i class="menu-icon iconfont-sys"></i><span class="menu-txt">退出登录</span></el-dropdown-item></el-dropdown-menu></template></el-dropdown></div>

        <div class="screen" @click="fullScreenFun" v-if="!isFullScreen"><i class="iconfont-sys btn"></i></div><div class="screen" @click="exitScreenFun" v-else><i class="iconfont-sys btn"></i></div><div class="notice notice-btn" @click="visibleNotice"><i class="iconfont-sys notice-btn btn"></i><span class="count notice-btn"></span></div>

        <div class="lang" v-if="true"><el-dropdown @command="changeLanguage"><i class="iconfont-sys btn"></i><template #dropdown><el-dropdown-menu><el-dropdown-item command="cn"><span class="menu-txt">中文</span></el-dropdown-item><el-dropdown-item command="en"><span class="menu-txt">English</span></el-dropdown-item></el-dropdown-menu></template></el-dropdown></div><div class="setting-btn" @click="openSetting"><i class="iconfont-sys btn"></i></div></div></div></div>
</template>

<script setup lang='ts'>
import { ref } from 'vue'
import Breadcrumb from '../Breadcrumb/index.vue'
import { fullScreen, exitScreen } from '@/utils/utils'

const searchVal = ref('')
const darkVal = ref(false)
const isFullScreen = ref(false)

const goPage = (url: string) => {

}

const changeLanguage = () => {

}

const openSetting = () => {

}

// 全屏
const fullScreenFun = () => {
  fullScreen()
  isFullScreen.value = true
}

// 退出全屏
const exitScreenFun = () => {
  exitScreen()
  isFullScreen.value = false
}

const visibleNotice = () => {

}
</script>

<style lang="scss" scoped>
@import './style.scss';
</style>

style.scss

.top-bar {
  border-bottom: 1px solid #ccc;

  .bar {
    @include flex-between;

    .iconfont-sys {
      font-size: 19px;
    }

    .btn {
      display: inline-block;
      padding: 0 20px;
      height: 60px;
      line-height: 60px;
      text-align: center;
      cursor: pointer;
      transition: all .2s;

      &:hover {
        color: var(--main-color);
      }
    }

    .left-bar {
      @include flex-between;

      .refersh-btn {
        &:hover {
          animation: rotate180 0.6s;
        }
      }
      
      i {}
    }

    .right-bar {
      @include flex-between;

      .search-wrap {}

      .user-wrap {
        @include flex-between;

        .name {
          cursor: pointer;
        }

        .cover {
          width: 36px;
          height: 36px;
          border-radius: 50%;
          background: #eee;
          margin: 0 15px 0 15px;
          overflow: hidden;
          cursor: pointer;
        }
      }

      .setting-btn {
        &:hover {
          animation: rotate180 0.6s;
        }
      }
    }
  }
}

@keyframes rotate180 {
  0% {
    transform: rotate(0);
  }
  100% {
    transform: rotate(180deg);
  }
}

utils.ts

// 全屏
export function fullScreen(){ 
  let el: any = document.documentElement;
  let rfs = el.requestFullScreen || el.webkitRequestFullScreen || el.mozRequestFullScreen || el.msRequestFullScreen;

  if (rfs) {
    rfs.call(el);
  }
}

//退出全屏
export function exitScreen(){
  let el: any = document;
  let cfs = el.cancelFullScreen || el.webkitCancelFullScreen || el.mozCancelFullScreen || el.exitFullScreen;

  if (cfs) {
    cfs.call(el);
  }
}

// 将hex颜色转成rgb  例如(#F55442, 1)
export function hexToRgba(hex: string, opacity: number) {
  let RGBA = "rgba(" + parseInt("0x" + hex.slice(1, 3)) + "," + parseInt("0x" + hex.slice(3, 5)) + "," + parseInt( "0x" + hex.slice(5, 7)) + "," + opacity + ")";
  return {
      red: parseInt("0x" + hex.slice(1, 3)),
      green: parseInt("0x" + hex.slice(3, 5)),
      blue: parseInt("0x" + hex.slice(5, 7)),
      rgba: RGBA
  }
}

// 将rgb颜色转成hex  例如(24,12,255)
export function rgbToHex(r: any,g: any,b: any) {
  let hex = "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
  return hex
}

// 颜色混合
export function colourBlend(c1: string, c2: string, ratio: any) {
  ratio = Math.max(Math.min(Number(ratio), 1), 0)
  let r1 = parseInt(c1.substring(1, 3), 16)
  let g1 = parseInt(c1.substring(3, 5), 16)
  let b1 = parseInt(c1.substring(5, 7), 16)
  let r2 = parseInt(c2.substring(1, 3), 16)
  let g2 = parseInt(c2.substring(3, 5), 16)
  let b2 = parseInt(c2.substring(5, 7), 16)
  let r: any = Math.round(r1 * (1 - ratio) + r2 * ratio)
  let g: any = Math.round(g1 * (1 - ratio) + g2 * ratio)
  let b: any = Math.round(b1 * (1 - ratio) + b2 * ratio)
  r = ('0' + (r || 0).toString(16)).slice(-2)
  g = ('0' + (g || 0).toString(16)).slice(-2)
  b = ('0' + (b || 0).toString(16)).slice(-2)
  return '#' + r + g + b
}

Breadcrumb(面包屑)

<template><div class="breadcrumb"><ul><li v-for="(item, index) in breadList" :key="index"><span>{{ item.meta.title }}</span><i v-if="index !== breadList.length - 1">/</i></li></ul></div>
</template>

<script setup lang="ts">
import { ref, watch } from "vue"
import { useRoute } from "vue-router"

const route = useRoute()
const breadList: any = ref([])

watch(() => route.path, () => {
  getBreadcrumb()
}, {
  immediate: true
})

function isHome(route: any) {
  return route?.name === "Home"
}

function getBreadcrumb() {
  let { matched } = route
  let list: any = []

  //如果不是首页if (!isHome(matched[0])) {
    matched.map(item => {
      let { path, meta } = item
      list.push(
        {
          path,
          meta
        }
      )
    })
  }
  breadList.value = list
}
</script>

<style lang="scss" scoped>
@import './style.scss';
</style>

style.scss

.breadcrumb {
  margin-left: 10px;

  ul {
    display: flex;

    li {
      font-size: 13px;

      span {
        font-size: 13px;
      }

      i {
        font-size: 13px;
        margin: 0 7px;
      }
    }
  }
}


1/0