Vue3 全栈实战第二周:Pinia + Vite + Router + axios 完整记录
「Vue前端转全栈实战」系列第四篇。第二周在邮件客户端项目里完成了 Pinia 状态管理、Vite 工程化配置、Vue Router 进阶、axios 封装四个模块的实战练习。这篇把知识点、踩坑、代码全部记录下来。
目录
- 本周项目结构
- [Day1-2:Pinia 状态管理](#Day1-2:Pinia 状态管理 "#day1-2pinia-%E7%8A%B6%E6%80%81%E7%AE%A1%E7%90%86")
- [Pinia 是什么](#Pinia 是什么 "#pinia-%E6%98%AF%E4%BB%80%E4%B9%88")
- [Pinia vs Vuex](#Pinia vs Vuex "#pinia-vs-vuex")
- [Store 的三个核心概念](#Store 的三个核心概念 "#store-%E7%9A%84%E4%B8%89%E4%B8%AA%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5")
- storeToRefs:解构保留响应式
- 持久化插件
- 踩坑记录
- [Day3:Vite 工程化配置](#Day3:Vite 工程化配置 "#day3vite-%E5%B7%A5%E7%A8%8B%E5%8C%96%E9%85%8D%E7%BD%AE")
- [Day4:Vue Router 进阶](#Day4:Vue Router 进阶 "#day4vue-router-%E8%BF%9B%E9%98%B6")
- [Day5:axios 封装](#Day5:axios 封装 "#day5axios-%E5%B0%81%E8%A3%85")
- 第二周总结
- 下周计划
本周项目结构
第二周结束后,项目结构从单文件变成了完整的工程化架构:
css
src/
api/
mailApi.ts ← axios API 函数封装
contactApi.ts ← 联系人 API
composables/
useFetch.ts ← 第一周泛型 Composable
practice/
week1.ts ← 第一周练习代码(不参与构建)
router/
index.ts ← 路由配置
stores/
mailStore.ts ← Pinia Store
types/
mail.ts ← 类型定义
utils/
request.ts ← axios 实例
views/
InboxView.vue ← 收件箱页面
MailDetailView.vue ← 邮件详情页
SentView.vue ← 已发送页面
NotFoundView.vue ← 404 页面
env.d.ts ← 环境变量类型声明
Day1-2:Pinia 状态管理
Pinia 是什么
Pinia 是 Vue3 官方推荐的状态管理库,解决的核心问题是组件间共享数据。
邮件客户端场景里,收件箱列表、未读数量、当前选中邮件这些数据需要在多个组件里用到:
markdown
没有 Pinia:
NavBar 需要未读数量
InboxView 需要邮件列表 → 数据分散,props 传来传去很麻烦
MailDetail 需要当前邮件
有 Pinia:
mailStore(全局数据仓库)
↑ ↑ ↑
NavBar InboxView MailDetail → 三个组件直接从 Store 取数据
Pinia vs Vuex
Vuex 需要通过 Mutation 修改数据,多了一层没有意义的中间层:
typescript
// Vuex:改数据要绕一圈
mutations: {
SET_MAILS(state, mails) { state.mails = mails }
},
actions: {
async fetchMails({ commit }) {
const data = await getMails()
commit('SET_MAILS', data) // 不能直接改,要 commit
}
}
// Pinia:直接改,简洁
async function fetchMails() {
mails.value = await getMails() // 直接赋值
}
Pinia 还有一个重要优势------原生 TypeScript 支持:
typescript
// Vuex:类型是 any,没有提示
this.$store.state.mails // any
this.$store.getters.unreadCount // any
// Pinia:类型自动推断
const mailStore = useMailStore()
mailStore.mails // 自动推断 MailV2[]
mailStore.unreadCount // 自动推断 number
Store 的三个核心概念
Pinia Composition 风格的 Store 和 Vue3 组件写法完全一致:
typescript
export const useMailStore = defineStore('mail', () => {
// State = ref / reactive(存数据)
const mails = ref<MailV2[]>([])
const selectedMail = ref<MailV2 | null>(null)
const loading = ref<boolean>(false)
// Getter = computed(基于数据计算)
const unreadCount = computed(() =>
mails.value.filter(mail => !mail.isRead).length
)
const hasUnread = computed(() => unreadCount.value > 0)
// Action = 普通函数(修改数据)
function markAsRead(mailId: number) {
const mail = mails.value.find(m => m.id === mailId)
if (mail) mail.isRead = true
}
function deleteMail(mailId: number) {
mails.value = mails.value.filter(m => m.id !== mailId)
if (selectedMail.value?.id === mailId) {
selectedMail.value = null
}
}
// Composition 风格必须 return,不 return 的组件访问不到
return { mails, selectedMail, loading, unreadCount, hasUnread, markAsRead, deleteMail }
})
为什么必须 return?
Composition 风格的 Store 本质上是一个普通函数,return 决定哪些东西暴露给外部,和 Vue3 组件的 setup() 道理完全一样。不 return 的数据,组件里访问是 undefined。
storeToRefs:解构保留响应式
typescript
const mailStore = useMailStore()
// ❌ 直接解构会失去响应式
const { mails, unreadCount } = mailStore
// mails 变成了普通变量,Store 里的数据变了这里不会更新
// ✅ storeToRefs 解构,保留响应式
const { mails, unreadCount, loading } = storeToRefs(mailStore)
// mails 是 ref,Store 数据变化时自动更新界面
// Action 直接解构,不需要 storeToRefs
const { fetchMails, markAsRead, deleteMail } = mailStore
记住:State 和 Getter 用 storeToRefs,Action 直接解构。
持久化插件
默认情况下刷新页面 Store 数据会清空,用持久化插件存到 localStorage:
bash
pnpm add pinia-plugin-persistedstate
typescript
// main.ts
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// mailStore.ts:开启持久化
export const useMailStore = defineStore('mail', () => {
// ...
}, {
persist: {
key: 'mail-store',
pick: ['mails', 'selectedMail'] // 只持久化这两个字段
}
})
注意:新版本 pinia-plugin-persistedstate 里 paths 已经改成了 pick,用旧写法会报类型错误。
Day1-2 踩坑记录
坑一:computed 有花括号但没有 return
typescript
// ❌ 永远返回 undefined
const unreadCount = computed(() => {
mails.value.filter(mail => !mail.isRead).length // 没有 return!
})
// ✅ 去掉花括号直接返回
const unreadCount = computed(() =>
mails.value.filter(mail => !mail.isRead).length
)
坑二:Store 忘了 return
Composition 风格 Store 定义了 State 和 Action,但忘了最后的 return,组件里访问全是 undefined。每次写完 Store 检查一下最后有没有 return。
坑三:变量拼写错误
selectedMail 写成了 selectdMail(少了一个 e),导致 Store 和组件里的命名不一致。用 VSCode 的 Ctrl+H 全局替换,一次搞定。
Day3:Vite 工程化配置
环境变量
不同环境用不同的配置,在项目根目录新建三个文件:
bash
.env # 所有环境都生效
.env.development # 只在开发环境生效(pnpm dev)
.env.production # 只在生产环境生效(pnpm build)
变量名必须以 VITE_ 开头才能在前端代码里访问:
bash
# .env.development
VITE_API_BASE_URL=http://localhost:3000
VITE_APP_TITLE=邮件客户端(开发版)
在代码里使用:
typescript
const apiUrl = import.meta.env.VITE_API_BASE_URL
const isDev = import.meta.env.DEV // 内置变量,true/false
给环境变量加 TS 类型提示,新建 src/env.d.ts:
typescript
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_APP_TITLE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
代理配置
开发环境跨域问题用 Vite 代理解决:
typescript
// vite.config.ts
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
前端请求 /api/mails,Vite 自动转发到 http://localhost:3000/mails,绕过跨域限制。现在后端还没启动,第9周接入真实后端时直接用这个配置。
构建分析和分包
安装构建分析插件:
bash
pnpm add -D rollup-plugin-visualizer
typescript
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
vue(),
visualizer({ open: true, gzipSize: true }) // 构建完自动打开分析页面
],
build: {
rollupOptions: {
output: {
// 手动分包:Vue 和 Pinia 单独打包,利用浏览器缓存
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('vue') || id.includes('pinia')) {
return 'vue-vendor'
}
}
}
}
}
}
})
构建结果:
bash
dist/assets/index-xxx.js 3.99 kB ← 业务代码,每次更新重新下载
dist/assets/vue-vendor-xxx.js 69.77 kB ← Vue+Pinia,浏览器缓存,不重复下载
分包的好处:更新业务代码时用户只需要重新下载 4KB,而不是 74KB。
Day3 踩坑记录
坑:baseUrl 在新版 TS 里弃用了
tsconfig.app.json 里配置路径映射,baseUrl 在 TypeScript 6.0+ 已经弃用,直接删掉 baseUrl,只保留 paths 就够了:
jsonc
// ❌ 会报警告
{
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
}
// ✅ 直接用 paths
{
"paths": { "@/*": ["./src/*"] }
}
坑:manualChunks 对象写法在新版本报错
typescript
// ❌ 新版本不支持对象写法
manualChunks: { 'vue-vendor': ['vue', 'pinia'] }
// ✅ 改成函数写法
manualChunks(id) {
if (id.includes('node_modules') && (id.includes('vue') || id.includes('pinia'))) {
return 'vue-vendor'
}
}
Day4:Vue Router 进阶
路由配置
typescript
// src/router/index.ts
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', redirect: '/inbox' },
{
path: '/inbox',
name: 'inbox',
component: () => import('@/views/InboxView.vue') // 懒加载
},
{
path: '/mail/:id', // 动态路由
name: 'mail-detail',
component: () => import('@/views/MailDetailView.vue')
},
{
path: '/:pathMatch(.*)*', // 404
name: 'not-found',
component: () => import('@/views/NotFoundView.vue')
}
]
})
懒加载的作用:只有用户访问这个路由时才下载对应组件的代码,首屏加载更快。
动态路由
typescript
// 路由配置:/mail/:id 中的 :id 是动态参数
{ path: '/mail/:id', name: 'mail-detail' }
// 组件里获取参数
const route = useRoute()
const mailId = computed(() => Number(route.params.id))
// 注意:route.params.id 是字符串,需要 Number() 转换
// 用 computed 包裹:URL 变化时自动重新计算
const mail = computed(() =>
mails.value.find(m => m.id === mailId.value) ?? null
)
路由守卫
typescript
// 全局前置守卫:每次跳转前执行
router.beforeEach((to) => {
const isLoggedIn = localStorage.getItem('token')
if (!isLoggedIn && to.name !== 'login') {
return { name: 'login' } // 未登录跳转到登录页
}
})
useRoute vs useRouter
typescript
// useRoute:读(获取当前路由信息)
const route = useRoute()
route.path // 当前路径:'/mail/1'
route.params.id // 路由参数:'1'
route.query.tab // query 参数:'?tab=unread'
// useRouter:写(执行跳转操作)
const router = useRouter()
router.push({ name: 'inbox' }) // 跳转
router.push({ name: 'mail-detail', params: { id: 1 }}) // 带参数跳转
router.back() // 返回上一页
记住:Route 读,Router 写。
Day4 踩坑记录
坑:持久化后 Date 变成字符串
localStorage 只能存字符串,持久化插件序列化时 Date 对象变成了 "2025-01-06T00:00:00.000Z",取出来调用 .toLocaleDateString() 会报错:
vbnet
TypeError: mail.createdAt.toLocaleDateString is not a function
解决:用 new Date() 兼容两种情况:
typescript
function formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString('zh-CN')
}
坑:删除邮件后跳回列表,被删的邮件还在
原因是 InboxView 的 onMounted 每次进入页面都重新调用 fetchMails(),模拟数据覆盖了删除结果。
真实项目里后端会记录删除状态,重新请求拿到的就是删除后的数据,这个问题自然消失。
Day5:axios 封装
axios vs fetch
| 功能 | fetch | axios |
|---|---|---|
| 自动解析 JSON | ❌ 手动 .json() |
✅ 自动 |
| 请求拦截器 | ❌ 没有 | ✅ 有 |
| 响应拦截器 | ❌ 没有 | ✅ 有 |
| 自动携带 token | ❌ 每次手动加 | ✅ 拦截器统一加 |
| 超时设置 | ❌ 需要 AbortController | ✅ 直接配置 |
| 错误处理 | ❌ 手动判断状态码 | ✅ 自动抛出错误 |
请求响应拦截器
typescript
// src/utils/request.ts
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
})
// 请求拦截器:自动携带 token
request.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器:统一处理错误
request.interceptors.response.use(
(response) => response.data, // 直接返回 data,业务代码不用写 .data
(error) => {
const status = error.response?.status
switch (status) {
case 401: localStorage.removeItem('token'); break
case 404: console.error('接口不存在'); break
case 500: console.error('服务器错误'); break
}
return Promise.reject(error)
}
)
API 函数封装
typescript
// src/api/mailApi.ts
export const mailApi = {
async getList(): Promise<MailV2[]> {
return request.get('/mails')
},
async getById(id: number): Promise<MailV2> {
return request.get(`/mails/${id}`)
},
async create(data: CreateMail): Promise<MailV2> {
return request.post('/mails', data)
},
async markAsRead(id: number): Promise<void> {
return request.patch(`/mails/${id}/read`)
},
async delete(id: number): Promise<void> {
return request.delete(`/mails/${id}`)
},
}
// 业务代码只调用函数,不关心请求细节
// 第9周接入后端后,Store 里直接替换:
// mails.value = await mailApi.getList()
整体架构:
markdown
组件(InboxView.vue)
↓ 调用
Store(mailStore.ts)
↓ 调用
API 函数(mailApi.ts)
↓ 调用
axios 实例(request.ts)
↓ 发请求
后端 API
Day5 踩坑记录
坑:响应拦截器返回 response.data 后,业务代码不能再写 .data
响应拦截器里已经 return response.data,如果业务代码里再写 .data,实际上是在取 data.data,结果是 undefined。
第二周总结
这周最大的收获:
项目从一个单文件 App.vue 变成了有完整工程化结构的项目------类型定义、状态管理、路由、请求封装各司其职,每个文件只做一件事。
这种「关注点分离」的思想是工程化的核心,不只是前端,后面学后端也是同样的思路。
掌握的知识点自检:
- Pinia State / Getter / Action 对应 ref / computed / 函数
- storeToRefs 解构保留响应式,Action 直接解构
- Composition 风格 Store 必须 return
- 环境变量 VITE_ 前缀,.env 文件分环境配置
- 路由懒加载 () => import()
- useRoute 读,useRouter 写
- route.params 是字符串,需要 Number() 转换
- axios 拦截器统一处理 token 和错误
- export default 一个文件一个,export 可以多个
下周计划
第三周:Vue3 组件化开发
- 把现有页面拆分成真正的组件
- 组件通信:props、emit、provide/inject
- 插槽 slot
- 自定义指令
「Vue前端转全栈实战」系列持续更新,欢迎关注。 有问题欢迎评论区交流,我会把遇到的问题和解决过程都记录下来。