Skip to content

Vuex 迁移

从 Vuex 迁移到 Pinia 的综合指南,包括自动化迁移工具、逐步转换过程和渐进式迁移的兼容性模式。

功能特性

  • 🔄 自动化迁移工具和脚本
  • 📋 逐步迁移检查清单
  • 🔀 渐进式迁移策略
  • 🛠️ Vuex 兼容性层
  • 📊 状态结构转换
  • 🎯 Action 和 Mutation 映射
  • 🧪 测试迁移策略
  • 📦 模块系统转换
  • 🔧 开发工具集成
  • 📝 迁移验证工具

迁移概览

主要差异

方面VuexPinia
Store 定义单一 store 带模块多个独立 stores
Mutations状态变更必需直接状态变更
Actions异步操作同步/异步操作
Getters计算属性计算属性
模块嵌套模块独立 stores
TypeScript复杂类型定义内置 TypeScript 支持
开发工具Vue DevToolsVue DevTools + 更好的 DX
SSR手动设置自动 SSR 支持

迁移工具

自动化迁移脚本

typescript
// scripts/vuex-to-pinia-migration.ts
import * as fs from 'fs'
import * as path from 'path'
import { parse } from '@babel/parser'
import traverse from '@babel/traverse'
import generate from '@babel/generator'
import * as t from '@babel/types'

interface VuexModule {
  name: string
  state: any
  mutations: Record<string, Function>
  actions: Record<string, Function>
  getters: Record<string, Function>
  modules?: Record<string, VuexModule>
}

interface MigrationConfig {
  sourceDir: string
  targetDir: string
  storeFile: string
  preserveModules: boolean
  generateTypes: boolean
  createTests: boolean
}

class VuexToPiniaMigrator {
  private config: MigrationConfig
  private vuexStore: any
  private migrationLog: string[] = []
  
  constructor(config: MigrationConfig) {
    this.config = config
  }
  
  async migrate(): Promise<void> {
    console.log('🚀 开始 Vuex 到 Pinia 迁移...')
    
    try {
      // 1. 解析现有 Vuex store
      await this.parseVuexStore()
      
      // 2. 创建 Pinia stores
      await this.createPiniaStores()
      
      // 3. 生成 TypeScript 类型
      if (this.config.generateTypes) {
        await this.generateTypes()
      }
      
      // 4. 创建测试
      if (this.config.createTests) {
        await this.createTests()
      }
      
      // 5. 生成迁移报告
      await this.generateMigrationReport()
      
      console.log('✅ 迁移成功完成!')
      
    } catch (error) {
      console.error('❌ 迁移失败:', error)
      throw error
    }
  }
  
  private async parseVuexStore(): Promise<void> {
    const storeFilePath = path.join(this.config.sourceDir, this.config.storeFile)
    const storeContent = fs.readFileSync(storeFilePath, 'utf-8')
    
    const ast = parse(storeContent, {
      sourceType: 'module',
      plugins: ['typescript', 'decorators-legacy']
    })
    
    this.vuexStore = this.extractStoreStructure(ast)
    this.migrationLog.push(`✓ 从 ${storeFilePath} 解析 Vuex store`)
  }
  
  private extractStoreStructure(ast: any): VuexModule {
    let storeStructure: VuexModule = {
      name: 'root',
      state: {},
      mutations: {},
      actions: {},
      getters: {},
      modules: {}
    }
    
    traverse(ast, {
      ObjectExpression(path) {
        const properties = path.node.properties
        
        properties.forEach((prop: any) => {
          if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
            const key = prop.key.name
            
            switch (key) {
              case 'state':
                storeStructure.state = this.extractState(prop.value)
                break
              case 'mutations':
                storeStructure.mutations = this.extractMutations(prop.value)
                break
              case 'actions':
                storeStructure.actions = this.extractActions(prop.value)
                break
              case 'getters':
                storeStructure.getters = this.extractGetters(prop.value)
                break
              case 'modules':
                storeStructure.modules = this.extractModules(prop.value)
                break
            }
          }
        })
      }
    })
    
