vue3一天上手二天上线

231 阅读10分钟

初始化项目

由于项目太赶了,拿着vue2的开发经验直接上手vue3踩了一堆坑,写个文档记录一下。

使用官方脚手架搭建项目

技术栈使用 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