我是如何治理一个混乱的 Pinia 状态管理系统的

最近接手了一个 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 anyas any[] 满天飞,TypeScript 成了摆设。有些复杂对象完全没有类型定义,全靠 IDE 猜。

3. 职责边界模糊

有个叫 point-to-point.ts 的文件,里面塞了:表单状态、字典数据、下拉选项、选中项管理、甚至还有一些工具函数。500 多行,谁都不敢动。

P1 - 迟早要改

  • 命名风格不统一point-to-pointappSystemadmin_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,
    },
  },
)

这个结构有几个好处:

  1. 注释分块,一目了然
  2. 返回值显式列出,知道 Store 暴露了什么
  3. 类型推断完美,不需要额外声明

三、统一持久化策略

之前的持久化很随意,现在统一规则:

数据类型 存储方式 理由
用户信息、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. 逐步迁移

按优先级分批迁移:

  1. Week 1-2:核心域(user、system、loading)
  2. Week 3-4:基础数据域(缓存、字典)
  3. 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 注释

维护成本从"看一眼就头疼"变成了"顺手就能改"。

一些心得

  1. 不要一步到位:重构最怕的是"大跃进",分批迁移、逐步验证才是正道
  2. 向后兼容很重要:老代码不可能一夜之间都改完,兼容层是必须的
  3. 规范先行:先定好规范,再动手写代码,不然迁移完了又是一坨新的屎山
  4. Store 和 Composable 别混用:想清楚每个东西的职责,别图方便什么都往 Store 里塞
  5. 类型是文档 :好的类型定义比注释更有用,interface 写清楚了,代码自解释

以上就是这次 Pinia 治理的全过程。如果你也在维护一个"历史悠久"的 Vue 项目,希望这篇文章能给你一些参考。

有问题欢迎评论区交流 👋

相关推荐
boombb1 小时前
国际化方案:多环境、多语言、动态加载的完整实践
前端
醒了接着睡1 小时前
Vue中的watch
vue.js
一 乐1 小时前
物业管理|基于SprinBoot+vue的智慧物业管理系统(源码+数据库+文档)
前端·javascript·数据库·vue.js·spring boot
测试人社区—52722 小时前
你的单元测试真的“单元”吗?
前端·人工智能·git·测试工具·单元测试·自动化·log4j
c骑着乌龟追兔子2 小时前
Day 32 函数专题1:函数定义与参数
开发语言·前端·javascript
fruge2 小时前
前端性能优化实战:首屏加载从 3s 优化到 800ms
前端·性能优化
weixin_307779132 小时前
采用Amazon SES解决电商邮件延迟:以最小化运维实现最大效率的方案选择
运维·云原生·架构·云计算·aws
zlpzlpzyd2 小时前
vue.js 2和vue.js 3的生命周期与对应的钩子函数区别
前端·javascript·vue.js
鸡吃丸子2 小时前
前端需要掌握的关于代理的相关知识
前端