Vite 项目搭建与Pinia状态管理

Vite 项目搭建与Pinia状态管理-- pd的前端笔记

文章目录

Vite 项目搭建与配置

为什么选 Vite?它比 Webpack 快在哪?

🔥 核心原理一句话:

Vite 在开发时利用浏览器原生 ES 模块(ESM)能力,实现"按需加载",无需打包整个应用。

对比 Webpack:

能力 Webpack Vite
启动速度 慢(需打包所有模块) ⚡ 极快(只编译当前页面依赖)
热更新(HMR) 全量或部分更新 精确到模块,毫秒级
配置复杂度 高(loader/plugin 体系) 低(插件少而精)
生产构建 自己做 基于 Rollup(更小的 bundle)

💡 举个例子:

你有 100 个组件,但首页只用 5 个。

Webpack:启动时打包全部 100 个 → 慢

Vite:只编译首页用的 5 个 → 快

Vite 配置文件结构:vite.config.ts

默认 vite.config.ts 长这样:

ts 复制代码
import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vueJsx(),
    vueDevTools(),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    },
  },
})
  • defineConfig:提供类型提示和自动补全(来自 Vite 类型定义)
  • plugins: [vue()]:启用 Vue 单文件组件(SFC)支持
    ✅ 最佳实践:始终用 defineConfig 包裹配置,享受 TS 类型安全!

