初始化项目

使用官方脚手架搭建项目

技术栈使用 ts+vue-router+pinia
eslint+prettier 代码格式化可选,最好也勾上,保持代码风格

npm create vue@latest

Vue.js - The Progressive JavaScript Framework
√ 请输入项目名称: ... vue3-test
√ 是否使用 TypeScript 语法? ... 否 / 是
√ 是否启用 JSX 支持? ... 否 / 是
√ 是否引入 Vue Router 进行单页面应用开发? ... 否 / 是
√ 是否引入 Pinia 用于状态管理? ... 否 / 是
√ 是否引入 Vitest 用于单元测试? ... 否 / 是
√ 是否要引入一款端到端(End to End)测试工具? » 不需要
√ 是否引入 ESLint 用于代码质量检测? ... 否 / 是
√ 是否引入 Prettier 用于代码格式化? ... 否 / 是

配置别名,启动项目

进入目录属性目录文件

cd [project-dir]
npm i

在vite.config.ts 配置别名
但是在这里配置的别名,ts和esliint识别不了ts文件

vite.config.ts

// ...省略代码
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@assets': fileURLToPath(new URL('./src/assets', import.meta.url)),
'@components': fileURLToPath(new URL('./src/components', import.meta.url)),
'@utils': fileURLToPath(new URL('./src/utils', import.meta.url))
}
}
// ...省略代码

tsconfig.app.json里配置别名,这样导入的时候就可以识别ts文件了
如果没有tsconfig.app.jsontsconfig.json里配置一样的

tsconfig.app.json

"compilerOptions": {
"include": ["env.d.ts", "src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts", "src/**/*.tsx"],
// ... 省略代码
"paths": {
"@/*": ["./src/*"],
"@utils/*":["./src/utils/*"]
}
}
// ...省略代码

配置端口和本地代理

在vite可以配置本地代理,这样在本地调试接口就不会出现跨域了

vite.config.ts

resolve:{... } // 省略代码
server: {
port: 8086, // vue服务端口
host: '0.0.0.0',
proxy: {
['/api']: {
target: 'http://127.0.0.1:3000/', // 服务器地址
changeOrigin: true, // 是否改变服务器地址,
rewrite: (path) => path.replace('/api', ''), // 重写路径
}

},
},

可以起一个后台服务测试一下,服务端口默认是3000

npm install koa-generator -g  // 全局安装 kos生成器
koa [demo-name] // 创建项目
cd demo-name // 进入目录
npm i // 安装依赖
npm run start // 启动服务

在前端请求后台服务
请求地址会通过 localhost:8086/api ->转发到后台服务-> localhost:3000

App.vue

<script setup lang="ts">
import { onMounted } from 'vue'
import axios from 'axios'
onMounted(async () => {
const { data } = await axios({
method: 'get',
url: '/api'
})
console.log(data)
})
</script>

全局ts文件类型声明

ts需要写各种类型声明,我们可以放到一个统一的**.d.ts文件下,比如我在src目录下新建一个project.d.ts

project.d.ts

interface Window {
refreshFlag: Boolean;
taskList: axiosAgent.PendingTask[];
utils: {
refreshToken: () => void
}
}

其中axiosAgent.PendingTask 需要用到另一个d.ts文件
为什么不放入一个文件里写呢?
因为在类型声明里使用了import, 就会被当做成一个模块,而不是单纯的类型声明,不会被ts解析
所以需要单独写一个d.ts,声明一个命名空间declare namespace name,并导出

project_axios.d.ts

import { AxiosRequestConfig } from 'axios';
declare namespace axiosAgent {
interface PendingTask {
config: AxiosRequestConfig;
resolve: Function;
}
}
export = axiosAgent;
export as namespace axiosAgent;

配置pinia

store基本配置

在stores里声明需要的状态管理器
通过definStore声明状态管理器
pinia状态管理器只有3个属性: state、getters、actions
state对应状态变量,是个方法,返回一个对象
getters 是状态的计算属性, 是个对象,对象属性可以使用箭头函数
actions 可以同步或异步操作state或其他的actions方法,这里官网没有用箭头函数了

stores/someList.ts

import { defineStore } from 'pinia'
type TState = {
someList:SList,
someCategory:CList
}
const useSomeList = defineStore('someList',{
state:():TState => {
return {
someList:[],
someCategory: []
}
},
getters: {
GET_LIST: ({someList}) => {
return someList
}
},
actions: {
async getList(categoryId:number) {
// 大致逻辑
this.someList = await axios('/api/slist')
},
async getCategory() {
// 大致逻辑
this.someCategory = await axios('/api/clist')
},
async init() {
await this.getCategory()
await Promise.all(this.someCategory.map(async (item:any) => await this.getList(item.cid)))
}
}
})
export default useSomeList

