最近接手了一个 Vue 3 + TypeScript 的中大型项目,状态管理这块...怎么说呢,一言难尽。花了两周时间做了一次系统性的治理,踩了不少坑,也总结出一些经验,分享给同样在"屎山"中挣扎的朋友们。
背景:接手时的状况
项目用的是 Pinia,但打开 src/store 目录的那一刻,我沉默了:
bash
src/store/
├── index.ts
├── user.ts # Options API 风格
├── system.ts # Options API 风格
├── loading.ts # 半成品
├── keepAlive.ts # 没有类型
├── point-to-point.ts # Setup 风格 + 啥都往里塞
├── selection.ts # 不知道干嘛的
├── xxx-name.ts # 好几个类似的文件
└── ...还有一堆
十几个 Store 文件扁平地堆在一起,有的用 Options API,有的用 Setup 风格,有的用 TypeScript,有的满屏 any。更离谱的是,composables 目录里也有一套"状态管理",两边功能重叠,谁也不知道该用哪个。
问题诊断:到底哪出了问题
在动手之前,我花了半天时间梳理,把问题分成了三个等级。
P0 - 不治不行
1. 代码风格精神分裂
一半 Options API,一半 Setup 风格。Options API 是 Vue 2 时代的写法,在 Vue 3 + TypeScript 项目里用这个,类型推断很难受:
typescript
// 旧代码:Options API
export const useUserStore = defineStore('user', {
state: () => ({
userInfo: null as any, // 到处都是 any
permissions: [] as any[],
}),
getters: {
getLocale: state => state.locale, // 这种 getter 毫无意义
},
actions: {
setUserInfoAction(info) { // Action 后缀是什么鬼
this.userInfo = info
},
},
})
2. 类型形同虚设
as any、as any[] 满天飞,TypeScript 成了摆设。有些复杂对象完全没有类型定义,全靠 IDE 猜。
3. 职责边界模糊
有个叫 point-to-point.ts 的文件,里面塞了:表单状态、字典数据、下拉选项、选中项管理、甚至还有一些工具函数。500 多行,谁都不敢动。
P1 - 迟早要改
- 命名风格不统一 :
point-to-point、appSystem、admin_user,三种命名法齐活 - 持久化策略混乱 :有的用
localStorage,有的用sessionStorage,有的根本没做持久化但数据刷新就丢 - 缓存逻辑重复:好几个 Store 都自己实现了一套"带过期时间的缓存",代码几乎一样
P2 - 代码洁癖
- Getter 只是简单返回 state,完全多余
- Action 命名带
Action后缀,不符合社区习惯 - 注释缺失,三个月后自己都看不懂
解决方案:怎么治
一、按业务域组织目录
扁平结构最大的问题是:项目一大,找文件全靠搜索。
重构后的目录结构按业务域划分:
bash
src/store/
├── index.ts # 统一导出
├── core/ # 核心域:用户、系统、加载状态
│ ├── index.ts
│ ├── user.ts
│ ├── system.ts
│ └── loading.ts
├── basicData/ # 基础数据域:缓存、字典
│ ├── index.ts
│ ├── cache.ts
│ └── dict.ts
├── search/ # 搜索域:查询表单、收藏
│ ├── index.ts
│ ├── queryForm.ts
│ └── favorite.ts
├── order/ # 订单域:表单、草稿
│ ├── index.ts
│ ├── orderForm.ts
│ └── orderDraft.ts
└── types/ # 类型定义
├── index.ts
└── user.types.ts
每个域一个目录,每个目录一个 index.ts 负责导出。使用时可以按域导入,也可以从根目录导入:
typescript
// 按域导入(推荐)
import { useUserStore } from '~/store/core'
// 根目录导入
import { useUserStore } from '~/store'
二、统一 Setup 风格 + 代码分块
所有 Store 统一用 Setup 风格重写,代码按 State → Getters → Actions → Return 分块组织:
typescript
/**
* 用户状态管理
* @description 管理用户信息、权限、Token
*/
export const useUserStore = defineStore(
'user',
() => {
// ==================== State ====================
/** 用户信息 */
const userInfo = ref<UserInfo | null>(null)
/** 权限列表 */
const permissions = ref<string[]>([])
/** Token */
const token = ref('')
// ==================== Getters ====================
/** 是否已登录 */
const isLoggedIn = computed(() => !!token.value && !!userInfo.value)
/** 检查是否有指定权限 */
const hasPermission = computed(() => (code: string) =>
permissions.value.includes(code)
)
// ==================== Actions ====================
/**
* 加载用户信息
*/
async function loadUserInfo(): Promise<void> {
const res = await getUserInfo()
userInfo.value = res.data
}
/**
* 登出
*/
function logout(): void {
userInfo.value = null
permissions.value = []
token.value = ''
}
// ==================== Return ====================
return {
// State
userInfo,
permissions,
token,
// Getters
isLoggedIn,
hasPermission,
// Actions
loadUserInfo,
logout,
}
},
{
persist: {
key: 'app-user',
storage: localStorage,
},
},
)
这个结构有几个好处:
- 注释分块,一目了然
- 返回值显式列出,知道 Store 暴露了什么
- 类型推断完美,不需要额外声明
三、统一持久化策略
之前的持久化很随意,现在统一规则:
| 数据类型 | 存储方式 | 理由 |
|---|---|---|
| 用户信息、Token | localStorage | 需要跨标签页、持久保存 |
| 系统配置、主题 | localStorage | 用户偏好需要持久 |
| 表单草稿 | localStorage | 防止意外关闭丢失 |
| 查询条件 | sessionStorage | 只在当前会话有效 |
| 加载状态 | 不持久化 | 实时状态,刷新归零 |
四、带 TTL 的缓存 Store
基础数据(比如字典、省市区)需要缓存,但不能无限期。写了一个通用的带过期时间的缓存 Store:
typescript
export const useDictStore = defineStore('dict', () => {
/** 缓存数据 */
const cache = ref<Map<string, CacheItem>>(new Map())
/** 默认 TTL:30 分钟 */
const DEFAULT_TTL = 30 * 60 * 1000
/**
* 获取字典数据(自动处理缓存)
*/
async function getDict(type: string): Promise<DictItem[]> {
const cached = cache.value.get(type)
// 缓存有效,直接返回
if (cached && Date.now() - cached.timestamp < cached.ttl) {
return cached.data
}
// 缓存过期或不存在,重新请求
const res = await fetchDictByType(type)
cache.value.set(type, {
data: res.data,
timestamp: Date.now(),
ttl: DEFAULT_TTL,
})
return res.data
}
/**
* 清除指定缓存
*/
function clearCache(type: string): void {
cache.value.delete(type)
}
return { cache, getDict, clearCache }
})
调用方完全不用关心缓存逻辑,直接 await dictStore.getDict('CONTRACT_TYPE') 就行。
五、Store 和 Composable 的分工
这是很多人纠结的问题:什么时候用 Store,什么时候用 Composable?
我的原则很简单:
| 场景 | 用 Store | 用 Composable |
|---|---|---|
| 数据需要跨组件共享 | ✅ | ❌ |
| 数据需要持久化 | ✅ | ❌ |
| 数据是全局单例 | ✅ | ❌ |
| 只在单个组件内使用 | ❌ | ✅ |
| 封装可复用的逻辑 | ❌ | ✅ |
| 封装副作用(定时器、事件监听) | ❌ | ✅ |
举个例子:
typescript
// Store:管理全局订单状态
export const useOrderStore = defineStore('order', () => {
const currentOrder = ref<Order | null>(null)
const draftList = ref<OrderDraft[]>([])
return { currentOrder, draftList }
})
// Composable:封装订单表单逻辑
export function useOrderForm() {
const store = useOrderStore()
const { t } = useI18n()
// 表单数据(组件级,不需要共享)
const formData = ref<OrderFormData>({})
const loading = ref(false)
// 表单验证规则
const rules = computed(() => ({
productName: [{ required: true, message: t('order.productRequired') }],
}))
// 提交订单
async function submit() {
loading.value = true
try {
const result = await submitOrder(formData.value)
store.currentOrder = result // 更新全局状态
return result
} finally {
loading.value = false
}
}
return { formData, loading, rules, submit }
}
Store 负责"数据仓库",Composable 负责"业务逻辑",各司其职。
迁移过程:怎么平滑过渡
不可能一口气把所有 Store 都重写,项目还要正常迭代。我采用的策略是:
1. 向后兼容导出
旧的 Store 暂时保留,新的 Store 写在域目录里,统一在 index.ts 做兼容导出:
typescript
// src/store/index.ts
// 新的域导出(推荐使用)
export * from './core'
export * from './basicData'
export * from './search'
// 向后兼容(逐步废弃)
export { useUserStore } from './core/user' // 旧路径的使用者不会报错
2. 逐步迁移
按优先级分批迁移:
- Week 1-2:核心域(user、system、loading)
- Week 3-4:基础数据域(缓存、字典)
- Week 5-8:业务域(按模块逐个迁移)
每次迁移完一个模块,跑一遍 TypeScript 检查和 E2E 测试,确保没问题再继续。
3. ESLint 规则护航
加了几条 ESLint 规则,防止"新代码写成老样子":
javascript
// eslint.config.js
{
files: ['src/store/**/*.ts'],
rules: {
// 禁止在 Store 中使用 any
'@typescript-eslint/no-explicit-any': 'error',
// 强制导入排序
'perfectionist/sort-imports': 'error',
},
}
最终效果
两周后的 Store 目录:
- ✅ 6 个业务域,结构清晰
- ✅ 100% Setup 风格
- ✅ 100% TypeScript 类型覆盖
- ✅ 统一的持久化策略
- ✅ 完善的 JSDoc 注释
维护成本从"看一眼就头疼"变成了"顺手就能改"。
一些心得
- 不要一步到位:重构最怕的是"大跃进",分批迁移、逐步验证才是正道
- 向后兼容很重要:老代码不可能一夜之间都改完,兼容层是必须的
- 规范先行:先定好规范,再动手写代码,不然迁移完了又是一坨新的屎山
- Store 和 Composable 别混用:想清楚每个东西的职责,别图方便什么都往 Store 里塞
- 类型是文档 :好的类型定义比注释更有用,
interface写清楚了,代码自解释
以上就是这次 Pinia 治理的全过程。如果你也在维护一个"历史悠久"的 Vue 项目,希望这篇文章能给你一些参考。
有问题欢迎评论区交流 👋