Day 10|Pinia 状态管理:把散落在各组件的数据,收进一个全局仓库

看看今天效果图:

前几天我们完成了从"手动操作 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() 重建

调试三件套:

  1. 装 Vue.js devtools 浏览器插件 → Pinia 面板能看到每个 store 的 state 实时值、每次 action 调用记录,还能"时间旅行"回退;
  2. 在任何地方 console.log(useCart().$state) 看原始数据;
  3. 用 $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 面板调试

✏️ 动手任务

  1. 给本项目加一个 couponStore (优惠券码 + 折扣率),让购物车合计实时扣减,并在结算时用 $patch 一次性应用。
  2. 把 Day 9 后台的"商品增删改"从组件本地 state 迁进 productStore,体验"任意页面改完,所有页面同时生效"。

📢 明天预告(Day 11)

Axios 请求封装与接口对接 ------今天的数据全是写死的本地数组。明天我们接上真实 HTTP:axios.create 实例、baseURL 与环境切换、请求/响应拦截器(自动带 token、统一错误处理)、abort 取消重复请求、以及 loading 与错误的全局状态联动(正好复用今天的 Pinia)。

相关推荐
好的佩奇4 小时前
Dart 之集合元素
android·flutter·dart
SomeOtherTime4 小时前
Rust所有权(AI回答)
开发语言·后端·rust
Easyhood5 小时前
Android 开发入门实践(十四):Compose 副作用入门与四个常用 API
android·开发语言·kotlin·android jetpack·compose
白杨尚青5 小时前
C++进阶篇(六):map 和 set 的模拟实现——用红黑树封装 mymap/myset(迭代器 + operator[])
开发语言·c++·迭代器·stl·set·map·红黑树
FlightYe6 小时前
音视频修炼之容器与协议(四):RTSP详解
android·linux·ffmpeg·音视频·dash
1-1=07 小时前
Vue 3 模板语
前端·javascript·vue.js
疯华整猫8 小时前
删掉 LeakCanary 那天,我用 Trae 自己撸了一套:最难的不是分析堆,是决定什么时候 dump
android·人工智能·性能优化
风卿8 小时前
看着AI写代码太孤独了,来欣赏星云吧:一个DSH 装饰插件
前端·javascript
牧艺8 小时前
弹层不用再手算坐标了:CSS Anchor Positioning 从原理到可上线
前端·javascript·css
愚坤9 小时前
前端真有意思,JS还能独立做项目?
前端·javascript·github