组件中使用

1.引入store的hooks并使用上面的属性

import {useSomeList} from '@/stores/index'
const someStore = useSomeList()
// 获取getter
someStore.GET_LIST
onMounted(async () => {
// 使用上面的action
await someStore.init()
})

store响应式问题

在组件中直接使用store上的state,是没有响应式的,需要通过import { storeToRefs } from 'pinia 转换成响应式,或者转换成computed的也是可以监听的
组件能监听到这里的state变动

app.vue

import {useSomeList} from '@/stores/index'
import { storeToRefs } from 'pinia'
const someStore = useSomeList()
// 组件能监听到这里的属性变动
const { someList, someCategory } = storeToRefs(someStore)
const listState = computed(() => someStore.someList);

组件不能监听到直接解构state的的数据变动

app.vue

import {useSomeList} from '@/stores/index'
const someStore = useSomeList()
// 组件不能监听这里的变动
const { increment } = store

页面测试效果
编写一个组件,遍历不同场景下someStore.someList数据

components/StateTest.vue

<template>
<div class="state-test" :style="`border:1px solid ${props.color}`">
<div>{{ props.title }}</div>
<div class="test" v-for="(item, index) in props.someList" :key="index"
:style="`height: 40px; width: 100px; color:${props.color} ;`">
<div>{{ item.sname }}</div>
</div>
</div>
</template>

<script lang='ts' setup>
const props = defineProps({
someList: { type: Object, default: () => { } },
title: { type: String, default: '' },
color: { type: String, default: '' }
})
</script>

onMounted调用someStore.init()初始化数据,但是数据需要等待五秒才会加载出来,因为一开始的token是错误的,会把请求放入window.taskList,等待五秒之后更新为正确token重新获取数据,axios请求逻辑请观看下一节
组件参数some-list分别传入computedstoreToRefs直接解构直接使用store.state操作后的数据,观察数据变化
app.vue

<template>
<header>
<div class="wrapper">
<state-test color="aquamarine" title="计算属性" :some-list="listState"></state-test>
<state-test color="pink" title="直接解构" :some-list="someList"></state-test>
<state-test color="orange" title="使用storeToRefs" :some-list="someListRef"></state-test>
<state-test color="red" title="直接使用store.state" :some-list="someStore.someList"></state-test>
</div>
</header>
<RouterView />
</template>
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { useSomeList } from '@/stores/index'
import { storeToRefs } from 'pinia'
import StateTest from '@/components/StateTest.vue'
const someStore = useSomeList()
// 组件能监听到这里的属性变动
const { someList:someListRef } = storeToRefs(someStore)
const { someList } = someStore
const listState = computed(() => someStore.someList);
onMounted(async () => {
// 初始化数据
await someStore.init()
})
</script>

配置axios

需求中有个重放请求的逻辑,当token失效的时候,如果会去更新token,当拿到新token的时候,重放刚刚的请求

token失效重试

如果后台返回了过期的状态码,那么就将全局过期标志refreshFlag制为true,通过refreshToken方法重新获取新的token, 并将当前的请求放到taskList队列里等待token更新后,重新请求

utils/request.ts

axios.interceptors.response.use(response => {
// ...省略代码
if (code === RET_ENUM.EXPIRE) {
window.refreshFlag = true
console.error('登陆太过期')
if (!window.taskList) window.taskList = []
// TODO 更新token
window.utils.refreshToken()
return new Promise(resolve => {
window.taskList.push({
config, resolve
})
})
}
return response
})

收集token失效后的请求

如果refreshFlag标志为true,则说明token过期将后续的请求全部收集放入taskList,等待token更新后重新发起请求

utils/request.ts

axios.interceptors.response.use(response => {
const {config} = response
//如果token失效,则收集请求
if(window.refreshFlag) {
if(!window.taskList) window.taskList = []
return new Promosie(resolve => {
window.taskList.push({
config,
resolve
})
})
}
// ...省略代码
})

当token更新后,重放请求

refreshToken方法模拟token更新操作,从config.data里取出请求数据,并更新config.data.token为正确的token,然后从失败的请求队列taskList里并重放刚刚的失败请求, 通过resolve返回的结果

utils/winUtils

import axios from "axios";
window.utils = {
refreshToken: () => {
setTimeout(async () => {
if (window.refreshFlag) {
window.refreshFlag = false
await Promise.all(window.taskList.map(async ({ config, resolve }) => {
const newData =
config.data instanceof Object ? JSON.parse(JSON.stringify(config.data)) : JSON.parse(config.data);
// 重新设置请求token
newData.token = '123456';
config.data = newData;
resolve(await axios(config));
}))

}
}, 5000);
}
}

组件批量传参