设置路径别名(@/src/

🎯 问题:

写 import MyComp from '.../.../.../components/MyComp.vue' 太啰嗦!

ts 复制代码
resolve: {
alias: {
    '@': fileURLToPath(new URL('./src', import.meta.url))
    '@files': pathResolve('./src/views/fils/src'),
},
},

开发代理(解决跨域)

✅ 解决方案:Vite 开发服务器代理

ts 复制代码
// vite.config.ts
export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: { '@': path.resolve(__dirname, './src') }
  },
  server: {
    port: 5173, // 可选:固定端口
    proxy: {
      '/api': {
        target: 'https://jsonplaceholder.typicode.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
})

🔍 配置说明:

  • 所有以 /api 开头的请求 → 转发到 https://jsonplaceholder.typicode.com
  • changeOrigin: true:修改 Host 头,让后端认为是同源请求
  • rewrite:去掉 /api 前缀(因为真实 API 路径是 /users,不是 /api/users

✅ 在代码中使用:

ts 复制代码
// 原来(跨域)
fetch('https://jsonplaceholder.typicode.com/users/1')

// 现在(走代理,无跨域)
fetch('/api/users/1') // Vite 自动转发!

环境变量与模式

📁 项目根目录创建 .env 文件:

shell 复制代码
# .env.development
VITE_API_BASE_URL=/api

# .env.production
VITE_API_BASE_URL=https://prod-api.example.com

⚠️ 关键规则:

只有以 VITE_ 开头的变量才会暴露给客户端代码(安全!)

在代码中使用:

ts 复制代码
// composables/useFetch.ts
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
const fullUrl = `${baseUrl}/users/${id}`

切换模式:

bash 复制代码
npm run dev          # 加载 .env.development
npm run build        # 加载 .env.production

优化生产构建

ts 复制代码
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: { '@': path.resolve(__dirname, './src') }
  },
  server: {
    proxy: {
      '/api': {
        target: 'https://jsonplaceholder.typicode.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  },
  build: {
    outDir: 'dist', // 输出目录
    sourcemap: true, // 生成 source map(便于调试)
    rollupOptions: {
      output: {
        // 静态资源分类
        assetFileNames: (assetInfo) => {
          if (assetInfo.name?.endsWith('.css')) return 'css/[name]-[hash].[ext]'
          return 'assets/[name]-[hash].[ext]'
        },
        chunkFileNames: 'js/[name]-[hash].js',
        entryFileNames: 'js/[name]-[hash].js'
      }
    }
  }
})

✅ 效果:

  • dist/ 目录结构清晰:js/, css/, assets/
  • 文件带 hash,利于缓存更新

✅ 本篇小结:Vite 配置 Checklist

需求 配置位置 关键代码
路径别名 vite.config.ts + tsconfig.json alias: { '@': ... } + paths
开发代理 server.proxy '/api': { target, rewrite }
环境变量 .env 文件 VITE_XXX + import.meta.env.VITE_XXX
构建优化 build.rollupOptions 分类输出静态资源

Vite 的 plugins 机制

这一节,我们就 彻底讲透 Vite 的 plugins 机制,包括:

✅ 插件到底是什么?

✅ @vitejs/plugin-vue 到底干了啥?

✅ 如何查找、安装、配置常用插件?

✅ 插件顺序重要吗?

✅ 自定义插件长什么样?(简单示例)

🧩 类比理解:

Vite 本身是一个"轻量内核",只处理最基础的 ES 模块加载和开发服务器。

插件(Plugins)就是给它装的"功能模块",比如:

  • .vue 文件支持 → 需要 @vitejs/plugin-vue
  • TypeScript 编译 → 内置支持,但可扩展
  • CSS 预处理器(Sass/Less)→ 需要额外插件
  • 图片压缩、SVG 雪碧图、Mock 数据...... → 各种社区插件

没有插件,Vite 只能处理 .js/.ts/.css 等原生浏览器支持的格式。

@vitejs/plugin-vue:让 Vite 认识 .vue 文件,其作用就是:

  1. 解析 .vue 单文件组件(SFC)
  2. <script setup> 编译成标准 JavaScript
  3. <template> 编译成 render() 函数
  4. 处理 lang="ts" → 调用 TypeScript 编译器
  5. 支持热更新(HMR):改模板/脚本,只更新对应部分

💡 本质上,它把 .vue 文件"翻译"成浏览器能运行的 JS + CSS。

常见插件清单(Vue 3 + TS 项目常用)

功能 插件名称 安装命令 用途
Vue SFC 支持 @vitejs/plugin-vue npm install -D @vitejs/plugin-vue 必装!
JSX 支持 @vitejs/plugin-vue-jsx npm install -D @vitejs/plugin-vue-jsx .tsx 组件
SVG 作为组件 vite-svg-loaderunplugin-vue-components 见下文 直接 import Icon from './icon.svg'
自动生成组件导入 unplugin-vue-components npm install -D unplugin-vue-components import,自动注册组件
路径别名增强 vite-tsconfig-paths npm install -D vite-tsconfig-paths 自动读取 tsconfig.jsonpaths
Mock 数据 vite-plugin-mock npm install -D vite-plugin-mock 开发时模拟 API

Pinia状态管理

一、为什么需要状态管理?

🎯 场景:用户登录状态要在 10 个组件中共享

假设你的应用有:

  • 导航栏(显示用户名)
  • 个人中心(显示用户信息)
  • 权限按钮(根据角色显示/隐藏)
  • 侧边栏(根据权限加载菜单)
  • ......

❌ 不用状态管理的写法(Prop Drilling)

text 复制代码
App.vue (user)
  └─ NavBar.vue (user)
      └─ UserProfile.vue (user)
          └─ UserAvatar.vue (user)  ← 传了 4 层!

每层组件都要:

ts 复制代码
// 父组件
<UserProfile :user="user" />

// 子组件
defineProps<{ user: User }>()

🔴 问题:

  • 代码啰嗦,中间组件只是"透传"
  • 改一个字段,所有层级都要改
  • 难以追踪数据从哪里来

✅ 用状态管理的写法

ts 复制代码
// 任何组件直接读取
const userStore = useUserStore()
const { user, isLoggedIn } = storeToRefs(userStore)

🧠 类比:

Prop Drilling 像"接力赛"------数据一层层传;

Pinia 像"公告板"------谁需要谁来看,不用传。

二、Pinia 是什么?为什么取代 Vuex?

Pinia 是什么?

它是 Vue 3 时代的官方状态管理库,由 Vue 核心团队成员开发,2021 年发布,2022 年被 Vue 官方推荐为 Vuex 的替代品。

名字来源:西班牙语"piña"(菠萝),发音接近"Pinia"

核心优势:

  • 更简洁的 API(无 mutations)
  • 完整的 TypeScript 支持(Vuex 的 TS 支持很痛苦)
  • 支持模块化(每个 store 独立)
  • 体积更小(~1KB)
  • 支持 Vue 2 和 Vue 3

🆚 Pinia vs Vuex 对比

特性 Vuex Pinia
核心概念 state / mutations / actions / getters state / actions / getters
TypeScript 支持 复杂,需要大量类型声明 原生支持,自动推断
模块化 需要手动注册 modules 每个 store 独立文件
DevTools 支持 有(更清晰)
体积 ~22KB ~1KB
Vue 3 推荐 ❌ 不再维护新功能 ✅ 官方推荐

💡 关键区别:Pinia 没有 mutations!

Vuex 中修改 state 必须通过 mutations(同步),actions 调用 mutations。

Pinia 中 actions 直接修改 state,更直观。

三、安装与初始化

第一步:安装 Pinia

bash 复制代码
npm install pinia

第二步:在 main.ts 中注册

ts 复制代码
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia' // 👈 导入
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia() // 👈 创建 Pinia 实例

app.use(pinia) // 👈 注册到 Vue 应用
app.mount('#app')

四、创建第一个 Store:用户状态管理

📁 目录结构建议

text 复制代码
src/
├── stores/
│   ├── user.ts        ← 用户状态
│   ├── cart.ts        ← 购物车状态
│   └── index.ts       ← 统一导出(可选)

📝 创建 src/stores/user.ts

ts 复制代码
// src/stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 定义用户接口
export interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
}

// defineStore 第一个参数是 store 的唯一 ID(必须!)
export const useUserStore = defineStore('user', () => {
  // ========== State(状态)==========
  // 用 ref 定义响应式状态
  const user = ref<User | null>(null)
  const token = ref<string>('')
  const isLoggedIn = computed(() => !!user.value)

  // ========== Getters(计算属性)==========
  // 也可以用 computed 定义
  const userName = computed(() => user.value?.name ?? '访客')
  const isAdmin = computed(() => user.value?.role === 'admin')

  // ========== Actions(动作)==========
  // 同步 action
  function setUser(newUser: User, newToken: string) {
    user.value = newUser
    token.value = newToken
    // 可选:保存到 localStorage
    localStorage.setItem('token', newToken)
  }

  // 异步 action(推荐用 async/await)
  async function login(email: string, password: string): Promise<boolean> {
    try {
      // 模拟 API 请求
      const response = await fetch('https://jsonplaceholder.typicode.com/users/1')
      if (!response.ok) throw new Error('登录失败')
      
      const userData = await response.json()
      const mockUser: User = {
        id: userData.id,
        name: userData.name,
        email: userData.email || email,
        role: 'user'
      }
      
      setUser(mockUser, 'fake-jwt-token')
      return true
    } catch (error) {
      console.error('登录错误:', error)
      return false
    }
  }

  // 登出
  function logout() {
    user.value = null
    token.value = ''
    localStorage.removeItem('token')
  }

  // ========== 返回给组件使用的部分 ==========
  return {
    // state
    user,
    token,
    // getters
    isLoggedIn,
    userName,
    isAdmin,
    // actions
    setUser,
    login,
    logout
  }
})

🔍 关键点解析:

概念 说明
defineStore('user', ...) 第一个参数是 store ID,DevTools 中显示用,必须唯一
ref() 定义响应式 state
computed() 定义 getters(派生状态)
function 定义 actions(修改 state 的逻辑)
return 决定哪些内容暴露给组件

五、在组件中使用 Store

📝 基础用法:读取 state 和 getters

html 复制代码
<!-- components/UserInfo.vue -->
<script setup lang="ts">
import { useUserStore } from '@/stores/user'

// 获取 store 实例
const userStore = useUserStore()

// 直接访问(不推荐用于模板,会失去响应性)
// const { user, isLoggedIn } = userStore

// ✅ 推荐:用 storeToRefs 保持响应性
import { storeToRefs } from 'pinia'
const { user, isLoggedIn, userName, isAdmin } = storeToRefs(userStore)
</script>

<template>
  <div style="padding: 20px; border: 1px solid #ddd">
    <h2>用户信息</h2>
    <div v-if="isLoggedIn">
      <p>欢迎,{{ userName }}!</p>
      <p>角色:{{ isAdmin ? '管理员' : '普通用户' }}</p>
      <button @click="userStore.logout">登出</button>
    </div>
    <div v-else>
      <p>您尚未登录</p>
    </div>
  </div>
</template>

🔍 为什么用 storeToRefs?

ts 复制代码
// ❌ 错误:直接解构会失去响应性
const { user, isLoggedIn } = userStore
// user 变成普通对象,不会随 store 变化而更新

// ✅ 正确:storeToRefs 把 state/getters 转为 ref
const { user, isLoggedIn } = storeToRefs(userStore)
// user 是 Ref<User | null>,模板中自动响应

⚠️ 注意:

  • storeToRefs 只用于 state 和 getters
  • actions 不需要,直接 userStore.login() 即可

📝 调用 actions:登录表单

html 复制代码
<!-- components/LoginForm.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()

const email = ref('')
const password = ref('')
const loading = ref(false)
const errorMsg = ref('')

async function handleLogin() {
  loading.value = true
  errorMsg.value = ''
  
  const success = await userStore.login(email.value, password.value)
  
  if (!success) {
    errorMsg.value = '登录失败,请检查账号密码'
  }
  
  loading.value = false
}
</script>

<template>
  <div style="max-width: 400px; margin: 20px auto; padding: 20px; border: 1px solid #ddd">
    <h2>登录</h2>
    
    <div style="margin: 10px 0">
      <label>Email:</label>
      <input 
        v-model="email" 
        type="email" 
        style="width: 100%; padding: 8px"
        placeholder="输入邮箱"
      />
    </div>
    
    <div style="margin: 10px 0">
      <label>密码:</label>
      <input 
        v-model="password" 
        type="password" 
        style="width: 100%; padding: 8px"
        placeholder="输入密码"
      />
    </div>
    
    <div v-if="errorMsg" style="color: red; margin: 10px 0">{{ errorMsg }}</div>
    
    <button 
      @click="handleLogin" 
      :disabled="loading"
      style="width: 100%; padding: 10px; background: #42b883; color: white; border: none"
    >
      {{ loading ? '登录中...' : '登录' }}
    </button>
  </div>
</template>

对象解构(Destructuring)

const { user, isLoggedIn, userName, isAdmin } = storeToRefs(userStore)注意这里涉及JavaScript/TypeScript 对象解构(Destructuring) 的核心机制。接收返回值的变量可以与return值的顺序不同、数量不同。因为 return 返回的是一个"对象(Object)",而你使用的是"对象解构"语法。对象是根据"键名(Key)"来匹配的,而不是根据"位置"或"数量"。

avaScript 中有两种常见的解构,规则完全不同:

  1. 数组解构(看位置):数组是有序的,解构时严格对应位置。
  2. 对象解构(看名字):对象是无序的键值对集合,解构时只看键名(Key)是否匹配。

当你写解构代码时,JavaScript 引擎内部执行逻辑如下:

  1. 查找:去 userStore 对象里找有没有叫 user 的键? → 有,取值。
  2. 查找:去 userStore 对象里找有没有叫 isLoggedIn 的键? → 有,取值。
  3. 忽略:token、login 等其他键? → 没人要,忽略。
  4. 顺序:无关紧要,因为是靠"名字"找到的,不是靠"第几个"找到的。

为什么要设计成这样?(工程价值)

  1. 避免"参数爆炸"
  2. 支持"渐进式开发":今天你只需要 user,明天你需要 login 方法。你只需要追加,不需要修改原有代码的顺序;
  3. 支持"重命名"(Alias):如果 store 里的名字和你组件里的变量名冲突了,你还可以重命名:
ts 复制代码
// store 里叫 user,我想在组件里叫 currentUser
const { user: currentUser } = userStore

✅ 总结

疑问 答案
为什么变量可以少于输出? 对象解构是"按需分配",没人要的键会被忽略。
为什么位置可以变? 对象是"键名匹配",不是"位置匹配",顺序无关。
这是什么语法? ES6+ 的 对象解构赋值(Object Destructuring)。
有什么要注意的? 解构 state/getters 需用 storeToRefs 保持响应性;actions 直接取用。

六、持久化插件:刷新后状态不丢失

🔎 先认识 pinia-plugin-persistedstate

这是什么?

一个 Pinia 社区插件,自动将 store 状态保存到 localStorage 或 sessionStorage,刷新页面后自动恢复。

典型用途:保持登录状态、主题设置、购物车等。

第一步:安装

bash 复制代码
npm install pinia-plugin-persistedstate

第二步:注册插件

ts 复制代码
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' // 👈 导入
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia()

pinia.use(piniaPluginPersistedstate) // 👈 注册插件

app.use(pinia)
app.mount('#app')

第三步:在 store 中启用持久化

ts 复制代码
// src/stores/user.ts
export const useUserStore = defineStore('user', () => {
  // ... state / getters / actions 同上
  return { user, token, isLoggedIn, userName, isAdmin, setUser, login, logout }
}, {
  // 👇 持久化配置
  persist: {
    key: 'user-store', // localStorage 的键名
    storage: localStorage, // 可选:localStorage 或 sessionStorage
    paths: ['token'] // 只持久化 token,不存 user(敏感信息)
  }
})

七、多 Store 模块化:购物车示例

📝 创建 src/stores/cart.ts

ts 复制代码
// src/stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export interface CartItem {
  id: number
  name: string
  price: number
  quantity: number
}

