Skip to content

Redux 迁移

从 Redux 迁移到 Pinia 的综合指南,包括自动化工具、模式转换策略、兼容性层和详细的迁移示例。

功能特性

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

迁移概览

主要差异

方面ReduxPinia
Store 定义单一 store 带 reducers多个独立 stores
状态更新Immutable reducers直接状态变更
ActionsAction creators + dispatch直接方法调用
中间件Redux middlewarePinia plugins
选择器Reselect/手动选择器计算属性
TypeScript复杂类型定义内置 TypeScript 支持
开发工具Redux DevToolsVue DevTools
异步操作Redux Thunk/Saga原生 async/await

迁移工具

自动化迁移脚本

typescript
// scripts/redux-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 ReduxSlice {
  name: string
  initialState: any
  reducers: Record<string, Function>
  actions: Record<string, Function>
  selectors: Record<string, Function>
}

interface MigrationConfig {
  sourceDir: string
  targetDir: string
  slicesDir: string
  preserveStructure: boolean
  generateTypes: boolean
  createTests: boolean
  convertMiddleware: boolean
}

class ReduxToPiniaMigrator {
  private config: MigrationConfig
  private reduxSlices: ReduxSlice[] = []
  private migrationLog: string[] = []
  
  constructor(config: MigrationConfig) {
    this.config = config
  }
  
  async migrate(): Promise<void> {
    console.log('🚀 开始 Redux 到 Pinia 迁移...')
    
    try {
      // 1. 解析 Redux slices
      await this.parseReduxSlices()
      
      // 2. 创建 Pinia stores
      await this.createPiniaStores()
      
      // 3. 转换中间件
      if (this.config.convertMiddleware) {
        await this.convertMiddleware()
      }
      
      // 4. 生成 TypeScript 类型
      if (this.config.generateTypes) {
        await this.generateTypes()
      }
      
      // 5. 创建测试
      if (this.config.createTests) {
        await this.createTests()
      }
      
      // 6. 生成迁移报告
      await this.generateMigrationReport()
      
      console.log('✅ 迁移成功完成!')
      
    } catch (error) {
      console.error('❌ 迁移失败:', error)
      throw error
    }
  }
  
  private async parseReduxSlices(): Promise<void> {
    const slicesPath = path.join(this.config.sourceDir, this.config.slicesDir)
    const sliceFiles = fs.readdirSync(slicesPath)
      .filter(file => file.endsWith('.ts') || file.endsWith('.js'))
    
    for (const file of sliceFiles) {
      const filePath = path.join(slicesPath, file)
      const slice = await this.parseSliceFile(filePath)
      if (slice) {
        this.reduxSlices.push(slice)
        this.migrationLog.push(`✓ 解析 Redux slice: ${file}`)
      }
    }
  }
  
  private async parseSliceFile(filePath: string): Promise<ReduxSlice | null> {
    const content = fs.readFileSync(filePath, 'utf-8')
    const ast = parse(content, {
      sourceType: 'module',
      plugins: ['typescript', 'decorators-legacy']
    })
    
    let slice: ReduxSlice | null = null
    
    traverse(ast, {
      CallExpression(path) {
        // 检测 createSlice 调用
        if (
          t.isIdentifier(path.node.callee, { name: 'createSlice' }) ||
          (t.isMemberExpression(path.node.callee) &&
           t.isIdentifier(path.node.callee.property, { name: 'createSlice' }))
        ) {
          const arg = path.node.arguments[0]
          if (t.isObjectExpression(arg)) {
            slice = this.extractSliceFromObject(arg)
          }
        }
      }
    })
    
    return slice
  }
  
