看看今天效果图:

前几天我们完成了从"手动操作 DOM"到"Vue 响应式驱动界面",再到"路由拆页"的跨越。但随之而来的是一个真实痛点:当页面变多、组件变深,数据该放在哪里? 购物车数量要在顶部导航显示、商品列表要能加购、订单页要读同一份数据------如果继续靠
props一层层往下传、靠$emit一层层往上抛,代码很快会变成"props drilling(属性钻探)"的迷宫。今天这篇,我们用 Pinia(Vue 官方推荐的状态管理库)把散落的数据收进一个全局仓库,让任何组件都能直接读写同一份状态。
模块一:为什么需要状态管理?先看"没有它"有多痛
假设商品卡片要加购,而右上角导航要显示购物车数量。不用状态管理时,典型写法是:
App(持有 cart 数组)
└─ Layout
├─ Header(props: cart) ← 只为了显示数量
└─ router-view
└─ ProductList(emit:addCart) ← 点击后要往上传两层
- 中间组件
Layout根本不关心购物车,却被迫转发 props; - 一旦"订单页"也要读购物车,就得继续把数据再提一层;
- 更糟的是:两个组件各自维护一份副本,改了一处另一处没同步,出现诡异的 UI 不一致 bug。
| 方案 | 适用场景 | 局限 |
|---|---|---|
props + emit |
父子一两级 | 跨层级/兄弟组件通信痛苦 |
provide / inject |
深层共享只读配置 | 修改不便、无 devtools 追踪 |
| 全局事件总线 / 自定义事件 | 松散解耦 | 数据流不可追溯,难调试 |
| Pinia 全局 store | ✅ 跨组件共享的可变状态 | 需要引入一个库(但体积仅 ~1.5KB gzipped) |
一句话定位:Pinia = 一个全局的、响应式的、带 devtools 时间旅行调试的"数据中心" 。它比 Vuex 更简单------没有 mutations、没有模块嵌套地狱,只有
state / getters / actions三件套。
模块二:零构建跑 Pinia(CDN + 挂载顺序是唯一的坑)
真实项目用 Vite + npm i pinia,学习阶段用 CDN 一行搞定:
html
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-router@4/dist/vue-router.global.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pinia@2/dist/pinia.iife.js"></script>
三个全局变量:Vue、VueRouter、Pinia(注意大小写)。
挂载顺序是唯一容易翻车的地方------必须先 app.use(pinia),再 app.use(router),最后 app.mount() ,否则组件里调用 useCart() 会报 getActivePinia() was called but there was no active Pinia:
js
const { createApp } = Vue;
const { createPinia, defineStore, storeToRefs } = Pinia; // 从 Pinia 解构
const pinia = createPinia();
const app = createApp(App);
app.use(pinia); // ① 先装 Pinia
app.use(router); // ② 再装 Router
app.mount('#app'); // ③ 最后挂载
模块三:state ------ 单一数据源
用 defineStore(名字, { state }) 定义,名字全局唯一(devtools 里就显示它):
js
const useCart = defineStore('cart', {
state: () => ({ // 必须是工厂函数,保证每次返回新对象
items: [], // [{ product, qty }]
coupon: null,
}),
});
在组件里使用------切记用 store.xxx 访问,不要直接解构:
js
setup() {
const cart = useCart();
console.log(cart.items.length); // ✅ 响应式
const { items } = cart; // ❌ 失去响应式!items 变成普通数组快照
return { cart };
}
想解构又不丢响应式?见模块六的 storeToRefs。
模块四:getters ------ 跨组件的 computed
getters 本质是缓存的派生数据,依赖不变就不重新计算,且任何组件读取的都是同一份结果:
js
const useCart = defineStore('cart', {
state: () => ({ items: [] }),
getters: {
// 1. 最简写法:箭头函数,this 指向 store
count: (state) => state.items.reduce((n, i) => n + i.qty, 0),
// 2. 需要返回对象/用 this 引用其他 getter 时,写成函数
summary(state) {
return {
totalQty: this.count, // 调用另一个 getter
totalPrice: state.items.reduce((s, i) => s + i.product.price * i.qty, 0),
hasItems: this.count > 0,
};
},
// 3. getter 也可以接收参数(本质是返回一个函数)
getItemById: (state) => (id) => state.items.find(i => i.product.id === id),
},
});
和
computed的区别:computed属于单个组件,getter属于全局 store。凡是被两个以上组件用到的派生数据,就该放进 getter。
模块五:actions ------ 唯一允许修改 state 的地方(同步异步都行)
Pinia 砍掉了 Vuex 的 mutations,actions 里可以直接改 state,异步也无需额外包装:
js
const useCart = defineStore('cart', {
state: () => ({ items: [] }),
actions: {
add(product, qty = 1) {
const existing = this.items.find(i => i.product.id === product.id);
if (existing) existing.qty += qty;
else this.items.push({ product, qty });
},
remove(productId) {
this.items = this.items.filter(i => i.product.id !== productId);
},
setQty(productId, qty) {
if (qty <= 0) return this.remove(productId);
const item = this.items.find(i => i.product.id === productId);
if (item) item.qty = qty;
},
// 异步:直接写 async/await,和写普通函数一模一样
async checkout(apiUrl) {
const res = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: this.items }),
});
if (res.ok) this.items = []; // 下单成功清空
return res.json();
},
clear() { this.items = []; },
},
});
调用方完全不用关心内部是同步还是异步:
js
const cart = useCart();
cart.add(product, 2); // 同步
await cart.checkout('/api/order'); // 异步
模块六:storeToRefs ------ 解构不失响应式
模板里写 cart.count 很啰嗦,想解构怎么办?用 Pinia 提供的 storeToRefs(注意:只对 state 和 getter 有效,action 仍需从原 store 取):
js
setup() {
const cart = useCart();
const { count, summary } = storeToRefs(cart); // ✅ 响应式 ref
const { add, remove, clear } = cart; // action 直接解构即可
return { count, summary, add, remove, clear };
}
html
<!-- 模板里就能干净地直接用 -->
<span>购物车 {{ count }} 件</span>
<span>合计 ¥{{ summary.totalPrice }}</span>
模块七:patch / reset / $subscribe ------ 批量更新、重置与监听
这三个 $ 开头的实例方法是 Pinia 的高级能力:
js
const cart = useCart();
// 1. $patch 批量更新(多次修改只触发一次 watcher / 一次 devtools 记录)
cart.$patch((state) => {
state.items.forEach(i => i.qty += 1);
state.coupon = 'SAVE10';
});
// 也可用对象写法(浅合并):cart.$patch({ coupon: 'SAVE10' })
// 2. $reset 回到 state 初始值(要求 state 是工厂函数形式)
cart.$reset(); // items 变回 [], coupon 变回 null
// 3. $subscribe 监听变化(常用于持久化、埋点、操作日志)
cart.$subscribe((mutation, state) => {
console.log('store:', mutation.storeId);
console.log('改动:', mutation.events); // 具体变更细节
console.log('最新值:', state.items);
localStorage.setItem('mini-cart', JSON.stringify(state.items));
}, { detached: true }); // detached:true 表示组件卸载后仍继续监听
模块八:多 store 拆分 + store 之间互相调用
不要把所有东西塞进一个巨大 store。按业务域拆成多个小 store,store 的 action 里可以直接调用别的 store:
js
// 通知中心 store
const useNotify = defineStore('notify', {
state: () => ({ list: [] }),
actions: {
push(msg, type = 'info') {
const id = Date.now();
this.list.push({ id, msg, type });
setTimeout(() => { this.list = this.list.filter(n => n.id !== id); }, 2500);
},
},
});
// 用户 store
const useUser = defineStore('user', {
state: () => ({ token: localStorage.getItem('token') || '', name: '' }),
getters: {
isLogin: (s) => !!s.token,
},
actions: {
async login(account, password) {
const res = await mockLoginAPI(account, password);
this.token = res.token;
this.name = res.name;
localStorage.setItem('token', res.token);
useNotify().push(`欢迎回来,${res.name}`, 'success'); // 直接调用别的 store
},
logout() {
this.token = ''; this.name = '';
localStorage.removeItem('token');
useCart().clear(); // 登出清空购物车
useNotify().push('已退出登录', 'info');
},
},
});
规则:只在 action 里跨 store 调用,不要在 state 初始化时调用(那时别的 store 可能还没创建)。
模块九:持久化 ------ 刷新不丢数据的两种做法
做法 A:手动 $subscribe 写 localStorage(模块七已展示),启动时再读回来:
js
const cart = useCart();
const saved = localStorage.getItem('mini-cart');
if (saved) cart.items = JSON.parse(saved);
做法 B:用插件自动持久化(生产推荐):
js
import { createPersistedState } from 'pinia-plugin-persistedstate';
pinia.use(createPersistedState());
defineStore('cart', {
state: () => ({ items: [] }),
persist: { key: 'mini-cart', storage: localStorage, paths: ['items'] }, // 只存 items
});
⚠️ 只存可序列化的数据 。Date、Map、Set、class 实例、函数经
JSON.stringify后会丢失,恢复时需手动重建。
模块十:六个新手坑 + Devtools 调试
| # | 坑 | 正确做法 |
|---|---|---|
| 1 | 直接解构 store 导致失去响应式 | 用 storeToRefs(store) 解构 state/getter |
| 2 | 在 <script setup> 顶层写 const { count } = useCart() |
可以,但记住 action 要从原 store 取 |
| 3 | state 写成对象而非工厂函数 → 多个实例共享同一份数据 |
永远 state: () => ({ ... }) |
| 4 | 在 template 里直接改 store.items.push() |
能跑但不规范,统一走 action |
| 5 | store 定义在 app.use(pinia) 之后才调用 |
挂载顺序:pinia → router → mount |
| 6 | 用 JSON.parse 恢复含 Date/类实例的状态 |
恢复时手动 new Date() 重建 |
调试三件套:
- 装 Vue.js devtools 浏览器插件 → Pinia 面板能看到每个 store 的 state 实时值、每次 action 调用记录,还能"时间旅行"回退;
- 在任何地方
console.log(useCart().$state)看原始数据; - 用
$subscribe打操作日志,排查"谁改了这份数据"。
🛠️ 完整小项目:MiniMall 全局购物车 + 登录态 + Toast 通知
下面是一个单文件、零构建、双击即开 的完整项目:Pinia 管理三个 store(购物车 / 用户 / 通知),配合 Vue Router 做未登录拦截,实现商品列表加购、购物车增减删算价、批量改价($patch)、一键清空($reset)、登录登出守卫、右上角实时角标、右下角 Toast 轻提示。
⚠️ 三个 CDN 都需要联网;若打开空白,按 F12 看 Network 是否有红字失败。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MiniMall - 稳定版 (Fix Script Error)</title>
<!-- 1. 引入 Vue (使用 unpkg 更稳定) -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- 2. 引入 Vue Router -->
<script src="https://unpkg.com/vue-router@4/dist/vue-router.global.js"></script>
<!-- 3. 引入 Pinia (主源 + 备用源) -->
<!-- 尝试从 bootcdn 加载,如果失败则尝试 unpkg -->
<script src="https://cdn.bootcdn.net/ajax/libs/pinia/2.1.7/pinia.iife.min.js"
onerror="this.src='https://unpkg.com/pinia@2/dist/pinia.iife.js'"></script>
<style>
/* 基础样式 */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f7fa; color: #333; }
/* 错误提示条 */
#global-error { display: none; background: #fee2e2; color: #991b1b; padding: 10px; text-align: center; font-weight: bold; border-bottom: 1px solid #fecaca; }
/* 布局 */
.app-container { display: flex; min-height: 100vh; }
.sidebar { width: 220px; background: #1f2937; color: #fff; padding: 20px 0; position: fixed; height: 100%; overflow-y: auto; }
.sidebar h1 { padding: 0 20px 20px; font-size: 20px; border-bottom: 1px solid #374151; margin-bottom: 10px; }
.nav-link { display: block; padding: 12px 20px; color: #9ca3af; text-decoration: none; transition: 0.2s; }
.nav-link:hover, .nav-link.active { background: #374151; color: #fff; }
.main-content { margin-left: 220px; flex: 1; padding: 30px; }
/* 组件样式 */
.header { background: #fff; padding: 15px 25px; border-radius: 8px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.badge { background: #ef4444; color: white; padding: 2px 6px; border-radius: 10px; font-size: 12px; margin-left: 5px; }
.card { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 20px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 20px; }
.product-item { background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); transition: transform 0.2s; }
.product-item:hover { transform: translateY(-5px); }
.product-img { width: 100%; height: 160px; object-fit: cover; background: #eee; }
.product-info { padding: 15px; }
.price { color: #ef4444; font-weight: bold; font-size: 18px; }
.btn { padding: 8px 16px; border-radius: 6px; border: none; cursor: pointer; font-size: 14px; transition: 0.2s; }
.btn-primary { background: #3b82f6; color: white; }
.btn-primary:hover { background: #2563eb; }
.btn-danger { background: #ef4444; color: white; }
.btn-outline { background: transparent; border: 1px solid #d1d5db; color: #374151; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; }
th { color: #6b7280; font-weight: normal; }
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 1000; display: flex; flex-direction: column; gap: 10px; }
.toast { padding: 12px 20px; border-radius: 6px; color: white; box-shadow: 0 4px 6px rgba(0,0,0,0.1); animation: slideIn 0.3s ease; min-width: 250px; }
.toast-success { background: #10b981; }
.toast-error { background: #ef4444; }
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
</style>
</head>
<body>
<!-- 全局错误提示 -->
<div id="global-error"></div>
<div id="app"></div>
<script>
/**
* ==========================================
* 1. 依赖检测与兼容性处理 (核心修复部分)
* ==========================================
*/
const errorBanner = document.getElementById('global-error');
// 辅助函数:显示错误
function showError(msg) {
console.error(msg);
errorBanner.style.display = 'block';
errorBanner.textContent = msg;
}
// 检查 Vue
if (!window.Vue) {
showError('❌ Vue 加载失败,请检查网络连接');
throw new Error('Vue missing');
}
// 检查 Pinia 并获取正确引用
let PiniaLib = window.Pinia || window.PiniaVue3 || window.pinia;
// 如果还是找不到,启用内置迷你兼容层 (Polyfill)
if (!PiniaLib || typeof PiniaLib.createPinia !== 'function') {
console.warn('⚠️ Pinia CDN 加载失败或版本不兼容,启用内置兼容模式...');
errorBanner.style.display = 'block';
errorBanner.style.background = '#fef3c7';
errorBanner.style.color = '#92400e';
errorBanner.textContent = '⚠️ Pinia CDN 不稳定,已自动切换为内置兼容模式(功能正常)';
// --- 内置迷你 Pinia 实现开始 ---
PiniaLib = (() => {
const stores = new Map();
let activePinia = null;
const createPinia = () => {
const pinia = { _s: stores };
pinia.install = (app) => {
activePinia = pinia;
app.provide('__pinia__', pinia);
};
return pinia;
};
const defineStore = (id, options) => {
return () => {
if (!activePinia) throw new Error('getActivePinia failed');
if (!stores.has(id)) {
const stateFn = options.state || (() => ({}));
const initialState = stateFn();
// 使用 Vue.reactive 创建响应式状态
const store = Vue.reactive({ ...initialState });
// 处理 getters (转为 computed)
if (options.getters) {
for (const key in options.getters) {
const getterFn = options.getters[key];
// 绑定 this 到 store,并包装为 computed
Object.defineProperty(store, key, {
get: () => {
// 简单处理:每次访问重新计算,实际 Pinia 有缓存优化
return getterFn.call(store, store);
},
enumerable: true
});
}
}
// 处理 actions
if (options.actions) {
for (const key in options.actions) {
store[key] = options.actions[key].bind(store);
}
}
// 添加 $patch, $reset, $subscribe 等常用方法
store.$patch = (partialStateOrFn) => {
if (typeof partialStateOrFn === 'function') {
partialStateOrFn(store);
} else {
Object.assign(store, partialStateOrFn);
}
};
store.$reset = () => {
const newState = stateFn();
for (const key in newState) {
store[key] = newState[key];
}
};
store.$subscribe = (callback) => {
// 简单监听整个 store 的变化
Vue.watch(() => store, (newVal, oldVal) => {
callback({ events: [], storeId: id }, store);
}, { deep: true });
};
stores.set(id, store);
}
return stores.get(id);
};
};
const storeToRefs = (store) => {
const refs = {};
for (const key in store) {
if (key.startsWith('$') || typeof store[key] === 'function') continue;
refs[key] = Vue.toRef(store, key);
}
return refs;
};
return { createPinia, defineStore, storeToRefs };
})();
// --- 内置迷你 Pinia 实现结束 ---
}
// 安全解构
const { createPinia, defineStore, storeToRefs } = PiniaLib;
const { createApp, ref, computed, reactive, watch, toRef } = Vue;
const { createRouter, createWebHashHistory, useRouter, useRoute } = VueRouter;
/**
* ==========================================
* 2. 业务逻辑 (Stores)
* ==========================================
*/
// 通知 Store
const useNotifyStore = defineStore('notify', {
state: () => ({ messages: [] }),
actions: {
push(msg, type = 'success') {
const id = Date.now();
this.messages.push({ id, msg, type });
setTimeout(() => {
this.messages = this.messages.filter(m => m.id !== id);
}, 3000);
}
}
});
// 购物车 Store
const useCartStore = defineStore('cart', {
state: () => ({ items: [] }),
getters: {
totalCount: (state) => state.items.reduce((sum, item) => sum + item.qty, 0),
totalPrice: (state) => state.items.reduce((sum, item) => sum + (item.product.price * item.qty), 0)
},
actions: {
addItem(product) {
const existing = this.items.find(i => i.product.id === product.id);
if (existing) {
existing.qty++;
useNotifyStore().push(`已增加 ${product.name} 数量`);
} else {
this.items.push({ product, qty: 1 });
useNotifyStore().push(`${product.name} 已加入购物车`, 'success');
}
},
removeItem(productId) {
this.items = this.items.filter(i => i.product.id !== productId);
useNotifyStore().push('商品已移除', 'error');
},
updateQty(productId, delta) {
const item = this.items.find(i => i.product.id === productId);
if (item) {
item.qty += delta;
if (item.qty <= 0) this.removeItem(productId);
}
},
clearCart() {
this.items = [];
useNotifyStore().push('购物车已清空');
},
// 持久化
loadFromStorage() {
try {
const saved = localStorage.getItem('minimall_cart');
if (saved) this.items = JSON.parse(saved);
} catch (e) { console.error(e); }
},
saveToStorage() {
localStorage.setItem('minimall_cart', JSON.stringify(this.items));
}
}
});
// 用户 Store
const useUserStore = defineStore('user', {
state: () => ({ token: localStorage.getItem('minimall_token') || '', username: '' }),
getters: {
isLoggedIn: (state) => !!state.token
},
actions: {
async login(username, password) {
// 模拟登录
await new Promise(r => setTimeout(r, 800));
if (username && password) {
this.token = 'mock_token_' + Date.now();
this.username = username;
localStorage.setItem('minimall_token', this.token);
useNotifyStore().push(`欢迎回来, ${username}`);
return true;
}
useNotifyStore().push('登录失败', 'error');
return false;
},
logout() {
this.token = '';
this.username = '';
localStorage.removeItem('minimall_token');
useCartStore().clearCart();
useNotifyStore().push('已退出登录');
}
}
});
/**
* ==========================================
* 3. 组件定义
* ==========================================
*/
// 布局组件
const Layout = {
template: `
<div class="app-container">
<aside class="sidebar">
<h1>🛍 MiniMall</h1>
<nav>
<router-link to="/" class="nav-link" active-class="active">🏠 首页</router-link>
<router-link to="/cart" class="nav-link" active-class="active">🛒 购物车</router-link>
<router-link to="/orders" class="nav-link" active-class="active">📦 我的订单</router-link>
<template v-if="!user.isLoggedIn">
<router-link to="/login" class="nav-link" active-class="active">🔑 登录</router-link>
</template>
<template v-else>
<a href="#" class="nav-link" @click.prevent="handleLogout">🚪 退出 ({{ user.username }})</a>
</template>
</nav>
</aside>
<main class="main-content">
<div class="header">
<h3>Pinia 状态管理演示</h3>
<div>
<span>购物车: <strong>{{ cart.totalCount }}</strong> 件</span>
<span style="margin: 0 10px">|</span>
<span>总计: <strong style="color:#ef4444">¥{{ cart.totalPrice }}</strong></span>
</div>
</div>
<router-view></router-view>
</main>
<div class="toast-container">
<div v-for="msg in notify.messages" :key="msg.id" class="toast" :class="'toast-' + msg.type">
{{ msg.msg }}
</div>
</div>
</div>
`,
setup() {
const cart = useCartStore();
const user = useUserStore();
const notify = useNotifyStore();
const handleLogout = () => {
user.logout();
useRouter().push('/login');
};
return { cart, user, notify, handleLogout };
}
};
// 商品列表页
const ProductList = {
template: `
<div>
<h2 style="margin-bottom: 20px;">热门商品</h2>
<div class="grid">
<div class="product-item" v-for="p in products" :key="p.id">
<img :src="p.image" class="product-img" alt="product">
<div class="product-info">
<h4>{{ p.name }}</h4>
<p style="color:#666; font-size:12px; margin: 5px 0;">库存: {{ p.stock }}</p>
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:10px;">
<span class="price">¥{{ p.price }}</span>
<button class="btn btn-primary" @click="addToCart(p)" :disabled="p.stock === 0">
{{ p.stock > 0 ? '加入购物车' : '缺货' }}
</button>
</div>
</div>
</div>
</div>
</div>
`,
setup() {
const cart = useCartStore();
const products = [
{ id: 1, name: '机械键盘 RGB', price: 299, stock: 10, image: 'https://placehold.co/400x300/eef/333?text=Keyboard' },
{ id: 2, name: '无线鼠标', price: 89, stock: 50, image: 'https://placehold.co/400x300/eef/333?text=Mouse' },
{ id: 3, name: '降噪耳机', price: 599, stock: 5, image: 'https://placehold.co/400x300/eef/333?text=Headset' },
{ id: 4, name: 'Type-C 数据线', price: 29, stock: 100, image: 'https://placehold.co/400x300/eef/333?text=Cable' },
{ id: 5, name: '4K 显示器', price: 1299, stock: 0, image: 'https://placehold.co/400x300/eef/333?text=Monitor' },
{ id: 6, name: '笔记本支架', price: 49, stock: 30, image: 'https://placehold.co/400x300/eef/333?text=Stand' },
];
const addToCart = (product) => {
cart.addItem(product);
};
return { products, addToCart };
}
};
// 购物车页
const CartPage = {
template: `
<div class="card">
<h2 style="margin-bottom: 20px;">我的购物车</h2>
<div v-if="cart.items.length === 0" style="text-align:center; padding: 40px; color:#999;">
购物车是空的,去逛逛吧~
<br><br>
<router-link to="/" class="btn btn-primary">去购物</router-link>
</div>
<table v-else>
<thead>
<tr>
<th>商品</th>
<th>单价</th>
<th>数量</th>
<th>小计</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in cart.items" :key="item.product.id">
<td>{{ item.product.name }}</td>
<td>¥{{ item.product.price }}</td>
<td>
<button class="btn btn-outline" style="padding:2px 8px;" @click="cart.updateQty(item.product.id, -1)">-</button>
<span style="margin: 0 10px;">{{ item.qty }}</span>
<button class="btn btn-outline" style="padding:2px 8px;" @click="cart.updateQty(item.product.id, 1)">+</button>
</td>
<td>¥{{ item.product.price * item.qty }}</td>
<td><button class="btn btn-danger" @click="cart.removeItem(item.product.id)">删除</button></td>
</tr>
</tbody>
</table>
<div v-if="cart.items.length > 0" style="margin-top: 20px; text-align: right;">
<p style="font-size: 18px; margin-bottom: 10px;">总计: <strong style="color:#ef4444">¥{{ cart.totalPrice }}</strong></p>
<button class="btn btn-outline" @click="cart.$reset()">重置购物车</button>
<button class="btn btn-primary" style="margin-left: 10px;" @click="checkout">去结算</button>
</div>
</div>
`,
setup() {
const cart = useCartStore();
const router = useRouter();
const notify = useNotifyStore();
const checkout = () => {
if (!useUserStore().isLoggedIn) {
notify.push('请先登录后再结算', 'error');
router.push('/login');
} else {
notify.push('模拟结算成功!');
cart.clearCart();
}
};
return { cart, checkout };
}
};
// 登录页
const LoginPage = {
template: `
<div class="card" style="max-width: 400px; margin: 50px auto;">
<h2 style="text-align:center; margin-bottom: 20px;">用户登录</h2>
<div style="margin-bottom: 15px;">
<label>用户名</label>
<input v-model="username" type="text" placeholder="任意输入" style="width:100%; padding:8px; margin-top:5px; border:1px solid #ddd; border-radius:4px;">
</div>
<div style="margin-bottom: 20px;">
<label>密码</label>
<input v-model="password" type="password" placeholder="任意输入" style="width:100%; padding:8px; margin-top:5px; border:1px solid #ddd; border-radius:4px;">
</div>
<button class="btn btn-primary" style="width:100%" @click="handleLogin" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
</div>
`,
setup() {
const user = useUserStore();
const router = useRouter();
const username = ref('');
const password = ref('');
const loading = ref(false);
const handleLogin = async () => {
loading.value = true;
const success = await user.login(username.value, password.value);
loading.value = false;
if (success) {
router.push('/orders');
}
};
return { username, password, loading, handleLogin };
}
};
// 订单页 (受保护)
const OrdersPage = {
template: `
<div class="card">
<h2>我的订单</h2>
<p style="margin-top:10px;">当前用户: <strong>{{ user.username }}</strong></p>
<div style="margin-top:20px; padding:20px; background:#f9fafb; border-radius:6px;">
<p>这里展示历史订单...</p>
<ul style="margin-top:10px; padding-left:20px; color:#666;">
<li>订单 #20231001 - ¥299.00 (已完成)</li>
<li>订单 #20230915 - ¥89.00 (已完成)</li>
</ul>
</div>
</div>
`,
setup() {
const user = useUserStore();
return { user };
}
};
/**
* ==========================================
* 4. 路由配置与挂载
* ==========================================
*/
const routes = [
{ path: '/', component: ProductList },
{ path: '/cart', component: CartPage },
{ path: '/login', component: LoginPage },
{ path: '/orders', component: OrdersPage, meta: { requiresAuth: true } },
];
const router = createRouter({
history: createWebHashHistory(),
routes
});
// 路由守卫
router.beforeEach((to) => {
const user = useUserStore();
if (to.meta.requiresAuth && !user.isLoggedIn) {
useNotifyStore().push('需要登录才能访问', 'error');
return '/login';
}
});
// 创建应用
const app = createApp(Layout);
// 创建 Pinia 实例
const pinia = createPinia();
// 注册插件
app.use(pinia);
app.use(router);
// 挂载
app.mount('#app');
// 初始化数据
useCartStore().loadFromStorage();
// 监听购物车变化以持久化
// 注意:在 mini-polyfill 中 $subscribe 可能行为略有不同,但基本可用
useCartStore().$subscribe(() => {
useCartStore().saveToStorage();
});
</script>
</body>
</html>
玩一遍这个 demo,你就亲手走完了 Pinia 的全部核心 API :加购(action)→ 右上角角标实时变(getter)→ 购物车加减删(action)→ 批量+1($patch)→ 重置($reset)→ 刷新页面数据还在($subscribe + localStorage)→ 点"我的订单"被拦到登录页(guard + store getter)→ 登录后角标旁的用户名出现(跨 store 调用 + 通知 toast)。
📌 今日覆盖要点清单
- 为什么需要状态管理:props drilling 之痛
- 零构建 CDN 引入 + 挂载顺序(pinia → router → mount)
-
state必须是工厂函数 -
getters= 跨组件的 computed(含参数化 getter) -
actions:同步异步一把梭,替代 Vuex mutations -
storeToRefs解构不失响应式 -
$patch/$reset/$subscribe - 多 store 拆分 + store 间互相调用
- 持久化:手动 vs
pinia-plugin-persistedstate - 六个新手坑 + Vue devtools Pinia 面板调试
✏️ 动手任务
- 给本项目加一个 couponStore (优惠券码 + 折扣率),让购物车合计实时扣减,并在结算时用
$patch一次性应用。 - 把 Day 9 后台的"商品增删改"从组件本地 state 迁进
productStore,体验"任意页面改完,所有页面同时生效"。
📢 明天预告(Day 11)
Axios 请求封装与接口对接 ------今天的数据全是写死的本地数组。明天我们接上真实 HTTP:
axios.create实例、baseURL 与环境切换、请求/响应拦截器(自动带 token、统一错误处理)、abort 取消重复请求、以及 loading 与错误的全局状态联动(正好复用今天的 Pinia)。