    return storeStructure
  }
  
  private extractState(node: any): any {
    // 提取状态属性
    const state: any = {}
    
    if (t.isObjectExpression(node)) {
      node.properties.forEach((prop: any) => {
        if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
          state[prop.key.name] = this.extractValue(prop.value)
        }
      })
    } else if (t.isArrowFunctionExpression(node) || t.isFunctionExpression(node)) {
      // 处理函数形式的 state
      const returnStatement = node.body.body?.find((stmt: any) => t.isReturnStatement(stmt))
      if (returnStatement && t.isObjectExpression(returnStatement.argument)) {
        return this.extractState(returnStatement.argument)
      }
    }
    
    return state
  }
  
  private extractMutations(node: any): Record<string, Function> {
    const mutations: Record<string, Function> = {}
    
    if (t.isObjectExpression(node)) {
      node.properties.forEach((prop: any) => {
        if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
          mutations[prop.key.name] = prop.value
        }
      })
    }
    
    return mutations
  }
  
  private extractActions(node: any): Record<string, Function> {
    const actions: Record<string, Function> = {}
    
    if (t.isObjectExpression(node)) {
      node.properties.forEach((prop: any) => {
        if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
          actions[prop.key.name] = prop.value
        }
      })
    }
    
    return actions
  }
  
  private extractGetters(node: any): Record<string, Function> {
    const getters: Record<string, Function> = {}
    
    if (t.isObjectExpression(node)) {
      node.properties.forEach((prop: any) => {
        if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
          getters[prop.key.name] = prop.value
        }
      })
    }
    
    return getters
  }
  
  private extractModules(node: any): Record<string, VuexModule> {
    const modules: Record<string, VuexModule> = {}
    
    if (t.isObjectExpression(node)) {
      node.properties.forEach((prop: any) => {
        if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
          modules[prop.key.name] = this.extractStoreStructure(prop.value)
        }
      })
    }
    
    return modules
  }
  
  private extractValue(node: any): any {
    if (t.isStringLiteral(node)) return node.value
    if (t.isNumericLiteral(node)) return node.value
    if (t.isBooleanLiteral(node)) return node.value
    if (t.isNullLiteral(node)) return null
    if (t.isArrayExpression(node)) {
      return node.elements.map((el: any) => this.extractValue(el))
    }
    if (t.isObjectExpression(node)) {
      const obj: any = {}
      node.properties.forEach((prop: any) => {
        if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
          obj[prop.key.name] = this.extractValue(prop.value)
        }
      })
      return obj
    }
    return undefined
  }
  
  private async createPiniaStores(): Promise<void> {
    // 创建主 store
    await this.createMainStore(this.vuexStore)
    
    // 创建模块 stores
    if (this.vuexStore.modules) {
      for (const [moduleName, moduleData] of Object.entries(this.vuexStore.modules)) {
        await this.createModuleStore(moduleName, moduleData)
      }
    }
  }
  
  private async createMainStore(storeData: VuexModule): Promise<void> {
    const storeContent = this.generatePiniaStore('main', storeData)
    const filePath = path.join(this.config.targetDir, 'stores', 'main.ts')
    
    await this.ensureDirectoryExists(path.dirname(filePath))
    fs.writeFileSync(filePath, storeContent)
    
    this.migrationLog.push(`✓ 创建主 store: ${filePath}`)
  }
  
  private async createModuleStore(moduleName: string, moduleData: VuexModule): Promise<void> {
    const storeContent = this.generatePiniaStore(moduleName, moduleData)
    const filePath = path.join(this.config.targetDir, 'stores', `${moduleName}.ts`)
    
    await this.ensureDirectoryExists(path.dirname(filePath))
    fs.writeFileSync(filePath, storeContent)
    
    this.migrationLog.push(`✓ 创建 ${moduleName} store: ${filePath}`)
  }
  
  private generatePiniaStore(storeName: string, storeData: VuexModule): string {
    const stateProps = Object.keys(storeData.state)
    const mutations = Object.keys(storeData.mutations)
    const actions = Object.keys(storeData.actions)
    const getters = Object.keys(storeData.getters)
    
    return `import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 从 Vuex 模块迁移: ${storeName}
export const use${this.capitalize(storeName)}Store = defineStore('${storeName}', () => {
  // 状态 (从 Vuex state 迁移)
${this.generateStateDeclarations(storeData.state)}

  // Getters (从 Vuex getters 迁移)
${this.generateGetters(storeData.getters)}

  // Actions (从 Vuex mutations 和 actions 迁移)
${this.generateActions(storeData.mutations, storeData.actions)}

  return {
    // 状态
${stateProps.map(prop => `    ${prop}`).join(',\n')},
    
    // Getters
${getters.map(getter => `    ${getter}`).join(',\n')},
    
    // Actions
${[...mutations, ...actions].map(action => `    ${action}`).join(',\n')}
  }
})

// 类型定义
export interface ${this.capitalize(storeName)}State {
${this.generateStateInterface(storeData.state)}
}
`
  }
  
  private generateStateDeclarations(state: any): string {
    return Object.entries(state)
      .map(([key, value]) => {
        const defaultValue = JSON.stringify(value)
        return `  const ${key} = ref(${defaultValue})`
      })
      .join('\n')
  }
  
  private generateGetters(getters: Record<string, Function>): string {
    return Object.entries(getters)
      .map(([name, getter]) => {
        // 将 Vuex getter 转换为 Pinia computed
        const getterCode = this.convertVuexGetterToPinia(getter)
        return `  const ${name} = computed(() => {\n    ${getterCode}\n  })`
      })
      .join('\n\n')
  }
  
  private generateActions(mutations: Record<string, Function>, actions: Record<string, Function>): string {
    const mutationActions = Object.entries(mutations)
      .map(([name, mutation]) => {
        const actionCode = this.convertVuexMutationToPinia(mutation)
        return `  function ${name}(payload?: any) {\n    ${actionCode}\n  }`
      })
    
    const regularActions = Object.entries(actions)
      .map(([name, action]) => {
        const actionCode = this.convertVuexActionToPinia(action)
        return `  async function ${name}(payload?: any) {\n    ${actionCode}\n  }`
      })
    
    return [...mutationActions, ...regularActions].join('\n\n')
  }
  
  private generateStateInterface(state: any): string {
    return Object.entries(state)
      .map(([key, value]) => {
        const type = this.inferType(value)
        return `  ${key}: ${type}`
      })
      .join('\n')
  }
  
  private convertVuexGetterToPinia(getter: Function): string {
    // 这是一个简化的转换 - 实际中需要更复杂的 AST 操作
    let code = getter.toString()
    
    // 替换 Vuex getter 参数为 Pinia 等价物
    code = code.replace(/\(state, getters, rootState, rootGetters\)/g, '()')
    code = code.replace(/state\./g, '')
    code = code.replace(/getters\./g, '')
    
    return code
  }
  
  private convertVuexMutationToPinia(mutation: Function): string {
    let code = mutation.toString()
    
    // 替换 Vuex mutation 参数
    code = code.replace(/\(state, payload\)/g, '(payload)')
    code = code.replace(/state\./g, '')
    
    // 将直接赋值转换为 ref 更新
    code = code.replace(/(\w+)\s*=/g, '$1.value =')
    
    return code
  }
  
  private convertVuexActionToPinia(action: Function): string {
    let code = action.toString()
    
    // 替换 Vuex action 参数
    code = code.replace(/\({ commit, dispatch, state, getters, rootState, rootGetters }, payload\)/g, '(payload)')
    code = code.replace(/commit\(/g, 'this.')
    code = code.replace(/dispatch\(/g, 'this.')
    code = code.replace(/state\./g, 'this.')
    
    return code
  }
  
  private inferType(value: any): string {
    if (Array.isArray(value)) {
      if (value.length > 0) {
        const itemType = this.inferType(value[0])
        return `${itemType}[]`
      }
      return 'any[]'
    }
    
    if (value === null) return 'null'
    if (typeof value === 'object') return 'Record<string, any>'
    
    return typeof value
  }
  
  private capitalize(str: string): string {
    return str.charAt(0).toUpperCase() + str.slice(1)
  }
  
  private async ensureDirectoryExists(dirPath: string): Promise<void> {
    if (!fs.existsSync(dirPath)) {
      fs.mkdirSync(dirPath, { recursive: true })
    }
  }
  
  private async generateTypes(): Promise<void> {
    const typesContent = this.generateGlobalTypes()
    const filePath = path.join(this.config.targetDir, 'types', 'stores.ts')
    
    await this.ensureDirectoryExists(path.dirname(filePath))
    fs.writeFileSync(filePath, typesContent)
    
    this.migrationLog.push(`✓ 生成类型: ${filePath}`)
  }
  
  private generateGlobalTypes(): string {
    return `// 从 Vuex 迁移自动生成的类型

export interface RootState {
  // 在此添加根状态类型
}

export interface StoreModules {
  // 在此添加 store 模块类型
}

// 重新导出所有 store 类型
export * from '../stores/main'
${Object.keys(this.vuexStore.modules || {})
  .map(moduleName => `export * from '../stores/${moduleName}'`)
  .join('\n')}
`
  }
  
  private async createTests(): Promise<void> {
    // 为主 store 创建测试
    await this.createStoreTest('main', this.vuexStore)
    
    // 为模块 stores 创建测试
    if (this.vuexStore.modules) {
      for (const [moduleName, moduleData] of Object.entries(this.vuexStore.modules)) {
        await this.createStoreTest(moduleName, moduleData)
      }
    }
  }
  
  private async createStoreTest(storeName: string, storeData: VuexModule): Promise<void> {
    const testContent = this.generateStoreTest(storeName, storeData)
    const filePath = path.join(this.config.targetDir, 'tests', 'stores', `${storeName}.test.ts`)
    
    await this.ensureDirectoryExists(path.dirname(filePath))
    fs.writeFileSync(filePath, testContent)
    
    this.migrationLog.push(`✓ 创建测试: ${filePath}`)
  }
  
  private generateStoreTest(storeName: string, storeData: VuexModule): string {
    const storeHook = `use${this.capitalize(storeName)}Store`
    
    return `import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { ${storeHook} } from '../../stores/${storeName}'

describe('${storeName} Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('应该使用正确的默认状态初始化', () => {
    const store = ${storeHook}()
    
    // 测试初始状态
${Object.entries(storeData.state)
  .map(([key, value]) => `    expect(store.${key}).toEqual(${JSON.stringify(value)})`)
  .join('\n')}
  })

${Object.keys(storeData.mutations)
  .map(mutationName => `  it('应该处理 ${mutationName} mutation', () => {
    const store = ${storeHook}()
    
    // TODO: 为 ${mutationName} 添加测试
    // store.${mutationName}(payload)
    // expect(store.someState).toBe(expectedValue)
  })`)
  .join('\n\n')}

${Object.keys(storeData.actions)
  .map(actionName => `  it('应该处理 ${actionName} action', async () => {
    const store = ${storeHook}()
    
    // TODO: 为 ${actionName} 添加测试
    // await store.${actionName}(payload)
    // expect(store.someState).toBe(expectedValue)
  })`)
  .join('\n\n')}

${Object.keys(storeData.getters)
  .map(getterName => `  it('应该正确计算 ${getterName} getter', () => {
    const store = ${storeHook}()
    
    // TODO: 为 ${getterName} 添加测试
    // expect(store.${getterName}).toBe(expectedValue)
  })`)
  .join('\n\n')}
})
`
  }
  
  private async generateMigrationReport(): Promise<void> {
    const report = `# Vuex 到 Pinia 迁移报告

生成时间: ${new Date().toISOString()}

## 迁移摘要

${this.migrationLog.map(log => `- ${log}`).join('\n')}

## 需要手动完成的步骤

1. 检查生成的 stores 并更新业务逻辑
2. 更新组件从 Vuex 到 Pinia 的导入
3. 用 Pinia 等价物替换 \`mapState\`\`mapGetters\`\`mapActions\`
4. 更新路由守卫和中间件
5. 彻底测试所有功能
6. 更新文档

## 破坏性变更

- Mutations 现在是直接状态修改
- Actions 可以是同步或异步的
- 不再有 \`commit\`\`dispatch\` - 直接调用 actions
- 模块命名空间被独立 stores 替代

## 下一步

1. 运行测试: \`npm run test\`
2. 更新组件使用新的 stores
3. 移除 Vuex 依赖
4. 更新构建配置
`
    
    const filePath = path.join(this.config.targetDir, 'MIGRATION_REPORT.md')
    fs.writeFileSync(filePath, report)
    
    console.log(`📋 迁移报告已生成: ${filePath}`)
  }
}

// 使用方法
export async function migrateVuexToPinia(config: MigrationConfig) {
  const migrator = new VuexToPiniaMigrator(config)
  await migrator.migrate()
}

// CLI 使用
if (require.main === module) {
  const config: MigrationConfig = {
    sourceDir: './src',
    targetDir: './src',
    storeFile: 'store/index.js',
    preserveModules: true,
    generateTypes: true,
    createTests: true
  }
  
  migrateVuexToPinia(config).catch(console.error)
}

Vuex 兼容性层

typescript
// utils/vuex-compat.ts
import { inject, InjectionKey } from 'vue'
import type { Store } from 'pinia'

// 渐进式迁移的兼容性层
export interface VuexCompatStore {
  state: any
  getters: any
  commit: (type: string, payload?: any) => void
  dispatch: (type: string, payload?: any) => Promise<any>
}

// 为 Pinia stores 创建类似 Vuex 的接口
export function createVuexCompat(piniaStore: any): VuexCompatStore {
  return {
    get state() {
      return piniaStore.$state
    },
    
    get getters() {
      // 将计算属性映射为 getters
      const getters: any = {}
      for (const key in piniaStore) {
        if (typeof piniaStore[key] === 'function' && key.startsWith('get')) {
          getters[key] = piniaStore[key]
        }
      }
      return getters
    },
    
    commit(type: string, payload?: any) {
      // 将 mutations 映射到直接方法调用
      if (typeof piniaStore[type] === 'function') {
        piniaStore[type](payload)
      } else {
        console.warn(`Mutation ${type} 在 store 中未找到`)
      }
    },
    
    async dispatch(type: string, payload?: any) {
      // 将 actions 映射到直接方法调用
      if (typeof piniaStore[type] === 'function') {
        return await piniaStore[type](payload)
      } else {
        console.warn(`Action ${type} 在 store 中未找到`)
        return Promise.resolve()
      }
    }
  }
}

// Pinia 的 Vuex 风格辅助函数
export function mapState(storeId: string, states: string[]) {
  return states.reduce((mapped, state) => {
    mapped[state] = function(this: any) {
      const store = this.$pinia.state.value[storeId]
      return store ? store[state] : undefined
    }
    return mapped
  }, {} as any)
}

export function mapGetters(storeId: string, getters: string[]) {
  return getters.reduce((mapped, getter) => {
    mapped[getter] = function(this: any) {
      const store = this.$pinia._s.get(storeId)
      return store ? store[getter] : undefined
    }
    return mapped
  }, {} as any)
}

export function mapActions(storeId: string, actions: string[]) {
  return actions.reduce((mapped, action) => {
    mapped[action] = function(this: any, payload?: any) {
      const store = this.$pinia._s.get(storeId)
      return store ? store[action](payload) : Promise.resolve()
    }
    return mapped
  }, {} as any)
}

export function mapMutations(storeId: string, mutations: string[]) {
  return mutations.reduce((mapped, mutation) => {
    mapped[mutation] = function(this: any, payload?: any) {
      const store = this.$pinia._s.get(storeId)
      if (store && typeof store[mutation] === 'function') {
        store[mutation](payload)
      }
    }
    return mapped
  }, {} as any)
}

逐步迁移示例

1. 简单 Vuex Store

迁移前 (Vuex):

javascript
// store/modules/counter.js
export default {
  namespaced: true,
  state: {
    count: 0,
    history: []
  },
  mutations: {
    INCREMENT(state) {
      state.count++
      state.history.push(`增加到 ${state.count}`)
    },
    DECREMENT(state) {
      state.count--
      state.history.push(`减少到 ${state.count}`)
    },
    SET_COUNT(state, value) {
      state.count = value
      state.history.push(`设置为 ${value}`)
    }
  },
  actions: {
    async incrementAsync({ commit }, delay = 1000) {
      await new Promise(resolve => setTimeout(resolve, delay))
      commit('INCREMENT')
    },
    reset({ commit }) {
      commit('SET_COUNT', 0)
    }
  },
  getters: {
    doubleCount: state => state.count * 2,
    isPositive: state => state.count > 0,
    lastAction: state => state.history[state.history.length - 1]
  }
}

迁移后 (Pinia):

typescript
// stores/counter.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // 状态
  const count = ref(0)
  const history = ref<string[]>([])
  
  // Getters
  const doubleCount = computed(() => count.value * 2)
  const isPositive = computed(() => count.value > 0)
  const lastAction = computed(() => history.value[history.value.length - 1])
  
  // Actions (mutations + actions 合并)
  function increment() {
    count.value++
    history.value.push(`增加到 ${count.value}`)
  }
  
  function decrement() {
    count.value--
    history.value.push(`减少到 ${count.value}`)
  }
  
  function setCount(value: number) {
    count.value = value
    history.value.push(`设置为 ${value}`)
  }
  
  async function incrementAsync(delay = 1000) {
    await new Promise(resolve => setTimeout(resolve, delay))
    increment()
  }
  
  function reset() {
    setCount(0)
  }
  
  return {
    // 状态
    count,
    history,
    
    // Getters
    doubleCount,
    isPositive,
    lastAction,
    
    // Actions
    increment,
    decrement,
    setCount,
    incrementAsync,
    reset
  }
})