  private extractSliceFromObject(obj: t.ObjectExpression): ReduxSlice {
    const slice: ReduxSlice = {
      name: '',
      initialState: {},
      reducers: {},
      actions: {},
      selectors: {}
    }
    
    obj.properties.forEach(prop => {
      if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
        const key = prop.key.name
        
        switch (key) {
          case 'name':
            if (t.isStringLiteral(prop.value)) {
              slice.name = prop.value.value
            }
            break
          case 'initialState':
            slice.initialState = this.extractValue(prop.value)
            break
          case 'reducers':
            if (t.isObjectExpression(prop.value)) {
              slice.reducers = this.extractReducers(prop.value)
            }
            break
          case 'extraReducers':
            // 处理 extraReducers (通常用于异步 actions)
            break
        }
      }
    })
    
    return slice
  }
  
  private extractReducers(obj: t.ObjectExpression): Record<string, Function> {
    const reducers: Record<string, Function> = {}
    
    obj.properties.forEach(prop => {
      if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
        const name = prop.key.name
        if (t.isFunction(prop.value) || t.isArrowFunctionExpression(prop.value)) {
          reducers[name] = prop.value as any
        }
      }
    })
    
    return reducers
  }
  
  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> {
    for (const slice of this.reduxSlices) {
      await this.createPiniaStore(slice)
    }
  }
  
  private async createPiniaStore(slice: ReduxSlice): Promise<void> {
    const storeContent = this.generatePiniaStore(slice)
    const filePath = path.join(this.config.targetDir, 'stores', `${slice.name}.ts`)
    
    await this.ensureDirectoryExists(path.dirname(filePath))
    fs.writeFileSync(filePath, storeContent)
    
    this.migrationLog.push(`✓ 创建 Pinia store: ${slice.name}.ts`)
  }
  
  private generatePiniaStore(slice: ReduxSlice): string {
    const stateProps = Object.keys(slice.initialState)
    const reducerNames = Object.keys(slice.reducers)
    
    return `import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 从 Redux slice 迁移: ${slice.name}
export const use${this.capitalize(slice.name)}Store = defineStore('${slice.name}', () => {
  // 状态 (从 Redux initialState 迁移)
${this.generateStateDeclarations(slice.initialState)}

  // 计算属性 (从 Redux selectors 迁移)
${this.generateComputedProperties(slice.selectors)}

  // Actions (从 Redux reducers 迁移)
${this.generateActions(slice.reducers)}

  return {
    // 状态
${stateProps.map(prop => `    ${prop}`).join(',\n')},
    
    // 计算属性
${Object.keys(slice.selectors).map(selector => `    ${selector}`).join(',\n')},
    
    // Actions
${reducerNames.map(reducer => `    ${reducer}`).join(',\n')}
  }
})

// 类型定义
export interface ${this.capitalize(slice.name)}State {
${this.generateStateInterface(slice.initialState)}
}

// Action 类型
export interface ${this.capitalize(slice.name)}Actions {
${reducerNames.map(name => `  ${name}: (payload?: any) => void`).join('\n')}
}
`
  }
  
  private generateStateDeclarations(initialState: any): string {
    return Object.entries(initialState)
      .map(([key, value]) => {
        const defaultValue = JSON.stringify(value)
        return `  const ${key} = ref(${defaultValue})`
      })
      .join('\n')
  }
  
  private generateComputedProperties(selectors: Record<string, Function>): string {
    return Object.entries(selectors)
      .map(([name, selector]) => {
        const selectorCode = this.convertReduxSelectorToPinia(selector)
        return `  const ${name} = computed(() => {\n    ${selectorCode}\n  })`
      })
      .join('\n\n')
  }
  
  private generateActions(reducers: Record<string, Function>): string {
    return Object.entries(reducers)
      .map(([name, reducer]) => {
        const actionCode = this.convertReduxReducerToPinia(reducer)
        return `  function ${name}(payload?: any) {\n    ${actionCode}\n  }`
      })
      .join('\n\n')
  }
  
  private generateStateInterface(initialState: any): string {
    return Object.entries(initialState)
      .map(([key, value]) => {
        const type = this.inferType(value)
        return `  ${key}: ${type}`
      })
      .join('\n')
  }
  
  private convertReduxSelectorToPinia(selector: Function): string {
    let code = selector.toString()
    
    // 替换 Redux selector 参数
    code = code.replace(/\(state\)/g, '()')
    code = code.replace(/state\./g, '')
    
    return code
  }
  
  private convertReduxReducerToPinia(reducer: Function): string {
    let code = reducer.toString()
    
    // 替换 Redux reducer 参数
    code = code.replace(/\(state, action\)/g, '(payload)')
    code = code.replace(/action\.payload/g, 'payload')
    code = code.replace(/action\.type/g, "'action'")
    
    // 将 immutable 更新转换为直接赋值
    code = code.replace(/return\s*{\s*\.\.\.state,\s*([^}]+)\s*}/g, (match, updates) => {
      const assignments = updates.split(',').map((update: string) => {
        const [key, value] = update.split(':').map((s: string) => s.trim())
        return `${key}.value = ${value}`
      })
      return assignments.join('\n    ')
    })
    
    // 处理数组操作
    code = code.replace(/state\.([\w]+)\.push\(/g, '$1.value.push(')
    code = code.replace(/state\.([\w]+)\.filter\(/g, '$1.value = $1.value.filter(')
    code = code.replace(/state\.([\w]+)\.map\(/g, '$1.value = $1.value.map(')
    
    // 处理直接赋值
    code = code.replace(/state\.([\w]+)\s*=/g, '$1.value =')
    
    return code
  }
  
  private async convertMiddleware(): Promise<void> {
    const middlewareContent = this.generatePiniaMiddleware()
    const filePath = path.join(this.config.targetDir, 'plugins', 'redux-compat.ts')
    
    await this.ensureDirectoryExists(path.dirname(filePath))
    fs.writeFileSync(filePath, middlewareContent)
    
    this.migrationLog.push(`✓ 创建中间件兼容层: redux-compat.ts`)
  }
  
  private generatePiniaMiddleware(): string {
    return `import { PiniaPluginContext } from 'pinia'

// Redux 中间件兼容层
export interface ReduxAction {
  type: string
  payload?: any
}

export interface ReduxMiddleware {
  (store: any): (next: any) => (action: ReduxAction) => any
}

// 日志中间件 (类似 redux-logger)
export const loggerPlugin = ({ store, options }: PiniaPluginContext) => {
  store.$subscribe((mutation, state) => {
    console.group(`🔄 ${store.$id} - ${mutation.type}`)
    console.log('Payload:', mutation.payload)
    console.log('Previous State:', mutation.oldValue)
    console.log('Current State:', state)
    console.groupEnd()
  })
}

// 持久化中间件 (类似 redux-persist)
export const persistPlugin = ({ store, options }: PiniaPluginContext) => {
  const storageKey = \`pinia-\${store.$id}\`
  
  // 从 localStorage 恢复状态
  const savedState = localStorage.getItem(storageKey)
  if (savedState) {
    try {
      const parsedState = JSON.parse(savedState)
      store.$patch(parsedState)
    } catch (error) {
      console.warn('无法恢复保存的状态:', error)
    }
  }
  
  // 监听状态变化并保存
  store.$subscribe((mutation, state) => {
    localStorage.setItem(storageKey, JSON.stringify(state))
  })
}

// 异步操作中间件 (类似 redux-thunk)
export const thunkPlugin = ({ store }: PiniaPluginContext) => {
  // Pinia 原生支持异步操作,此插件主要用于兼容性
  const originalActions = { ...store }
  
  Object.keys(originalActions).forEach(key => {
    if (typeof originalActions[key] === 'function') {
      const originalAction = originalActions[key]
      
      store[key] = async (...args: any[]) => {
        try {
          const result = await originalAction.apply(store, args)
          return result
        } catch (error) {
          console.error(\`Action \${key} 执行失败:\`, error)
          throw error
        }
      }
    }
  })
}

// 开发工具中间件
export const devToolsPlugin = ({ store }: PiniaPluginContext) => {
  if (typeof window !== 'undefined' && window.__VUE_DEVTOOLS_GLOBAL_HOOK__) {
    store.$subscribe((mutation, state) => {
      window.__VUE_DEVTOOLS_GLOBAL_HOOK__.emit('pinia:mutation', {
        storeName: store.$id,
        type: mutation.type,
        payload: mutation.payload,
        state
      })
    })
  }
}

// 错误处理中间件
export const errorHandlerPlugin = ({ store }: PiniaPluginContext) => {
  const originalActions = { ...store }
  
  Object.keys(originalActions).forEach(key => {
    if (typeof originalActions[key] === 'function') {
      const originalAction = originalActions[key]
      
      store[key] = (...args: any[]) => {
        try {
          const result = originalAction.apply(store, args)
          
          // 如果是 Promise,处理异步错误
          if (result && typeof result.catch === 'function') {
            return result.catch((error: Error) => {
              console.error(\`异步 action \${key} 失败:\`, error)
              // 可以在这里添加错误报告逻辑
              throw error
            })
          }
          
          return result
        } catch (error) {
          console.error(\`同步 action \${key} 失败:\`, error)
          // 可以在这里添加错误报告逻辑
          throw error
        }
      }
    }
  })
}
`
  }
  
  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 `// 从 Redux 迁移自动生成的类型

// 重新导出所有 store 类型
${this.reduxSlices
  .map(slice => `export * from '../stores/${slice.name}'`)
  .join('\n')}

// 全局 store 类型
export interface AppStores {
${this.reduxSlices
  .map(slice => `  ${slice.name}: ReturnType<typeof use${this.capitalize(slice.name)}Store>`)
  .join('\n')}
}

// Redux 兼容性类型
export interface ReduxAction {
  type: string
  payload?: any
}

export interface ReduxState {
${this.reduxSlices
  .map(slice => `  ${slice.name}: ${this.capitalize(slice.name)}State`)
  .join('\n')}
}
`
  }
  
  private async createTests(): Promise<void> {
    for (const slice of this.reduxSlices) {
      await this.createStoreTest(slice)
    }
  }
  
  private async createStoreTest(slice: ReduxSlice): Promise<void> {
    const testContent = this.generateStoreTest(slice)
    const filePath = path.join(this.config.targetDir, 'tests', 'stores', `${slice.name}.test.ts`)
    
    await this.ensureDirectoryExists(path.dirname(filePath))
    fs.writeFileSync(filePath, testContent)
    
    this.migrationLog.push(`✓ 创建测试: ${slice.name}.test.ts`)
  }
  
  private generateStoreTest(slice: ReduxSlice): string {
    const storeHook = `use${this.capitalize(slice.name)}Store`
    
    return `import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { ${storeHook} } from '../../stores/${slice.name}'

describe('${slice.name} Store (从 Redux 迁移)', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

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

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

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

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

## 迁移摘要

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

## 迁移的 Slices

${this.reduxSlices.map(slice => `### ${slice.name}
- 状态属性: ${Object.keys(slice.initialState).length}
- Reducers: ${Object.keys(slice.reducers).length}
- Selectors: ${Object.keys(slice.selectors).length}`).join('\n\n')}

## 需要手动完成的步骤

1. 检查生成的 stores 并更新业务逻辑
2. 更新组件从 Redux 到 Pinia 的导入
3. 用 Pinia 等价物替换 \`useSelector\`\`useDispatch\`
4. 转换异步 actions (Redux Thunk/Saga)
5. 更新中间件配置
6. 彻底测试所有功能
7. 更新文档

## 破坏性变更

- 不再需要 actions 和 reducers 分离
- 状态是可变的(使用 Vue 的响应性)
- 不再需要 dispatch - 直接调用 actions
- 选择器现在是计算属性
- 中间件系统不同(Pinia plugins)

## 下一步

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

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

// CLI 使用
if (require.main === module) {
  const config: MigrationConfig = {
    sourceDir: './src',
    targetDir: './src',
    slicesDir: 'store/slices',
    preserveStructure: true,
    generateTypes: true,
    createTests: true,
    convertMiddleware: true
  }
  
  migrateReduxToPinia(config).catch(console.error)
}

Redux 兼容性层

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

// Redux 兼容性接口
export interface ReduxCompatStore {
  getState: () => any
  dispatch: (action: { type: string; payload?: any }) => void
  subscribe: (listener: () => void) => () => void
}

// 为 Pinia stores 创建类似 Redux 的接口
export function createReduxCompat(piniaStore: any): ReduxCompatStore {
  const listeners: (() => void)[] = []
  
  // 监听状态变化
  piniaStore.$subscribe(() => {
    listeners.forEach(listener => listener())
  })
  
  return {
    getState() {
      return piniaStore.$state
    },
    
    dispatch(action: { type: string; payload?: any }) {
      // 将 Redux actions 映射到 Pinia actions
      const actionName = action.type.toLowerCase()
      if (typeof piniaStore[actionName] === 'function') {
        piniaStore[actionName](action.payload)
      } else {
        console.warn(`Action ${action.type} 在 store 中未找到`)
      }
    },
    
    subscribe(listener: () => void) {
      listeners.push(listener)
      
      // 返回取消订阅函数
      return () => {
        const index = listeners.indexOf(listener)
        if (index > -1) {
          listeners.splice(index, 1)
        }
      }
    }
  }
}

// Pinia 的 Redux 风格 hooks
export function useSelector<T>(selector: (state: any) => T, storeId?: string): T {
  // 这是一个简化实现 - 实际中需要更复杂的逻辑
  const store = inject(storeId || 'defaultStore') as any
  return selector(store.$state)
}

export function useDispatch(storeId?: string) {
  const store = inject(storeId || 'defaultStore') as any
  
  return (action: { type: string; payload?: any }) => {
    const actionName = action.type.toLowerCase()
    if (typeof store[actionName] === 'function') {
      return store[actionName](action.payload)
    }
  }
}

// Redux Toolkit 风格的 action creators
export function createAction<T = void>(type: string) {
  const actionCreator = (payload: T) => ({ type, payload })
  actionCreator.type = type
  actionCreator.toString = () => type
  return actionCreator
}

// 异步 action creator (类似 createAsyncThunk)
export function createAsyncAction<T, R>(
  type: string,
  asyncFn: (payload: T) => Promise<R>
) {
  return {
    pending: createAction(`${type}/pending`),
    fulfilled: createAction<R>(`${type}/fulfilled`),
    rejected: createAction<Error>(`${type}/rejected`),
    
    async execute(payload: T, dispatch: any) {
      dispatch(this.pending())
      try {
        const result = await asyncFn(payload)
        dispatch(this.fulfilled(result))
        return result
      } catch (error) {
        dispatch(this.rejected(error as Error))
        throw error
      }
    }
  }
}

模式转换示例

1. Redux Slice 到 Pinia Store

迁移前 (Redux Toolkit):

typescript
// store/slices/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface CounterState {
  value: number
  history: string[]
}

const initialState: CounterState = {
  value: 0,
  history: []
}

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1
      state.history.push(`增加到 ${state.value}`)
    },
    decrement: (state) => {
      state.value -= 1
      state.history.push(`减少到 ${state.value}`)
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload
      state.history.push(`增加 ${action.payload} 到 ${state.value}`)
    },
    reset: (state) => {
      state.value = 0
      state.history = []
    }
  }
})

export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions
export default counterSlice.reducer

// 选择器
export const selectCount = (state: { counter: CounterState }) => state.counter.value
export const selectHistory = (state: { counter: CounterState }) => state.counter.history
export const selectIsPositive = (state: { counter: CounterState }) => state.counter.value > 0

迁移后 (Pinia):

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

export const useCounterStore = defineStore('counter', () => {
  // 状态
  const value = ref(0)
  const history = ref<string[]>([])
  
  // 计算属性 (选择器)
  const isPositive = computed(() => value.value > 0)
  const doubleValue = computed(() => value.value * 2)
  
  // Actions (合并 reducers)
  function increment() {
    value.value += 1
    history.value.push(`增加到 ${value.value}`)
  }
  
  function decrement() {
    value.value -= 1
    history.value.push(`减少到 ${value.value}`)
  }
  
  function incrementByAmount(amount: number) {
    value.value += amount
    history.value.push(`增加 ${amount} 到 ${value.value}`)
  }
  
  function reset() {
    value.value = 0
    history.value = []
  }
  
  return {
    // 状态
    value,
    history,
    
    // 计算属性
    isPositive,
    doubleValue,
    
    // Actions
    increment,
    decrement,
    incrementByAmount,
    reset
  }
})

2. 异步 Actions (Redux Thunk 到 Pinia)

迁移前 (Redux Thunk):

typescript
// store/slices/userSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { userAPI } from '../api'

interface UserState {
  currentUser: User | null
  loading: boolean
  error: string | null
}

const initialState: UserState = {
  currentUser: null,
  loading: false,
  error: null
}

// 异步 thunk
export const loginUser = createAsyncThunk(
  'user/login',
  async (credentials: LoginCredentials, { rejectWithValue }) => {
    try {
      const user = await userAPI.login(credentials)
      return user
    } catch (error) {
      return rejectWithValue(error.message)
    }
  }
)

export const fetchUserProfile = createAsyncThunk(
  'user/fetchProfile',
  async (userId: string) => {
    const profile = await userAPI.getProfile(userId)
    return profile
  }
)

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    logout: (state) => {
      state.currentUser = null
      state.error = null
    },
    clearError: (state) => {
      state.error = null
    }
  },
  extraReducers: (builder) => {
    builder
      .addCase(loginUser.pending, (state) => {
        state.loading = true
        state.error = null
      })
      .addCase(loginUser.fulfilled, (state, action) => {
        state.loading = false
        state.currentUser = action.payload
      })
      .addCase(loginUser.rejected, (state, action) => {
        state.loading = false
        state.error = action.payload as string
      })
      .addCase(fetchUserProfile.fulfilled, (state, action) => {
        if (state.currentUser) {
          state.currentUser = { ...state.currentUser, ...action.payload }
        }
      })
  }
})

export const { logout, clearError } = userSlice.actions
export default userSlice.reducer

迁移后 (Pinia):

typescript
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { userAPI } from '@/api'

interface User {
  id: string
  name: string
  email: string
}

interface LoginCredentials {
  email: string
  password: string
}

export const useUserStore = defineStore('user', () => {
  // 状态
  const currentUser = ref<User | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)
  
  // 计算属性
  const isAuthenticated = computed(() => !!currentUser.value)
  const userName = computed(() => currentUser.value?.name || '访客')
  
  // Actions
  async function loginUser(credentials: LoginCredentials) {
    loading.value = true
    error.value = null
    
    try {
      const user = await userAPI.login(credentials)
      currentUser.value = user
      return user
    } catch (err) {
      error.value = err instanceof Error ? err.message : '登录失败'
      throw err
    } finally {
      loading.value = false
    }
  }
  
  async function fetchUserProfile(userId: string) {
    try {
      const profile = await userAPI.getProfile(userId)
      if (currentUser.value) {
        currentUser.value = { ...currentUser.value, ...profile }
      }
      return profile
    } catch (err) {
      error.value = err instanceof Error ? err.message : '获取用户资料失败'
      throw err
    }
  }
  
  function logout() {
    currentUser.value = null
    error.value = null
  }
  
  function clearError() {
    error.value = null
  }
  
  return {
    // 状态
    currentUser,
    loading,
    error,
    
    // 计算属性
    isAuthenticated,
    userName,
    
    // Actions
    loginUser,
    fetchUserProfile,
    logout,
    clearError
  }
})

3. 组件迁移

迁移前 (React + Redux):

tsx
// components/Counter.tsx
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { increment, decrement, incrementByAmount } from '../store/slices/counterSlice'
import type { RootState } from '../store'

const Counter: React.FC = () => {
  const count = useSelector((state: RootState) => state.counter.value)
  const history = useSelector((state: RootState) => state.counter.history)
  const dispatch = useDispatch()
  
  const handleIncrement = () => {
    dispatch(increment())
  }
  
  const handleDecrement = () => {
    dispatch(decrement())
  }
  
  const handleIncrementByAmount = () => {
    dispatch(incrementByAmount(5))
  }
  
  return (
    <div>
      <p>计数: {count}</p>
      <button onClick={handleIncrement}>+</button>
      <button onClick={handleDecrement}>-</button>
      <button onClick={handleIncrementByAmount}>+5</button>
      
      <div>
        <h3>历史:</h3>
        <ul>
          {history.map((item, index) => (
            <li key={index}>{item}</li>
          ))}
        </ul>
      </div>
    </div>
  )
}

export default Counter

迁移后 (Vue + Pinia):

vue
<!-- components/Counter.vue -->
<template>
  <div>
    <p>计数: {{ counter.value }}</p>
    <button @click="counter.increment">+</button>
    <button @click="counter.decrement">-</button>
    <button @click="() => counter.incrementByAmount(5)">+5</button>
    
    <div>
      <h3>历史:</h3>
      <ul>
        <li v-for="(item, index) in counter.history" :key="index">
          {{ item }}
        </li>
      </ul>
    </div>
  </div>
</template>

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

const counter = useCounterStore()
</script>

迁移检查清单

迁移前

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

迁移中

  • [ ] 将 Redux slices 转换为 Pinia stores
  • [ ] 将 reducers 转换为 actions
  • [ ] 将选择器转换为计算属性
  • [ ] 转换异步 actions (thunks/sagas)
  • [ ] 更新中间件为 Pinia plugins
  • [ ] 处理跨 store 依赖

迁移后

  • [ ] 更新所有组件导入
  • [ ] 用 Pinia 等价物替换 Redux hooks
  • [ ] 测试所有功能
  • [ ] 更新文档
  • [ ] 移除 Redux 依赖
  • [ ] 更新构建配置

测试迁移

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('Redux 到 Pinia 迁移', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  describe('Counter Store 迁移', () => {
    it('应该保持与 Redux 版本相同的功能', () => {
      const counter = useCounterStore()
      
      // 测试初始状态
      expect(counter.value).toBe(0)
      expect(counter.history).toEqual([])
      
      // 测试 actions
      counter.increment()
      expect(counter.value).toBe(1)
      expect(counter.history).toContain('增加到 1')
      
      counter.incrementByAmount(5)
      expect(counter.value).toBe(6)
      expect(counter.history).toContain('增加 5 到 6')
      
      // 测试计算属性
      expect(counter.isPositive).toBe(true)
      expect(counter.doubleValue).toBe(12)
    })
  })

  describe('User Store 迁移', () => {
    it('应该处理异步操作', async () => {
      const user = useUserStore()
      
      expect(user.isAuthenticated).toBe(false)
      expect(user.loading).toBe(false)
      
      // 模拟登录
      const mockCredentials = {
        email: 'test@example.com',
        password: 'password'
      }
      
      // 注意:在实际测试中,你需要模拟 API 调用
      // await user.loginUser(mockCredentials)
      
      // expect(user.isAuthenticated).toBe(true)
      // expect(user.currentUser).toBeTruthy()
    })
  })
})

最佳实践

  1. 渐进式迁移: 一次迁移一个 slice
  2. 保持兼容性: 在过渡期间使用兼容性层
  3. 彻底测试: 确保迁移后所有功能正常工作
  4. 性能监控: 监控迁移前后的性能
  5. 团队培训: 确保团队理解 Pinia 概念
  6. 文档更新: 在迁移过程中保持文档更新
  7. 错误处理: 确保错误处理逻辑正确迁移
  8. 类型安全: 充分利用 TypeScript 的类型安全特性

这个迁移指南提供了从 Redux 迁移到 Pinia 的全面方法,确保平滑过渡和功能完整性。

Released under the MIT License.