Vue3 全栈实战第二周:Pinia + Vite + Router + axios 完整记录

Vue3 全栈实战第二周:Pinia + Vite + Router + axios 完整记录

「Vue前端转全栈实战」系列第四篇。第二周在邮件客户端项目里完成了 Pinia 状态管理、Vite 工程化配置、Vue Router 进阶、axios 封装四个模块的实战练习。这篇把知识点、踩坑、代码全部记录下来。


目录


本周项目结构

第二周结束后,项目结构从单文件变成了完整的工程化架构:

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-persistedstatepaths 已经改成了 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')
}

坑:删除邮件后跳回列表,被删的邮件还在

原因是 InboxViewonMounted 每次进入页面都重新调用 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前端转全栈实战」系列持续更新,欢迎关注。 有问题欢迎评论区交流,我会把遇到的问题和解决过程都记录下来。

相关推荐
OpenTiny社区1 小时前
TinyEngine 2.11版本发布:AI 进一步融入画布,Vue 工程可一键转DSL
前端·vue.js·github
用户19883424362213 小时前
在vue中,abortController调用导致的内存泄漏问题
vue.js
大家的林语冰6 小时前
✌️ Deno 2.9 来了,ESM 直接导入 CSS,甚至能开发桌面应用?!
前端·javascript·node.js
HackTwoHub7 小时前
AntiDebug Mcp、AI逆向绕过前端,Hook 加密接口,抓取 Vue 路由漏洞,接入 MCP 让 AI 自动化挖掘前端漏洞
前端·vue.js·人工智能·安全·web安全·网络安全·系统安全
李明卫杭州8 小时前
还在用 Vue2 的 $set?一文吃透 Observable 响应式原理与 Vue3 的 Proxy 重构
vue.js
英勇无比的消炎药8 小时前
层级选择不再手写联动:TinyVue Cascader 级联选择器上手
vue.js
摇滚侠8 小时前
SpringBoot3+Vue3 全套视频教程 61
vue.js
breeze jiang8 小时前
告别等待焦虑:Vue 3 + DeepSeek 实现 AI 打字机流式对话,用户体验提升立竿见影
vue.js·人工智能·ux
Listen·Rain9 小时前
Vue概述
前端·javascript·vue.js