Lesson 04 · Pinia
src/store/ 下有两个子目录。它们名字都叫 store,
但一个装运行状态,一个装表格字段定义 —— 分不清会一直别扭。
gameboxclient 工作区快照。
1. 一个 ref 明明赋值了,界面却没变。最可能是什么?
答案:B。规则是反的:<script>
里要写 .value,<template>
里不写(Vue 自动拆包)。
这个错误的典型症状就是「不报错,但界面不动」。
2.
<ElButton disabled="false">(注意没有冒号)会发生什么?
答案:C。没有冒号 = 传字符串字面量。
"false" 这个字符串在 JS 里是真值。
「按钮怎么点不动」先检查冒号。
上一课讲了:组件之间传数据靠 props 往下传、emit 往上传。 这在父子之间很好用。
问题是有些数据跟组件树的形状完全无关。 比如「当前登录用户是谁」「当前选的是哪台服务器」—— 顶栏要用,某个深埋在五层组件里的表单也要用。 靠 props 一层层传下去,中间那些完全不关心这个数据的组件也得跟着接一手再传一手。
Pinia 就是把这类数据从组件树里拿出来,放到一个所有组件都够得着的地方。 仅此而已。它不神秘,本质就是一个「带响应式的全局对象」。
对你来说最接近的类比
如果你在 Laya 项目里写过一个 GameData 单例 ——
里面放着当前关卡、玩家属性,全局各处 GameData.instance.hp
这样取 —— 那 Pinia 就是那个单例,外加自动响应式。
区别是:单例里改了 hp,你得手动通知界面; Pinia store
里改了 hp,用到它的组件自动重渲染。
项目里所有 store 都用同一套写法(选项式)。看
src/store/modules/counter.ts,是最短的一个:
import { defineStore } from 'pinia'
import { store } from '../index'
export const useCounter = defineStore('counter', {
state: () => ({ // ① 数据
isCoolingDown: false,
timer: null
}),
getters: {}, // ② 派生数据(相当于 computed)
actions: { // ③ 改数据的方法
setCoolingDown(status) {
this.isCoolingDown = status // ← 用 this
},
startCoolingPeriod() {
this.setCoolingDown(true) // ← action 之间可以互相调
this.timer = setTimeout(() => {
this.setCoolingDown(false)
}, 30000)
}
}
})
| 部分 | 对应组件里的 | 说明 |
|---|---|---|
state |
ref / reactive |
必须写成函数返回对象,不能直接给对象 —— 否则多个实例会共享同一份数据 |
getters |
computed |
会缓存,依赖不变不重算 |
actions |
普通函数 |
可以是 async。在 action 里用 this 访问 state,不需要 .value
|
组件里这么用:
const counter = useCounter()
counter.isCoolingDown // 读,不用 .value
counter.setCoolingDown(true) // 改,调 action
.value?
因为 useCounter() 返回的是一个被 reactive
包过的对象,属性访问会自动解包。
const { isCoolingDown } = useCounter() 解构出来,
会丢掉响应式。需要解构就用
storeToRefs()。这是 Pinia 最常见的一个坑。
useXxxWithOut():本项目最特别的一个约定
你会发现每个 store 文件末尾都跟着一个奇怪的双胞胎。
src/store/modules/GameEvn.ts:40:
export const useGameEvnWithOut = () => {
return useGameEvn(store) // ← 显式把 pinia 实例传进去
}
为什么需要它?
正常的 useGameEvn() 靠 Vue 的「当前活动实例」找到 pinia。
这只在组件的 setup 期间成立。
而这个项目有大量 store 使用发生在组件之外 —— axios
拦截器、路由守卫、api 模块的顶层。那些地方没有「当前组件」, 直接调
useGameEvn() 会报错。 WithOut 版本把 pinia
实例显式传进去,绕开这个限制。
| 你在哪写代码 | 用哪个 |
|---|---|
组件的 <script setup> 里 |
useGameEvn() |
| api 模块、拦截器、路由守卫、工具函数 —— 任何组件之外 |
useGameEvnWithOut()
|
18 个 store 模块里都有这个 WithOut 变体。
记住这条规则,能省掉一个很难懂的运行时报错。
⚠️ 隔离
大量 store 在模块顶层就被实例化。比如
src/config/axios/service.ts:11-13、
src/permission.ts:12-18、
src/api/login/index.ts:15-22, 都是在文件最外层直接
const appStore = useAppStoreWithOut()。
后果:这些 store 在模块被 import 的那一刻就创建了, 时机取决于 import 顺序而不是业务逻辑。
src/main.ts 第一行 import '@/permission'
之所以排在最前面,跟这个有关系。
规范做法是在函数内部按需调用
useXxxWithOut(), 而不是模块顶层。这样 store
的创建时机可控。 见
Pinia · 在组件外使用 store。
你现在该做的:不要动存量(改 import 顺序风险极高)。
但新写的模块,把 useXxxWithOut() 放进函数体里。
store/ 下的两个目录不是一回事
src/store/modules/ ——
19 个文件,这才是真正的 store。
装运行时状态:当前用户、任务队列、路由表、弹窗开关、进度条。
src/store/config/ ——
16 个文件,这些不是状态。
装的是「NPC 表有哪些字段、每个字段该用什么控件、下拉里有哪些选项」——
纯粹的静态描述数据,运行期间从不改变。
为什么这个区分很重要
因为它决定了你改需求时该去哪。
「登录后要多存一个字段」→ 改 modules/,那是状态。
「物品表要加一列」→ 改 config/,那是描述。
第二类需求在这个项目里的占比远高于第一类。
modules/:19 个运行态 store| store | 装什么 | 重要度 |
|---|---|---|
GameEvn.ts |
整个工具的钥匙链。SVN / Jenkins / GM 的地址和凭据 | ★★★ 排障必看 |
table.ts |
把 config/ 下的 schema 装载进内存,供 PropEditor 取用
|
★★★ 两个目录的桥梁 |
app.ts |
token / userInfo 的存储键名、全局 loading、权限枚举 | ★★ |
GameEditor.ts |
各编辑模块的当前数据:NPC.currentData、
ITEM.itemList…
|
★★★ 编辑页都靠它 |
permission.ts |
动态路由表(不做权限过滤,名不副实) | ★★ |
task.ts / jenkins.ts /
progress.ts
|
发布任务队列、构建状态、进度 | ★★ 发版排障用 |
dialog.ts / tagsView.ts /
searchStore.ts / guide.ts
|
弹窗、标签页、搜索条件记忆、新手引导 | ★ 界面辅助 |
app.ts 里有个容易看错的地方// src/store/modules/app.ts
state: () => ({
token: 'token', // ← 这不是 token 本身
userInfo: 'userInfo', // ← 这也不是用户信息
initialize: 'initialize',
…
})
这些字段存的是存储键名,不是值。 真正的 token 在
localStorage 里,appStore.getToken
给出的是「去 localStorage 里用哪个 key 找」。所以你会看到:
const token = getStorage(appStore.getToken) // 用 store 里的键名去 storage 里取值
❌ 别学
用 store 的 state 存常量字符串。
token: 'token' 这种写法让人第一眼一定会读错 —— 看到
appStore.getToken 谁都以为拿到的是 token。
规范做法:存储键名是编译期常量,应该是
const STORAGE_KEYS = { token: 'token' } as const
这样一个普通模块导出,不该占 store 的 state ——
它既不会变、也不需要响应式。
怎么不踩:看到 appStore.getXxx
先想一下它是「值」还是「键名」。这个项目里两种都有。
config/:16 份表字段定义看 src/store/config/Box.ts 的开头,最能说明这是什么:
const DialogConfig: ConfigModel = {
id: {
type: 'Number',
summary: 'id',
desc: '物品的ID',
meta: { readonly: true }
},
name: {
type: 'String',
summary: '名称',
desc: '盒子的名称'
},
type: {
type: 'Number',
summary: '宝箱类型(0:普通,1:数量随机 )',
desc: '0:普通宝箱\n1:数量随机宝箱',
editor: 'select', // ← 用哪个控件
options: [
{ label: '普通', value: 0 },
{ label: '数量随机', value: 1 }
]
},
…
}
| 字段 | 决定什么 |
|---|---|
type |
数据类型,影响默认控件与校验 |
summary |
界面上显示的标签文字 |
desc |
鼠标悬停的说明气泡 |
editor |
指定控件:select / switch /
time…(全集在
components/PropEditor/Components/)
|
options |
下拉选项 |
meta |
杂项:只读、禁用、占位符、样式类名 |
condition / visible |
联动显示:满足条件时这个字段才出现 |
这就是「找不到界面文字在哪」的答案
课 01 说过一个通用技巧:从界面文案倒推。
现在你知道为什么它管用了 —— 你在界面上看到的字段标签, 八成就是某个
store/config/*.ts 里的 summary。
全局搜那几个字,直接落到定义处。
table.tssrc/store/modules/table.ts:9 这一行是关键:
const TableConfig: any = import.meta.glob('@/store/config/*.ts')
import.meta.glob 是 Vite 的功能:
按通配符把一批文件收集成一个
{ 路径: () => import(路径) } 的对象。也就是说 config/ 下的文件
没有任何地方显式 import 它们,全靠这一行扫进来。
然后 init()(table.ts:103)逐个
await 加载,把每个文件的 default 导出转成 Map,
存进 this.config[文件名]。页面用
TableStore.getConfigByKey('Npc')
(table.ts:196)取出来。
❌ 危险
import.meta.glob
意味着静态分析工具看不到这些依赖。
你用 IDE 的「查找引用」搜
Npc.ts,会得到零结果。
看起来像死文件,删掉之后 NPC 编辑页的表单会变成空白 —— 而且不报错。
规矩:src/store/config/ 下的任何文件,
永远不要因为「搜不到引用」就删。
init() 有个 1500 毫秒的延迟
table.ts 的 init() 不是在应用启动时调的,
而是在登录成功之后延迟 1500 毫秒 (views/ReLogin/index.vue:195-198):
setTimeout(async () => {
await getServerIds()
TableStore.init()
}, 1500)
❌ 别学
用 setTimeout 猜时序。
1500 这个数字没有任何依据,它只是「大概够了」。
后果:如果用户手快,在这 1.5 秒内跳进某个编辑页,
getConfigByKey() 会返回 undefined,
表单渲染成空白。机器慢或网络慢的时候窗口更大。
这类 bug 极难复现,因为它跟机器性能有关。
规范做法:用真正的依赖关系表达时序 ——
await getServerIds() 之后直接
await TableStore.init(),不需要定时器; 或者让
getConfigByKey() 在未初始化时返回一个 Promise。
排障提示:「偶尔进编辑页表单是空的,刷新一下就好了」—— 这就是它。
把这一课和前三课串起来,一条真实的链路:
① 用户登录 → loginSuccess() 调
initEnv(),把凭据写进 GameEvn.ENV(modules)
② GameEvn 上的 watch 把 ENV
整体落进 localStorage(GameEvn.ts:44-49)
③ 1500ms 后 TableStore.init() 把
config/ 下 16 份 schema 装进内存
④ 用户进 NPC 页 → byMapIdGetNpc() 发请求
(拦截器从 GameEvn 取 pcode,课 02)
⑤ 结果写进
GameEditor.NPC.npcList(modules)
⑥ 点进编辑页 → 从 TableStore 取
Npc 的 schema,和 NPC.currentData 一起交给
PropEditor
⑦ PropEditor 按 schema 的
editor 挑控件渲染
两条链在第 ⑥ 步汇合:一条是数据(modules), 一条是描述(config)。这就是架构图 FIG.04 画的那两条泳道。
JSON.parse(localStorage.getItem('GameENV'))
这就是 GameEvn.ENV 落盘后的样子
/game/box),在界面上找一个字段标签(比如「宝箱类型」),
然后在编辑器里全局搜这几个字 —— 应该落到
src/store/config/Box.ts 的 summary
summary 改成别的字,保存,回界面看标签变了。
注意:你没有碰任何 .vue 文件。改完记得改回来
src/config/axios/service.ts,看第 11 行用的是
useAppStoreWithOut() 而不是 useAppStore()。
想一下:这个文件是在组件里吗?