Day 80 · Pinia 上手 — defineStore:全局状态的正规军
替换周第二刀:Pinia 接管全局数据。今天先建
useDashboardStore——把 Day 65 ScreenStore 的"状态部分"(latest / alarms / tempWindows / connState)迁入 Pinia 的 state,用 getters 替代派生 computed。今天只迁状态,数据源(WS 连接与订阅)明天接——一次只换一个变量。
目录
- 一、安装与创建
- 二、defineStore 三件套:state / getters / actions
- 三、实战:dashboard store
- 四、组件消费:storeToRefs 的必要性
- 五、Devtools:状态可视化调试
- 六、常见坑点
- 七、自测挑战
- 八、总结
一、安装与创建
npm install pinia
// src/main.ts —— 注册 Pinia(在路由之前,顺序无强制但建议状态先行)
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import { router } from "./router";
import "./styles/tokens.css";
createApp(App).use(createPinia()).use(router).mount("#app");
二、defineStore 三件套:state / getters / actions
import { defineStore } from "pinia";
export const useCounterStore = defineStore("counter", {
// state:状态(组件里的 ref 集合)
state: () => ({ count: 0 }),
// getters:派生(组件里的 computed,自动缓存)
getters: {
double: (state) => state.count * 2,
},
// actions:变更与异步(组件里的方法 + watch 副作用的归口)
actions: {
increment() { this.count++; },
},
});
2.1 与组件内响应式的概念映射
| 组件里 | Pinia 里 | 说明 |
|---|---|---|
ref / shallowRef |
state |
状态上移到全局 |
computed |
getters |
派生跟着状态走 |
| 方法 / 异步取数 | actions |
变更的唯一入口 |
大屏视角的心智转换:Week 11 的 useScreenStore 是"每个组件订阅一份镜像";Pinia 是全局唯一状态树,组件直接读——镜像冗余(Day 75 遗留问题)从根上消失。
三、实战:dashboard store
// src/stores/dashboard.ts
import { defineStore } from "pinia";
import type { PanelSnapshot, Alarm } from "../core/types";
/**
* 大屏全局数据中枢(Pinia 版)
* 迁移自 Day 65 ScreenStore 的状态部分 + Day 75 useScreenStore 的镜像
*
* 分层说明:
* state —— 四类状态(原 ScreenStore.state 的字段一一对应)
* getters —— 原生版在各屏手写的派生(平均温/告警排序等)统一收编
* actions —— 今天只放纯状态变更;数据源接入明天进 actions(Day 81)
*
* 性能纪律(延续 Day 72 选型三连):
* 快照/窗口这类"大而整体替换"的数据,赋值动作必须换引用
*/
export const useDashboardStore = defineStore("dashboard", {
state: () => ({
latest: null as PanelSnapshot | null, // 最新快照
alarms: [] as Alarm[], // 告警累积
tempWindows: new Map<string, { ts: number; v: number }[]>(), // 温度窗口
connState: "closed" as "connected" | "connecting" | "closed",
}),
getters: {
/** 平均温度(原 Day 63 面板手写计算 → Day 73 computed → 现在收编为全局派生) */
avgTemp(state): number {
if (!state.latest) return 0;
const ds = state.latest.devices;
return ds.reduce((s, d) => s + d.temperature, 0) / ds.length;
},
/** 活跃告警(未确认)——Day 73 告警派生链的 store 版 */
activeAlarms(state): Alarm[] {
return state.alarms.filter((a) => !a.acked);
},
/** 危险级告警计数(状态灯徽标用) */
dangerCount(): number {
return this.activeAlarms.filter((a) => a.level === "danger").length;
},
},
actions: {
/** 摄入快照:整体替换(换引用——shallowRef 哲学在 Pinia 的延续) */
setSnapshot(snap: PanelSnapshot): void {
this.latest = snap;
// 温度窗口维护(Day 65 pushWindows 逻辑迁移)
for (const d of snap.devices) {
const buf = this.tempWindows.get(d.deviceId) ?? [];
buf.push({ ts: snap.ts, v: d.temperature });
if (buf.length > 120) buf.shift(); // 定长窗口
this.tempWindows.set(d.deviceId, buf);
}
},
/** 告警到达:拷贝追加(保证数组引用变化触发更新——Day 75 坑 4 纪律) */
pushAlarms(incoming: Alarm[]): void {
this.alarms = [...this.alarms, ...incoming].slice(-200); // 上限截断
},
/** 确认告警(屏 A/B 共用动作) */
ackAlarm(id: string): void {
this.alarms = this.alarms.map((a) =>
a.id === id ? { ...a, acked: true } : a
);
},
/** 连接状态变更 */
setConnState(s: "connected" | "connecting" | "closed"): void {
this.connState = s;
},
},
});
注意 state 里的 Map:Pinia 的 state 是 reactive 包装,Map 天然被深度代理。温度窗口每秒 push 两次会触发代理开销——这是已知代价,Day 81 评估后可能改为"窗口放 store 外、getters 暴露只读快照"的优化,先跑通再优化。
四、组件消费:storeToRefs 的必要性
4.1 标准消费姿势
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useDashboardStore } from "../stores/dashboard";
const store = useDashboardStore();
// ✅ 状态与派生:storeToRefs 解构——保持响应性
const { latest, connState } = storeToRefs(store);
const { dangerCount } = storeToRefs(store); // getters 也走这里
// ✅ 方法:直接解构(函数不存在响应性问题)
const { ackAlarm } = store;
</script>
<template>
<ConnIndicator :state="connState" />
<span>{{ dangerCount }} 条危险告警</span>
</template>
4.2 不用 storeToRefs 会怎样
// ❌ 直接解构 state:拿到的是当下值的快照,响应性当场死亡
const { connState } = store;
// connState 从此是 "closed" 这个字符串——store 更新它不变
// ❌ 直接解构 getter:同理
const { dangerCount } = store;
原理:store 实例是一个 reactive 对象,解构 = 把属性值拷出来,与 reactive 解构陷阱(Day 72 坑 1)同源。storeToRefs 把每个字段包成 ref 再交给你,连接不断。
肌肉记忆:state/getters 走 storeToRefs,actions 直接解构。
五、Devtools:状态可视化调试
Pinia 与 Vue Devtools 深度集成(这是它对手写 Store 的降维优势):
Vue Devtools → Pinia 面板:
▸ dashboard store 实时状态树(latest/alarms/connState 当前值)
▸ 时间旅行:点击历史快照回放状态(告警列表的时序调试利器)
▸ 手动触发 actions / 直接改 state(模拟告警不用等服务器)
实战价值:调试"屏 A 徽标没更新"时,先看 Pinia 面板——state 里 dangerCount 变了没?变了 → 组件订阅问题;没变 → actions 没被调。二分定位,一步锁定层位——手写 Store 时代要靠 console.log 硬看。
六、常见坑点
坑 1:直接解构 store 丢响应性
最高频坑(第四节详述)。报错特征:“首屏有值,之后再也不更新”。规范:storeToRefs 管状态与派生。
坑 2:在组件里直接改 store state
store.connState = "connected" 技术上可行(Pinia 默认允许),但变更绕过了 actions 汇聚点,审查时无法追踪"谁改的"。团队规范:变更一律走 actions(保留 Devtools 的变更追踪)。
坑 3:state 里的函数/类实例
WebSocket 实例、定时器句柄不属于状态——放 store 外的模块作用域(Day 81 的分层设计)。state 只放"可序列化的数据",时间旅行与持久化才可用。
坑 4:getters 里访问其他 getters 忘了 this
getters: { dangerCount(state) { return state.activeAlarms... } }——❌ state 上没有 activeAlarms(它是 getter)。访问兄弟 getter 用 this(见第三节 dangerCount 写法),且该 getter 的签名不能带 state 参数(带了 TS 推断 this 类型会错)。
坑 5:Map 状态的响应性误判
直接对 reactive Map 调 set/push,Vue 能追踪(深层代理生效)——但引用没变,若有组件 watch 该 Map 的引用(浅侦听),不会触发。规范:凡希望"变化被感知"的赋值,换引用(第三节的拷贝纪律)。
七、自测挑战
T1 · store 建立 + 消费改造(60 分钟)
完成第三节 store;把 ScreenB 的 useScreenStore() 消费改为 useDashboardStore()(数据暂用 Devtools 手动触发 actions 喂数——store.setSnapshot(mock) 验证链路)。四类状态 + 三 getters 全部上屏。
T2 · 解构陷阱复现(20 分钟)
故意直接解构 const { connState } = store,Devtools 里改 state,观察组件死住不更新;再换 storeToRefs 恢复。坑 1 的肌肉记忆成型。
T3 · Devtools 工作流(30 分钟)
用 Pinia 面板完成一次"模拟告警"调试:手动 pushAlarms 造 3 条告警 → 观察徽标与列表 → 时间旅行回放 → ackAlarm 后列表变化。把操作录屏存档(这是"状态管理调试能力"的证据)。
八、总结
| 环节 | 要点 |
|---|---|
| 三件套 | state=ref 集合 / getters=computed / actions=变更唯一入口 |
| 消费 | storeToRefs 管状态派生,actions 直接解构 |
| 拷贝纪律 | 赋值必换引用(Map/数组同理) |
| Devtools | 状态树 + 时间旅行——二分定位层位 |
| 进度 | 状态已入 store;数据源(WS)明天接入 |
状态树就位但还没有活水。明天把 WS 数据源接进 actions,手写 ScreenStore 完成退役。