有时候传参我们不想单个的传可以使用v-bind={propA:'value2', propB: 'value2'} 批量传参数
增加一个需求,每个软件名字旁边有一个按钮,不同cid类型的软件,按钮风格不一样

StateTest组件再细化一下,里面的文案和按钮部分拆分为SoftButton
通过props传入ghostcolorgetClass方法中判断并改变buttonClass,让button获取对应的样式

components/SoftButton.vue

<template>
<div class="sitem-contain">
<div>{{ props.sname }}</div>
<button :style="buttonClass">下载</button>
</div>
</template>

<script lang='ts' setup>
import { onMounted, ref } from 'vue'

const props = defineProps({
sname: { type: String, default: '' },
ghost: { type: Boolean, default: false },
color: { type: String, default: '' }
})
const buttonClass = ref({})
const getClass = () => {
if (props.ghost === true) {
buttonClass.value = {
backgroundColor: '#FFF',
border: `1px solid ${props.color}`,
color: `${props.color}`
}
return
}
buttonClass.value = {
backgroundColor: `${props.color}`,
border: `1px solid ${props.color}`,
color: '#FFF'
}
}
getClass()
</script>

StateTest将原来的文案部分替换为soft-button组件,并通过v-bind="getTheme(item.cid)判断对应的props值

components/StateTest.vue

<template>
<div class="state-test" :style="`border:1px solid ${props.color}`">
<div>{{ props.title }}</div>
<div class="test" v-for="(item, index) in props.someList" :key="index"
:style="`height: 40px; width: 100px; color:${props.color} ;`">
<soft-button :sname="item.sname" v-bind="getTheme(item.cid)"></soft-button>
</div>
</div>
</template>

<script lang='ts' setup>
import SoftButton from '@/components/SoftButton.vue'
const props = defineProps({
someList: { type: Object, default: () => { } },
title: { type: String, default: '' },
color: { type: String, default: '' }
})
const getTheme = (cid:number) => {
if(cid === 1) {
return {
ghost: true,
color: props.color
}
}
return {
ghost: false,
color: props.color
}
}
</script>

props与toRefs的坑

在给子组件soft-button传参使用的是一个函数,多少有点不雅观,于是想给每个someListitem增加ghostcolor属性,这样就可以直接使用item.ghostitem.color传参了
添加属性的方式有很多,第一种直接替换整个数组

踩坑1

通过toRefs解构props对象,然后再通过watch,每次监听到props.someList有改动的时候,替换该属性,在组件上遍历someListNew

@/components/StateTest.vue

<template>
<div class="state-test" :style="`border:1px solid ${props.color}`">
<!-- 省略代码.... -->
<div class="test" v-for="(item, index) in someListNew" :key="index"
:style="`height: 40px; width: 100px; color:${item.color} ;`">
<soft-button :sname="item.sname" v-bind="{ ghost: item.ghost, color: item.color }"></soft-button>
</div>
<!-- 省略代码.... -->
</div>
</template>
<script lang='ts' setup>
// 修改prop默认声明,使用之前的方式不好找到someList里面元素的属性
type Props = { someList: SList[], title: string, color: string }
const props = defineProps<Props>()
// 通过toRefs解构props
const { someList: someListNew } = toRefs(props)
// 通过watch监听props.someList,记得加上深度监听 deep:true
watch(() => props.someList, () => {
getTheme()
}, {
deep: true
})
// 每次props.someList变动,通过map返回带ghost和color属性的新数组,重新赋值给someListNew
const getTheme = () => {
const newSList = someListNew.value.map((item: SList): any => {
if (item.cid === 1) {
return {
...item,
ghost: true,
color: props.color
}
} else {
return {
...item,
ghost: false,
color: props.color
}
}
})
someListNew.value = newSList
}
</script>

键盘啪啪一顿操作,结果发现页面展示效果和预期的不一样,打开控制台一看,原来props解构出来的都是readonly属性,是不让修改的

踩坑2(不建议)

虽然可以直接去修改item上的属性,通过someListNew.value.map出来的item也是响应式的
但是这样会改动最原始的对象,pinia上的属性导致渲染出来的都是最后加载出来的红色

@/components/StateTest.vue

<template>
<div class="state-test" :style="`border:1px solid ${props.color}`">
<!-- 省略代码.... -->
<div class="test" v-for="(item, index) in someListNew" :key="index"
:style="`height: 40px; width: 100px; color:${item.color} ;`">
<soft-button :sname="item.sname" v-bind="{ ghost: item.ghost, color: item.color }"></soft-button>
</div>
<!-- 省略代码.... -->
</div>
</template>
<script lang='ts' setup>
// -------------省略代码---------------
// 每次props.someList变动,通过map返回带ghost和color属性的新数组,重新赋值给someListNew
const getTheme = () => {
const newSList = someListNew.value.map((item: SList): any => {
if (item.cid === 1) {
item.ghost = true
item.color = props.color
// 不要替换整个item,会失去响应式,item的地址指向也变了,是没有用的
// item = {...item,ghost:true,color:props.color}
return
} else {
item.ghost = false
item.color = props.color
return
}
})
}
</script>

