1 项目搭建
1.1 基础
vue版本:Vue3
打包工具:Vite,需要 Node.js 版本 18+ 或 20+
UI:Vant
编程语言:TypeScript
全局状态管理工具:Vuex
CSS预处理语言:Less
开发工具推荐:vscode
vscode推荐拓展:Vue - Official、Vue 3 Snippets 等
1.2 搭建
通过命令行有两种创建方式,可参考 Vite,此处采用 vue-ts 模板。
npm create vite@latest my-vant-vuex-vite-pro -- --template vue-ts
npm init vite@latest my-vant-vuex-vite-pro -- --template vue-ts
到此,一个项目已经初步搭建完成,安装相关依赖 (如果一步步下来的,记得切换到项目根目录再安装)
npm install --registry=https://registry.npmmirror.***
通过命令行已经可以启动项目了
npm run dev
Tips:在不同的项目中对于 node 可能用的版本不一样,可以安装 node管理工具:nvm
此处不再具体讲解。
2 安装依赖
UI组件:vant
npm i vant
按需引入组件所需依赖:unplugin-vue-***ponents、unplugin-auto-import
npm i @vant/auto-import-resolver unplugin-vue-***ponents unplugin-auto-import -D
基于组合API封装好用的工具函数 :@vueuse/core
npm i @vueuse/core
为TypeScript项目提供Node.js的API的类型定义,从而在开发过程中提供代码补全和类型检查的支持:@types/node
npm i @types/node -D
路由管理工具:vue-router
npm i vue-router
CSS预处理语言:Less
npm install -D less
Tips:如遇到安装依赖不成功的可以通过添加淘宝域名的方式安装,不建议使用 ***pm
在安装命令行后边添加:--registry=https://registry.npmmirror.*** 如:
npm install vuex@next --registry=https://registry.npmmirror.***
3 项目配置
3.1 配置按需引入
在 vite.config.ts 中,我们引入以下依赖
import AutoImport from 'unplugin-auto-import/vite'
import ***ponents from 'unplugin-vue-***ponents/vite'
import { VantResolver } from '@vant/auto-import-resolver'
在 defineConfig 中 的 plugins 下,配置相关的自动导入
plugins: [
vue(),
AutoImport({
// 自动导入 Vue 相关函数,如:ref, reactive, toRef 等
imports: ['vue', 'vue-router', '@vueuse/core'],
// 自动导入 vant 相关函数 API,如:showToast
resolvers: [VantResolver()],
vueTemplate: true,
// 配置文件生成位置(false:关闭自动生成)
dts: false,
}),
***ponents({
// 自动导入 vant 组件
resolvers: [VantResolver()],
// 指定自定义组件位置(默认:src/***ponents)
dirs: ['src/***ponents', 'src/**/***ponents'],
// 配置文件位置 (false:关闭自动生成)
dts: false,
}),
],
完成以上两步,就可以直接在模板中使用 Vant 组件了,unplugin-vue-***ponents 会解析模板并自动注册对应的组件, @vant/auto-import-resolver 会自动引入对应的组件样式。
接下来, 在 src/App.vue中,加入一个 vant 的组件,启动项目,按需引入配置完成。
Tips1:已经配置了按需引入,就不需要再 main.ts中在全局引入了,同时使用可能会导致代码重复、样式错乱等问题。
3.2 配置路由
在 src 文件夹下面,创建 router 文件夹用来配置路由,并在其下面创建 index.ts
在 src 文件夹下面,创建 views 文件夹存放页面,接着在 views文件夹下面创建个 dashboard 文件夹以及 dashboard 下面的 index.vue 作为 测试页面
重新进入到 router/index.ts 中,安装以下进行配置:
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
// 静态路由
export const constantRoutes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/dashboard',
},
{
path: '/dashboard',
name: 'Dashboard',
***ponent: () => import('../views/dashboard/index.vue')
},
]
/**
* 创建路由
*/
const router = createRouter({
history: createWebHashHistory(),
routes: constantRoutes as RouteRecordRaw[],
// 刷新时,滚动条位置还原
scrollBehavior: () => ({ left: 0, top: 0 }),
})
export default router
此处配置的是 hash路由,url 带#号。如需配置 history路由,可使用 createWebHistory,需要服务端配合设置。
改写 main.ts
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
// 引入路由
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')
运行项目,发现页面地址变了,但是内容怎么没变,我们忘了重要的一点:没有 router-view 怎么能渲染路由呢。
在 App.vue 中,去除多余的内容,HTML 部分改写成
<!-- App.vue -->
<template>
<router-view></router-view>
</template>
同样的方法,在 views 下再创建一个 home 页面,作为页面切换使用,同时在 router/index.ts 配置该页面的路由,接下来要实现 dashboard 和 home 页面的跳转与返回
<!-- dashboard.vue -->
<template>
<div>
<van-button type="primary" block @click="toHome">Dashboard</van-button>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
defineOptions({
name: 'Dashboard',
inheritAttrs: false
})
const router = useRouter()
// 跳转到 home 页面
function toHome () {
router.push('/home')
}
</script>
同样的方法,在 home 页面 添加 back 方法,通过 router.back() 返回到 dashboard 页面。
Tips1:使用的是 setup 语法糖,不太熟悉的可移步 <script setup>
Tips2:注意 useRouter 和 useRoute 的区别,useRouter 得到的是动态路由,可通过 push、back等实现页面的跳转,useRoute 得到的是静态路由,可以获取路由的一些静态属性。
Tips3:有的可能已经注意到了在 router/indes.ts 中使用的是相对路径引入的页面模块,在 vue2 中已经习惯了使用 @/ 方式引入,在这直接使用,发现会报错,那么下一章节来介绍下使用 @/ 引入
3.3 配置 @/ 方式引入
在 vite.config.ts 中 设置解析路径,无法 从 path中引入的,请查看下相关依赖是否已安装
@types/node 提供了相关的类型定义,参考 目录2 进行安装
设置 @ 引用的解析路径为 src,通过 @/* 引用 src 文件下的资源
import { resolve } from 'path'
const pathSrc = resolve(__dirname, 'src')
// defineConfig 中添加 解析模块,与 plugins 同级
resolve: {
alias: {
'@': pathSrc,
},
},
配置已经完成,去 router/index.ts里把路径改成 @ 形式,发现修改已经生效,但是 ts 依然报了类型检查的错误。
tsconfig.app.json 中 在 ***pilerOptions 下,加入以下代码解决报红问题
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"allowJs": true,
Tips:此处 加入 allowJs,是允许编译 js 文件,同样也可以加入 checkJs 来检查 js 文件
4 组件样式
4.1 全局Less变量
在 src 下创建 variables.less 作为 Less变量的定义文件,如果在其他地方使用,就得引入这个文件,如何才能全局去使用,而不必每次都引入呢。
打开 vite.config.ts 文件下, defineConfig 下配置 CSS预处理器
css: {
// CSS 预处理器
preprocessorOptions: {
// 定义全局 less 变量
less: {
javascriptEnabled: true,
// 写法一
additionalData: `
@import '${resolve(__dirname, 'src/styles/variables.module.less')}';
`,
// 写法二
// additionalData: `
// @import 'src/styles/variables.less';
// `,
},
},
},
这样全局就可以使用 variables.module.less 下面定义的变量了,这里在文件扩展名前加上 .module 来结合使用 CSS modules 和预处理器可以让我们在 js 中直接获取定义的样式变量
以 variables.module.less,下面贴上定义好的样式
/* 定义样式变量 */
:root {
--theme-color: #42a26a;
--text-color: var(--van-white);
--bg-color: #f2f2f2;
--bg-padding: 10px;
}
:root[theme="dark"] {
--theme-color: var(--van-black);
--text-color: var(--van-white);
--bg-color: var(--van-gray-8);
--bg-padding: 10px;
}
/* 由于 vant 中的主题变量也是在 :root 下声明的,所以在有些情况下会由于优先级的问题无法成功覆盖。通过 :root:root 可以显式地让你所写内容的优先级更高一些,从而确保主题变量的成功覆盖。 */
:root:root {
--van-nav-bar-background: var(--theme-color);
--van-nav-bar-title-text-color: var(--text-color);
--van-nav-bar-icon-color: var(--text-color);
--van-nav-bar-height: 46px;
}
@themeColor: var(--theme-color);
@bgColor: var(--bg-color);
@bgPadding: var(--bg-padding);
:export {
name: 'less';
themeColor: #42a26a;
}
:export 可以导出我们要使用的变量
Tips1:导入以
.module.css为后缀名的 CSS 文件,则获取的是全部的定义的类名,注意这获取到的是样式名,css-loader 默认哈希算法会带有哈希值。 参考:CSS Modules
Tips2:在 variables.module.less 文件中,通过设置 :root[theme="dark"] 、:root[theme="light"] 等不同的样式,可作为主体切换方案:document.documentElement.setAttribute('theme', theme)
4.2 优化页面(仅做为参考)
目标:定义一个父级的页面总结构组件,页面统一使用该组件进行页面布局,可自定义标题、内容、和底部内容
因父级组件和页面自定义标题都会用到标题组件,抽离标题组件,好了,直接上代码
4.2.1 页面组件
<!--
* ***ponents/Layout/index.vue
* @Description: 页面结构组件
-->
<script setup lang="ts">
defineOptions({
name: 'Layout',
inheritAttrs: false
})
const props = defineProps({
// body区域样式
bodyStyle: {
type: Object,
default: {}
}
})
</script>
<template>
<div class="app-page">
<slot name="header">
<Header v-bind="$attrs"/>
</slot>
<div class="app-page-body" :style="props.bodyStyle">
<slot></slot>
</div>
<slot name="footer"></slot>
</div>
</template>
<style lang="less" scoped>
.app-page {
position: relative;
width: 100vw;
height: 100vh;
.app-page-body {
position: absolute;
left: 0;
right: 0;
top: var(--van-nav-bar-height);
bottom: 0;
overflow-y: scroll;
padding: @bgPadding;
}
}
</style>
Tips: 灵活使用 v-bind="$attrs" 可以把父组件所有未声明的 props 传递给子组件,注意只能传递属性,不能传递事件和指令。
4.2.2 标题组件
标题组件是以 vant 中的标题组件为基础,进行的二次封装
<!--
* ***ponents/Header/index.vue
* @Description: 标题栏
-->
<template >
<van-nav-bar v-bind="props" @click-left="back"/>
</template>
<script setup lang="ts">
defineOptions({
name: 'Header',
inheritAttrs: false
})
const router = useRouter()
const props = defineProps({
// 标题
title: {
type: String,
default: ''
},
// 左侧文案
leftText: {
type: String,
default: ''
},
// 右侧文案
rightText: {
type: String,
default: ''
},
// v4.6.8, 是否禁用左侧按钮,禁用时透明度降低,且无法点击
leftDisabled: {
type: Boolean,
default: false
},
// v4.6.8, 是否禁用右侧按钮,禁用时透明度降低,且无法点击
rightDisabled: {
type: Boolean,
default: false
},
// 是否显示左侧箭头
leftArrow: {
type: Boolean,
default: true
},
// 是否显示下边框
border: {
type: Boolean,
default: false
},
// 是否固定在顶部
fixed: {
type: Boolean,
default: true
},
// 固定在顶部时,是否在标签位置生成一个等高的占位元素
placeholder: {
type: Boolean,
default: false
},
// 导航栏 z-index
zIndex: {
type: [Number, String],
default: 1
},
// 是否开启顶部安全区适配
safeAreaInsetTop: {
type: Boolean,
default: true
},
// 是否开启两侧按钮的点击反馈
clickable: {
type: Boolean,
default: false
},
})
function back () {
router.back()
}
</script>
<style lang="less" scoped>
</style>
Tips:注意设置子组件的 inheritAttrs: false,避免父作用域的不被认作props的特性绑定应用在子组件的根元素上
4.3.3 全局引用组件
在 3.1 章节中已经配置了按需引入,在 src 下面的 ***ponents 下的组件会自动按需引入,如果设置了自动生成配置文件,会在根目录生成一个 ***ponents.d.ts,如果没有此文件,可手动添加一个
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-***ponents
// Read more: https://github.***/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface Global***ponents {
Header: typeof import('@/src/***ponents/Header/index.vue')['default']
Layout: typeof import('@/src/***ponents/Layout/index.vue')['default']
}
}
4.3.4 组件使用
<template>
<!-- 没有返回按键 -->
<Layout title="首页" :left-arrow="false">
<!-- body 内容 -->
</Layout>
</template>
<template>
<!-- 默认 -->
<Layout title="个人中心" :left-arrow="false">
<!-- body 内容 -->
</Layout>
</template>
<template>
<!-- 自定义标题 -->
<Layout>
<Header slot="header" title="自定义标题" @click="back"/>
</Layout>
</template>
Tips:自定义标题,在需要改写返回按键、添加右侧按钮等特殊场景中经常用到,同理也可以通过 slot 自定义 footer 区域
4.3.5 页面过滤动画
本章节的最后在附上一份我经常使用的页面切换,App.vue 中
<script setup lang="ts">
import type { RouteRecordNameGeneric } from 'vue-router'
import { useStore } from '@/store'
const router = useRouter()
const store = useStore()
// 本地存储key
const viewPageListName = ref('viewPageList')
// 本地存储
const viewPageList = reactive<RouteRecordNameGeneric[]>([])
// 切换动画
const slideTransition = ref('slide-left')
// 页面缓存
const cacheView = ***puted(() => store.state.view.cacheView)
// 监测路由
watch(
() => router.currentRoute.value,
(to, from) => {
if (to.name === from?.name) return
if (!viewPageList || viewPageList.length === 0) {
let viewPageList = sessionStorage.getItem(viewPageListName.value)
if (viewPageList) {
viewPageList = JSON.parse(viewPageList)
}
}
if (viewPageList.indexOf(to.name) === -1) {
viewPageList.push(to.name)
}
if (viewPageList.indexOf(to.name) > viewPageList.indexOf(from?.name)) {
slideTransition.value = 'slide-left'
} else {
slideTransition.value = 'slide-right'
}
if (viewPageList.indexOf(from?.name) === viewPageList.length - 1) {
viewPageList.splice(viewPageList.indexOf(from?.name), 1)
}
sessionStorage.setItem(viewPageListName.value, JSON.stringify(viewPageList))
},
{ immediate: true }
)
</script>
<template>
<!-- <router-view> can no longer be used directly inside <transition> or <keep-alive>. -->
<router-view v-slot="{ ***ponent }">
<transition :name="slideTransition" mode="in-out">
<keep-alive :include="cacheView">
<***ponent :is="***ponent" />
</keep-alive>
</transition>
</router-view>
</template>
参考:Transition | Vue.js
原理:把页面 name存放缓存中主要是为了区分页面的前进后退关系,好设置页面是前进还是后退动画,mode 设置为 in-out 模式,页面先进后出,配合 Header 的fixed定位,可以实现页面标题不动,只内容切换的效果。
Tips1:vue3中 router-view 标签不能再嵌套在 transition 和 keep-alive 内部了,需注意
Tips2:上文中涉及到了 cacheView 变量来做页面缓存的,需要搭配 vuex 使用,下一章节讲解,这部分报错可暂时去除。
5 全局状态管理工具 - vuex
参考:TypeScript 支持 | Vuex
网上的例子很多,官方也给出了很明确的文档,以官方示例为基础,拆分出 modules 以便于模块化管理,以做页面缓存功能为例:
5.1 目录结构
src 目录下新建 store 目录,其目录结构如下
store/index.ts 作为主入口
store/modules/index.ts 作为模块化管理的主入口,统一往外输出
store/modules/view/index.ts 是本次要添加的页面缓存模块
以后如要加其他模块,则配置在 store/modules 下面
5.2 构建store
5.2.1 store/index.ts
// store/index.ts
import type { InjectionKey, App } from 'vue'
import { createStore, useStore as baseUseStore, Store } from 'vuex'
import modules from './modules'
// 此处声明中 count 是非必须的,如有需要,也可以设置其他的参数
export interface State {
count: number,
[x: string]: any // 解决使用 store.state.view.x 等报红线的问题
}
// InjectionKey 将store安装到Vue应用程序时提供类型,将类型传递InjectionKey给useStore方法
const key: InjectionKey<Store<State>> = Symbol()
const store = createStore<State>({
modules
})
/**
* 对外导出的方法,页面中使用 useStore 不在从 vuex 中引入,而是从 store 目录中,要是怕引起混
* 淆,也可以改用其他名字
* @returns
*/
export function useStore() {
return baseUseStore(key)
}
// 全局注册 store
export function setupStore(app: App) {
app.use(store, key)
}
export default store
5.2.2 store/modules/index.ts
import view from "./view"
// 对外统一的输出
export default { view }
5.2.3 store/modules/view/index.ts
/*
* @Description: 处理页面缓存
*/
import type { RouteRecordNameGeneric } from 'vue-router'
import type { GetterTree, MutationTree, ActionTree } from 'vuex'
import type { State } from '@/store'
export interface ViewState {
cacheView: RouteRecordNameGeneric[], // 存储的页面缓存变量,存的是路由中定义的页面的name
}
const state: ViewState = {
cacheView: []
}
const getters: GetterTree<ViewState, State> = {}
const mutations: MutationTree<ViewState> = {
ADD_CACHE_VIEW: (state: ViewState, view: RouteRecordNameGeneric) => {
if (!state.cacheView.includes(view)) {
state.cacheView.push(view)
}
},
DELETE_CACHE_VIEW: (state: ViewState, view: RouteRecordNameGeneric) => {
const index = state.cacheView.indexOf(view)
if (index > -1) {
state.cacheView.splice(index, 1)
}
}
}
const actions: ActionTree<ViewState, State> = {
addCacheView ({ ***mit } , view: RouteRecordNameGeneric) {
***mit('ADD_CACHE_VIEW', view)
},
deleteCacheView ({ ***mit }, view: RouteRecordNameGeneric) {
***mit('DELETE_CACHE_VIEW', view)
}
}
export default {
namespace: true,
state,
getters,
mutations,
actions
}
跟 vue2 中一样可以定义 state、mutations、actions,只不过涉及到了 ts 对于类型的的检查比较严格,既然使用了 ts 就避免把所有的类型都设为 any,要不然还不如直接使用 js 版本的。
5.2.4 main.ts
在 store/indes.ts中 往外输出了一个构建 store的方法,在 main.ts 中引入并全局注册
import { createApp } from 'vue'
import './styles/index.less'
import App from './App.vue'
import router from './router'
import { setupStore } from './store'
const app = createApp(App)
app.use(router)
setupStore(app)
app.mount('#app')
5.2.5 使用
以 4.3.5 为例,通过与 ***puted 计算属性结合使用来获取到 store中存的值
import { useStore } from '@/store'
const store = useStore()
// 缓存的页面
const cacheView = ***puted(() => store.state.view.cacheView)
// 添加页面缓存
store.dispatch('addCacheView', 'Dashboard')
// 删除页面缓存
store.dispatch('deleteCacheView', 'Dashboard')
// 通过 ***mit 操作,此处仅做为示例
store.***mit('ADD_CACHE_VIEW', 'Dashboard')
store.***mit('DELETE_CACHE_VIEW', 'Dashboard')
Tips:特别注意此处引用的 useStore 是从 store中导出的那个,而不是 vuex 自带的,要是怕混淆,可以改成其他名字。
6 总结
至此,一个简单的项目已经搭建完毕,另涉及到的 接口请求、本地代理等,后续有时间了会补充上,也可根据开发环境自行搭配。
2024.11.14
源码已上传到GitHub,有需要的可以 点这里查看