2. 组件迁移

迁移前 (Vuex):

vue
<template>
  <div>
    <p>计数: {{ count }}</p>
    <p>双倍: {{ doubleCount }}</p>
    <p>最后操作: {{ lastAction }}</p>
    <button @click="increment">+</button>
    <button @click="decrement">-</button>
    <button @click="incrementAsync">+ 异步</button>
    <button @click="reset">重置</button>
  </div>
</template>

<script>
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'

export default {
  computed: {
    ...mapState('counter', ['count']),
    ...mapGetters('counter', ['doubleCount', 'lastAction'])
  },
  methods: {
    ...mapMutations('counter', ['increment', 'decrement']),
    ...mapActions('counter', ['incrementAsync', 'reset'])
  }
}
</script>

迁移后 (Pinia):

vue
<template>
  <div>
    <p>计数: {{ counter.count }}</p>
    <p>双倍: {{ counter.doubleCount }}</p>
    <p>最后操作: {{ counter.lastAction }}</p>
    <button @click="counter.increment">+</button>
    <button @click="counter.decrement">-</button>
    <button @click="counter.incrementAsync">+ 异步</button>
    <button @click="counter.reset">重置</button>
  </div>
</template>

<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

3. 带模块的复杂 Store

迁移前 (Vuex):

javascript
// store/modules/user.js
export default {
  namespaced: true,
  state: {
    currentUser: null,
    preferences: {},
    isAuthenticated: false
  },
  mutations: {
    SET_USER(state, user) {
      state.currentUser = user
      state.isAuthenticated = !!user
    },
    SET_PREFERENCES(state, preferences) {
      state.preferences = preferences
    },
    LOGOUT(state) {
      state.currentUser = null
      state.isAuthenticated = false
      state.preferences = {}
    }
  },
  actions: {
    async login({ commit, dispatch }, credentials) {
      try {
        const user = await api.login(credentials)
        commit('SET_USER', user)
        await dispatch('loadPreferences')
        return user
      } catch (error) {
        throw error
      }
    },
    async loadPreferences({ commit, state }) {
      if (state.currentUser) {
        const preferences = await api.getUserPreferences(state.currentUser.id)
        commit('SET_PREFERENCES', preferences)
      }
    },
    logout({ commit }) {
      commit('LOGOUT')
      // 清除其他 stores
      commit('posts/CLEAR_POSTS', null, { root: true })
    }
  },
  getters: {
    userName: state => state.currentUser?.name || '访客',
    userRole: state => state.currentUser?.role || 'guest',
    hasPermission: state => permission => {
      return state.currentUser?.permissions?.includes(permission) || false
    }
  }
}

