我是如何治理一个混乱的 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 项目,希望这篇文章能给你一些参考。

有问题欢迎评论区交流 👋

相关推荐
LabVIEW开发3 小时前
LabVIEW QMH 队列消息处理架构
架构·labview·labview知识·labview功能·labview程序
代码搬运媛3 小时前
Jest 测试框架详解与实现指南
前端
counterxing4 小时前
我把 Codex 里的 Skills 做成了一个 MCP,还支持分享
前端·agent·ai编程
wangqiaowq4 小时前
windows下nginx的安装
linux·服务器·前端
rising start4 小时前
二、全面理解MySQL架构
mysql·架构
之歆5 小时前
DAY_12JavaScript DOM 完全指南(二):实战与性能篇
开发语言·前端·javascript·ecmascript
发现一只大呆瓜5 小时前
Vite凭什么这么快?3分钟带你彻底搞懂 Vite 热更新的幕后黑手
前端·面试·vite
麦客奥德彪5 小时前
Android Skills
架构·ai编程
Maimai108085 小时前
React如何用 @microsoft/fetch-event-source 落地 SSE:比原生 EventSource 更灵活的实时推送方案
前端·javascript·react.js·microsoft·前端框架·reactjs·webassembly
candyTong5 小时前
Claude Code 的 Edit 工具是怎么工作的
javascript·后端·架构