Lesson 04 · Pinia

Pinia,以及那个叫 store 但不是 store 的目录

src/store/ 下有两个子目录。它们名字都叫 store, 但一个装运行状态,一个装表格字段定义 —— 分不清会一直别扭。

基线:2026-08-12 的 gameboxclient 工作区快照。

先复习

1. 一个 ref 明明赋值了,界面却没变。最可能是什么?

2. <ElButton disabled="false">(注意没有冒号)会发生什么?

先说人话:Pinia 解决的是什么问题

上一课讲了:组件之间传数据靠 props 往下传、emit 往上传。 这在父子之间很好用。

问题是有些数据跟组件树的形状完全无关。 比如「当前登录用户是谁」「当前选的是哪台服务器」—— 顶栏要用,某个深埋在五层组件里的表单也要用。 靠 props 一层层传下去,中间那些完全不关心这个数据的组件也得跟着接一手再传一手。

Pinia 就是把这类数据从组件树里拿出来,放到一个所有组件都够得着的地方。 仅此而已。它不神秘,本质就是一个「带响应式的全局对象」。

对你来说最接近的类比

如果你在 Laya 项目里写过一个 GameData 单例 —— 里面放着当前关卡、玩家属性,全局各处 GameData.instance.hp 这样取 —— 那 Pinia 就是那个单例,外加自动响应式

区别是:单例里改了 hp,你得手动通知界面; Pinia store 里改了 hp,用到它的组件自动重渲染。

一、一个 store 长什么样

项目里所有 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-13src/permission.ts:12-18src/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/,那是描述。

第二类需求在这个项目里的占比远高于第一类。

3.1 modules/:19 个运行态 store

store 装什么 重要度
GameEvn.ts 整个工具的钥匙链。SVN / Jenkins / GM 的地址和凭据 ★★★ 排障必看
table.ts config/ 下的 schema 装载进内存,供 PropEditor 取用 ★★★ 两个目录的桥梁
app.ts token / userInfo 的存储键名、全局 loading、权限枚举 ★★
GameEditor.ts 各编辑模块的当前数据:NPC.currentDataITEM.itemList ★★★ 编辑页都靠它
permission.ts 动态路由表(不做权限过滤,名不副实) ★★
task.ts / jenkins.ts / progress.ts 发布任务队列、构建状态、进度 ★★ 发版排障用
dialog.ts / tagsView.ts / searchStore.ts / guide.ts 弹窗、标签页、搜索条件记忆、新手引导 ★ 界面辅助

3.2 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 先想一下它是「值」还是「键名」。这个项目里两种都有。

3.3 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

全局搜那几个字,直接落到定义处。

3.4 两个目录怎么接上:table.ts

src/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/ 下的任何文件, 永远不要因为「搜不到引用」就删

3.5 init() 有个 1500 毫秒的延迟

table.tsinit() 不是在应用启动时调的, 而是在登录成功之后延迟 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)

点进编辑页 → 从 TableStoreNpc 的 schema,和 NPC.currentData 一起交给 PropEditor

PropEditor 按 schema 的 editor 挑控件渲染

两条链在第 ⑥ 步汇合:一条是数据(modules), 一条是描述(config)。这就是架构图 FIG.04 画的那两条泳道。

真机验证