迁移后 (Pinia):

typescript
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { usePostsStore } from './posts'
import * as api from '@/api'

interface User {
  id: string
  name: string
  role: string
  permissions: string[]
}

export const useUserStore = defineStore('user', () => {
  // 状态
  const currentUser = ref<User | null>(null)
  const preferences = ref<Record<string, any>>({})
  const isAuthenticated = ref(false)
  
  // Getters
  const userName = computed(() => currentUser.value?.name || '访客')
  const userRole = computed(() => currentUser.value?.role || 'guest')
  const hasPermission = computed(() => {
    return (permission: string) => {
      return currentUser.value?.permissions?.includes(permission) || false
    }
  })
  
  // Actions
  function setUser(user: User | null) {
    currentUser.value = user
    isAuthenticated.value = !!user
  }
  
  function setPreferences(newPreferences: Record<string, any>) {
    preferences.value = newPreferences
  }
  
  async function login(credentials: { email: string; password: string }) {
    try {
      const user = await api.login(credentials)
      setUser(user)
      await loadPreferences()
      return user
    } catch (error) {
      throw error
    }
  }
  
  async function loadPreferences() {
    if (currentUser.value) {
      const userPreferences = await api.getUserPreferences(currentUser.value.id)
      setPreferences(userPreferences)
    }
  }
  
  function logout() {
    setUser(null)
    setPreferences({})
    
    // 清除其他 stores
    const postsStore = usePostsStore()
    postsStore.clearPosts()
  }
  
  return {
    // 状态
    currentUser,
    preferences,
    isAuthenticated,
    
    // Getters
    userName,
    userRole,
    hasPermission,
    
    // Actions
    setUser,
    setPreferences,
    login,
    loadPreferences,
    logout
  }
})

