Vuex 迁移
从 Vuex 迁移到 Pinia 的综合指南,包括自动化迁移工具、逐步转换过程和渐进式迁移的兼容性模式。
功能特性
- 🔄 自动化迁移工具和脚本
- 📋 逐步迁移检查清单
- 🔀 渐进式迁移策略
- 🛠️ Vuex 兼容性层
- 📊 状态结构转换
- 🎯 Action 和 Mutation 映射
- 🧪 测试迁移策略
- 📦 模块系统转换
- 🔧 开发工具集成
- 📝 迁移验证工具
迁移概览
主要差异
方面 | Vuex | Pinia |
---|---|---|
Store 定义 | 单一 store 带模块 | 多个独立 stores |
Mutations | 状态变更必需 | 直接状态变更 |
Actions | 异步操作 | 同步/异步操作 |
Getters | 计算属性 | 计算属性 |
模块 | 嵌套模块 | 独立 stores |
TypeScript | 复杂类型定义 | 内置 TypeScript 支持 |
开发工具 | Vue DevTools | Vue 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)
})
})
})
最佳实践
- 渐进式迁移: 一次迁移一个模块
- 保持兼容性: 在过渡期间使用兼容性层
- 彻底测试: 确保迁移后所有功能正常工作
- 更新文档: 在迁移过程中保持文档更新
- 团队沟通: 与团队成员协调迁移进度
- 性能监控: 监控迁移前后的性能
- 回滚计划: 制定出现问题时的回滚计划
- 培训: 确保团队理解 Pinia 概念和模式
这个迁移指南提供了从 Vuex 迁移到 Pinia 的全面方法,同时保持应用程序的稳定性和功能性。