方法

重新通过ref声明一个变量,去接收someListNew.map返回的新数组,记得修改组件遍历对象为mySomeList

@/components/StateTest.vue

<template>
<div class="state-test" :style="`border:1px solid ${props.color}`">
<!-- 省略代码.... -->
<div class="test" v-for="(item, index) in mySomeList" :key="index"
:style="`height: 40px; width: 100px; color:${item.color} ;`">
<soft-button :sname="item.sname" v-bind="{ ghost: item.ghost, color: item.color }"></soft-button>
</div>
<!-- 省略代码.... -->
</div>
</template>
<script lang='ts' setup>
// -------------省略代码---------------
// 通过ref声明一个新的mySomeList属性
const mySomeList = ref<SList[]>([])
// -------------省略代码---------------
// 每次props.someList变动,通过map返回带ghost和color属性的新数组,重新赋值给mySomeList
const getTheme = () => {
const newSList = someListNew.value.map((item: SList): any => {
if (item.cid === 1) {
return {
...item,
ghost: true,
color: props.color
}
} else {
return {
...item,
ghost: false,
color: props.color
}
}
})
mySomeList.value = newSList
}
</script>

组件事件操作

通过上面操作已经可以正常展示组件内容了,现在增加一点交互事件,当点击下载的时候让每个软件item变成灰色,并且按钮变为已下载

soft-button组件在增加一个down下载监听事件,每次点击下载就触发changeColor改变对应的软件的color

@/components/StateTest.vue

<!-- 省略代码.... -->
<!-- 增加down事件,和参数sid -->
<soft-button :sid="item.sid" :sname="item.sname" v-bind="{ ghost: item.ghost, color: item.color }" @down="changeColor"></soft-button>
<!-- 省略代码.... -->
<script lang='ts' setup>
// -------------省略代码---------------
// 如果点击下载就变成灰色
const changeColor = (sid:number) => {
const sitem = mySomeList.value.find(item => item.sid === sid)
if(!sitem) return
sitem.color = 'gray'
}
// -------------省略代码---------------
</script>

soft-button编写触发逻辑,当buttton按钮点击时,监听 click事件来触发down事件
还需要监听props.color,每次变动的时候需要更新样式

<template>
<!-- 省略代码.... -->
<!-- 通过监听click在changeColor出发down事件 -->
<button :style="buttonClass" @click="changeColor">{{ downText }}</button>
<!-- 省略代码.... -->
</template>

<script lang='ts' setup>
import { computed, ref, watch } from 'vue'
// -------------省略代码---------------
// 通过defineEmits获取事件
const emits = defineEmits(['down'])
const props = defineProps({
sid: { type: Number, default: null }, // 增加sid,需要传给down事件来判断是哪个软件
// ....
})
// ....省略代码
const downText = computed((() => props.color === 'gray' ? '已下载' : '下载')) // 通过downText计算属性来切换文案
// 在changeColor吃哦昂触发down事件,并传入参数sid
const changeColor = () => {
if(props.color === 'gray') return
emits('down', props.sid)
}
const getClass = () => {
// 增加已下载样式
if (props.color === 'gray') {
buttonClass.value = {
backgroundColor: '#FFF',
border: `1px solid ${props.color}`,
color: `${props.color}`
}
return
}
// ....省略代码
}
// 通过watch监听props.color,并增加属性 immediate: true,来立即执行getClass函数,不然一开始的样式展示会有问题
watch(()=>props.color, ()=>{
getClass()
},{
immediate: true
})
</script>

setup外的store和router操作

在setup里面我们可以使用store的hook和router的hooks函数

<script setup lang="ts">
import StateTest from '@/components/StateTest.vue'
import {useRouter } from 'vue-router'
const router = useRouter()
// router.push('/home')
const someStore = useSomeList()
</script>

但是在setup外面,router 就不能使用hooks了,比如在axios请求的失败的时候需要跳转,就需要通过createRouter创建的router进行操作了

import router from '@/router/index'
import {useSomeList} from '@/stores/index'
try {

}catch(err){
// 跳转地址
router.push('/home')
// store还是可以正常使用的
const store = useSomeList()
console.log(store.someList)
}

总结

通过上面的练习基本把vue3的流程跑了一遍,简单熟悉了一下props,pinia,axios,router和一些事件操作,由于项目太赶了都是按照以前的经验快速开发,后期还需要多看官方文档巩固vue3的基础概念

参考

vue.js

git仓库

vue-test
vue-test-serve