Redux 迁移
从 Redux 迁移到 Pinia 的综合指南,包括自动化工具、模式转换策略、兼容性层和详细的迁移示例。
功能特性
- 🔄 自动化迁移工具和脚本
- 📋 逐步迁移检查清单
- 🔀 模式转换策略
- 🛠️ Redux 兼容性层
- 📊 状态结构转换
- 🎯 Action 和 Reducer 映射
- 🧪 测试迁移策略
- 📦 中间件转换
- 🔧 开发工具集成
- 📝 迁移验证工具
迁移概览
主要差异
方面 | Redux | Pinia |
---|---|---|
Store 定义 | 单一 store 带 reducers | 多个独立 stores |
状态更新 | Immutable reducers | 直接状态变更 |
Actions | Action creators + dispatch | 直接方法调用 |
中间件 | Redux middleware | Pinia plugins |
选择器 | Reselect/手动选择器 | 计算属性 |
TypeScript | 复杂类型定义 | 内置 TypeScript 支持 |
开发工具 | Redux DevTools | Vue 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()
})
})
})
最佳实践
- 渐进式迁移: 一次迁移一个 slice
- 保持兼容性: 在过渡期间使用兼容性层
- 彻底测试: 确保迁移后所有功能正常工作
- 性能监控: 监控迁移前后的性能
- 团队培训: 确保团队理解 Pinia 概念
- 文档更新: 在迁移过程中保持文档更新
- 错误处理: 确保错误处理逻辑正确迁移
- 类型安全: 充分利用 TypeScript 的类型安全特性
这个迁移指南提供了从 Redux 迁移到 Pinia 的全面方法,确保平滑过渡和功能完整性。