迁移检查清单

迁移前

  • [ ] 审核现有 Vuex store 结构
  • [ ] 识别模块依赖关系
  • [ ] 创建当前实现的备份
  • [ ] 在项目中设置 Pinia
  • [ ] 规划迁移策略(渐进式 vs 完整)

迁移中

  • [ ] 将状态转换为响应式 refs
  • [ ] 将 mutations 和 actions 合并为 actions
  • [ ] 将 getters 转换为计算属性
  • [ ] 更新模块结构为独立 stores
  • [ ] 处理跨 store 依赖
  • [ ] 更新组件导入和使用

迁移后

  • [ ] 更新所有组件导入
  • [ ] 用 Pinia 等价物替换 Vuex 辅助函数
  • [ ] 更新路由守卫和中间件
  • [ ] 测试所有功能
  • [ ] 更新文档
  • [ ] 移除 Vuex 依赖
  • [ ] 更新构建配置

测试迁移

typescript
// tests/migration.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '@/stores/counter'
import { useUserStore } from '@/stores/user'

describe('Vuex 到 Pinia 迁移', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  describe('Counter Store 迁移', () => {
    it('应该保持与 Vuex 版本相同的功能', () => {
      const counter = useCounterStore()
      
      // 测试初始状态
      expect(counter.count).toBe(0)
      expect(counter.history).toEqual([])
      
      // 测试 mutations -> actions
      counter.increment()
      expect(counter.count).toBe(1)
      expect(counter.history).toContain('增加到 1')
      
      counter.decrement()
      expect(counter.count).toBe(0)
      expect(counter.history).toContain('减少到 0')
      
      // 测试 getters
      expect(counter.doubleCount).toBe(0)
      expect(counter.isPositive).toBe(false)
    })
    
    it('应该正确处理异步 actions', async () => {
      const counter = useCounterStore()
      
      await counter.incrementAsync(100)
      expect(counter.count).toBe(1)
    })
  })

  describe('User Store 迁移', () => {
    it('应该处理认证流程', async () => {
      const user = useUserStore()
      
      expect(user.isAuthenticated).toBe(false)
      expect(user.userName).toBe('访客')
      
      // 模拟登录
      const mockUser = {
        id: '1',
        name: '张三',
        role: 'admin',
        permissions: ['read', 'write']
      }
      
      user.setUser(mockUser)
      
      expect(user.isAuthenticated).toBe(true)
      expect(user.userName).toBe('张三')
      expect(user.hasPermission('read')).toBe(true)
    })
  })
})

最佳实践

  1. 渐进式迁移: 一次迁移一个模块
  2. 保持兼容性: 在过渡期间使用兼容性层
  3. 彻底测试: 确保迁移后所有功能正常工作
  4. 更新文档: 在迁移过程中保持文档更新
  5. 团队沟通: 与团队成员协调迁移进度
  6. 性能监控: 监控迁移前后的性能
  7. 回滚计划: 制定出现问题时的回滚计划
  8. 培训: 确保团队理解 Pinia 概念和模式

这个迁移指南提供了从 Vuex 迁移到 Pinia 的全面方法,同时保持应用程序的稳定性和功能性。

Released under the MIT License.