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)。

相关推荐
Elastic 中国社区官方博客1 小时前
我们如何通过 70 多个拉取请求,将 OpenTelemetry Java agent 迁移到 invokedynamic
java·大数据·开发语言·elasticsearch·搜索引擎·全文检索·es
tyn1882 小时前
记录本地安装nacos服务的方法及异常
java·开发语言
造火箭2 小时前
安卓不同类型自动化测试介绍
android
步行cgn2 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
传奇开心果编程3 小时前
【Rust入门练中学】第5课:引用与借用
开发语言·rust·引用·编程语言理论·通用编程语言·借用
qetfw3 小时前
Bash 变量:export、位置参数与默认值展开验证
linux·开发语言·chrome·bash
酷炫码神3 小时前
Day 8|Vue3 响应式:告别手动操作 DOM,让数据驱动界面
javascript·vue.js·ecmascript
电子云与长程纠缠3 小时前
UE5魂系游戏开发教程05
开发语言·学习·游戏·ui·ue5
cspttty4 小时前
2027风险管理岗准备路线:数学与应用数学如何补齐金融、SQL与Python
开发语言·python·金融