Vite 项目搭建与Pinia状态管理-- pd的前端笔记
文章目录
-
- [Vite 项目搭建与Pinia状态管理-- pd的前端笔记](#Vite 项目搭建与Pinia状态管理-- pd的前端笔记)
- [Vite 项目搭建与配置](#Vite 项目搭建与配置)
-
- [为什么选 Vite?它比 Webpack 快在哪?](#为什么选 Vite?它比 Webpack 快在哪?)
- [Vite 配置文件结构:`vite.config.ts`](#Vite 配置文件结构:
vite.config.ts) -
- [设置路径别名(`@/` → `src/`)](#设置路径别名(
@/→src/)) - 开发代理(解决跨域)
- 环境变量与模式
- 优化生产构建
- [Vite 的 plugins 机制](#Vite 的 plugins 机制)
- [设置路径别名(`@/` → `src/`)](#设置路径别名(
- Pinia状态管理
-
- 一、为什么需要状态管理?
- [二、Pinia 是什么?为什么取代 Vuex?](#二、Pinia 是什么?为什么取代 Vuex?)
- 三、安装与初始化
- [四、创建第一个 Store:用户状态管理](#四、创建第一个 Store:用户状态管理)
- [五、在组件中使用 Store](#五、在组件中使用 Store)
- 六、持久化插件:刷新后状态不丢失
- [七、多 Store 模块化:购物车示例](#七、多 Store 模块化:购物车示例)
- 八、常见误区与最佳实践
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'),
},
},
开发代理(解决跨域)
- 前端 http://localhost:5173
- 后端 API http://api.example.com
- 浏览器阻止跨域请求!
✅ 解决方案: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 文件,其作用就是:
- 解析 .vue 单文件组件(SFC)
- 把
<script setup>编译成标准 JavaScript - 把
<template>编译成render()函数 - 处理 lang="ts" → 调用 TypeScript 编译器
- 支持热更新(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-loader 或 unplugin-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.json 的 paths |
| 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 中有两种常见的解构,规则完全不同:
- 数组解构(看位置):数组是有序的,解构时严格对应位置。
- 对象解构(看名字):对象是无序的键值对集合,解构时只看键名(Key)是否匹配。
当你写解构代码时,JavaScript 引擎内部执行逻辑如下:
- 查找:去
userStore对象里找有没有叫 user 的键? → 有,取值。 - 查找:去
userStore对象里找有没有叫 isLoggedIn 的键? → 有,取值。 - 忽略:
token、login等其他键? → 没人要,忽略。 - 顺序:无关紧要,因为是靠"名字"找到的,不是靠"第几个"找到的。
为什么要设计成这样?(工程价值)
- 避免"参数爆炸"
- 支持"渐进式开发":今天你只需要
user,明天你需要login方法。你只需要追加,不需要修改原有代码的顺序; - 支持"重命名"(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 |