export const useCartStore = defineStore('cart', () => {
  // State
  const items = ref<CartItem[]>([])

  // Getters
  const totalCount = computed(() => 
    items.value.reduce((sum, item) => sum + item.quantity, 0)
  )
  
  const totalPrice = computed(() => 
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

  // Actions
  function addItem(item: Omit<CartItem, 'quantity'>) {
    const existing = items.value.find(i => i.id === item.id)
    if (existing) {
      existing.quantity++
    } else {
      items.value.push({ ...item, quantity: 1 })
    }
  }

  function removeItem(id: number) {
    items.value = items.value.filter(i => i.id !== id)
  }

  function clearCart() {
    items.value = []
  }

  return {
    items,
    totalCount,
    totalPrice,
    addItem,
    removeItem,
    clearCart
  }
}, {
  persist: {
    key: 'cart-store',
    storage: localStorage
  }
})

📝 在组件中跨 Store 协作

html 复制代码
<!-- components/ProductList.vue -->
<script setup lang="ts">
import { useCartStore } from '@/stores/cart'
import { useUserStore } from '@/stores/user'

const cartStore = useCartStore()
const userStore = useUserStore()

function addToCart(id: number, name: string, price: number) {
  // 检查是否登录
  if (!userStore.isLoggedIn) {
    alert('请先登录')
    return
  }
  
  cartStore.addItem({ id, name, price })
}
</script>

<template>
  <div>
    <h2>商品列表</h2>
    <button @click="addToCart(1, 'iPhone', 5999)">添加 iPhone</button>
    <button @click="addToCart(2, 'MacBook', 9999)">添加 MacBook</button>
    
    <div style="margin-top: 20px">
      <p>购物车:{{ cartStore.totalCount }} 件商品</p>
      <p>总计:¥{{ cartStore.totalPrice }}</p>
    </div>
  </div>
</template>

八、常见误区与最佳实践

误区 正确做法
直接解构 store 失去响应性 storeToRefs 解构 state/getters
在 store 里操作 DOM store 只管理数据,UI 逻辑放组件
所有状态都放 Pinia 局部状态用 ref,全局状态用 Pinia
持久化所有字段 只持久化必要字段(如 token),敏感数据不存
一个巨型 store 按业务模块拆分(user/cart/settings)

✅ Store 拆分原则

场景 建议
用户相关(登录、信息、权限) user.ts
购物车/订单 cart.ts / order.ts
应用配置(主题、语言) settings.ts
缓存数据(列表、详情) cache.ts 或按模块分

✅ 本篇小结

概念 说明
defineStore 创建 store 的核心函数
state ref 定义的响应式数据
getters computed 定义的派生状态
actions 修改 state 的函数(同步/异步)
storeToRefs 解构时保持响应性
持久化插件 pinia-plugin-persistedstate
相关推荐
麦麦大数据3 小时前
F071_vue+flask基于YOLOv8的实时目标检测与追踪系统
vue.js·yolo·目标检测·flask·vue·视频检测
岱宗夫up4 小时前
FastAPI进阶3:云原生架构与DevOps最佳实践
前端·python·云原生·架构·前端框架·fastapi·devops
lyyl啊辉1 天前
1. Vue3简介
vue.js·vue
keyborad pianist1 天前
Web开发 Day1
开发语言·前端·css·vue.js·前端框架
lyyl啊辉1 天前
4. Vue-Router机制
vue
狂龙骄子1 天前
RuoYi-Vue字典标签CSS样式自定义指南
css·前端框架·ruoyi·数据字典·若依·字典标签·样式属性
光影少年2 天前
浏览器渲染原理?
前端·javascript·前端框架
小唛的前端宝库2 天前
【SSR】SSR 到底做了什么(用最简单的方式)
前端框架
容沁风2 天前
react路由Cannot GET错误
前端·react.js·前端框架·sharp7