24 KiB
24 KiB
React 到 Vue 完整迁移方案
📋 文档说明
本文档提供从 React 技术栈完整迁移到 Vue 3 技术栈的详细方案,包括技术选型、迁移步骤、代码转换示例和测试策略。
🎯 迁移目标
- 完全移除 React 依赖
- 使用 Vue 3 Composition API
- 保持所有现有功能
- 保持 Tailwind CSS 样式
- 保持 TypeScript 类型系统
🛠️ 技术选型
核心框架
- Vue:
^3.4.0(使用 Composition API) - TypeScript:
^5.2.2(保持不变) - Vite:
^5.0.8(保持不变,更换插件)
状态管理
- Pinia:
^2.1.0(推荐,替代 Context API)
路由(可选)
- Vue Router:
^4.2.0(如果需要路由功能)
UI 库
- 图标库:
lucide-vue-next:^0.344.0 - 图表库:
echarts-for-vue:^1.2.0或vue-echarts:^6.6.0
样式
- Tailwind CSS:
^3.3.6(保持不变)
📦 依赖包替换清单
需要移除的依赖
{
"react": "^18.2.0",
"react-dom": "^18.2.0",
"lucide-react": "^0.344.0",
"@vitejs/plugin-react": "^4.2.1",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5"
}
需要添加的依赖
{
"vue": "^3.4.0",
"@vitejs/plugin-vue": "^5.0.0",
"vue-tsc": "^1.8.25",
"lucide-vue-next": "^0.344.0",
"pinia": "^2.1.0",
"vue-router": "^4.2.0",
"@vueuse/core": "^10.7.0"
}
开发依赖
{
"@vitejs/plugin-vue": "^5.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"eslint-plugin-vue": "^9.20.0"
}
🗂️ 迁移阶段规划
阶段一:项目基础配置(1-2 天)
1.1 更新 package.json
{
"name": "finyx-frontend",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "^3.4.0",
"pinia": "^2.1.0",
"vue-router": "^4.2.0",
"lucide-vue-next": "^0.344.0",
"@vueuse/core": "^10.7.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"eslint-plugin-vue": "^9.20.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"tailwindcss": "^3.3.6",
"vue-tsc": "^1.8.25"
}
}
1.2 更新 vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
host: '0.0.0.0',
port: 5173,
strictPort: false,
open: false,
},
preview: {
host: '0.0.0.0',
port: 4173,
},
})
1.3 更新 tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
1.4 创建 env.d.ts
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
阶段二:类型定义迁移(1 天)
类型定义文件基本不需要修改,只需确保兼容 Vue。
src/types/index.ts - 保持不变
阶段三:工具组件迁移(2-3 天)
3.1 ProgressBar 组件
React 版本 (src/components/ProgressBar.tsx):
import React from 'react';
interface ProgressBarProps {
percent: number;
status: string;
}
export const ProgressBar: React.FC<ProgressBarProps> = ({ percent, status }) => {
// ...
};
Vue 版本 (src/components/ProgressBar.vue):
<template>
<div class="w-full bg-slate-200 rounded-full h-2">
<div
:class="colorClass"
class="h-2 rounded-full"
:style="{ width: `${percent}%` }"
></div>
</div>
</template>
<script setup lang="ts">
interface Props {
percent: number;
status: string;
}
const props = defineProps<Props>();
const colorClass = computed(() => {
const colorMap: Record<string, string> = {
risk: 'bg-red-500',
warning: 'bg-amber-500',
review: 'bg-purple-500',
new: 'bg-slate-300',
};
return colorMap[props.status] || 'bg-blue-500';
});
</script>
3.2 Toast 组件迁移
React 版本 (src/components/Toast.tsx):
export const useToast = () => {
const [toasts, setToasts] = React.useState<Toast[]>([]);
// ...
};
Vue 版本 (src/components/Toast.vue):
<template>
<div v-if="toasts.length > 0" class="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
<div
v-for="toast in toasts"
:key="toast.id"
class="pointer-events-auto"
>
<ToastItem :toast="toast" @close="closeToast" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import ToastItem from './ToastItem.vue';
import type { Toast, ToastType } from './types';
const toasts = ref<Toast[]>([]);
const showToast = (message: string, type: ToastType = 'info', duration?: number) => {
const id = Math.random().toString(36).substring(7);
const newToast: Toast = { id, message, type, duration };
toasts.value.push(newToast);
setTimeout(() => {
closeToast(id);
}, duration || 3000);
return id;
};
const closeToast = (id: string) => {
const index = toasts.value.findIndex(t => t.id === id);
if (index > -1) {
toasts.value.splice(index, 1);
}
};
defineExpose({
success: (message: string, duration?: number) => showToast(message, 'success', duration),
error: (message: string, duration?: number) => showToast(message, 'error', duration),
warning: (message: string, duration?: number) => showToast(message, 'warning', duration),
info: (message: string, duration?: number) => showToast(message, 'info', duration),
});
</script>
Toast Context 迁移为 Pinia Store (src/stores/toast.ts):
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type { Toast, ToastType } from '@/types';
export const useToastStore = defineStore('toast', () => {
const toasts = ref<Toast[]>([]);
const showToast = (message: string, type: ToastType = 'info', duration?: number) => {
const id = Math.random().toString(36).substring(7);
const newToast: Toast = { id, message, type, duration };
toasts.value.push(newToast);
setTimeout(() => {
closeToast(id);
}, duration || 3000);
return id;
};
const closeToast = (id: string) => {
const index = toasts.value.findIndex(t => t.id === id);
if (index > -1) {
toasts.value.splice(index, 1);
}
};
const success = (message: string, duration?: number) => showToast(message, 'success', duration);
const error = (message: string, duration?: number) => showToast(message, 'error', duration);
const warning = (message: string, duration?: number) => showToast(message, 'warning', duration);
const info = (message: string, duration?: number) => showToast(message, 'info', duration);
return {
toasts,
showToast,
closeToast,
success,
error,
warning,
info,
};
});
阶段四:布局组件迁移(2-3 天)
4.1 MainLayout 组件
React 版本:
export const MainLayout: React.FC<MainLayoutProps> = ({
currentView,
setCurrentView,
// ...
}) => (
<div className="flex h-screen w-full bg-slate-900">
{/* ... */}
</div>
);
Vue 版本 (src/layouts/MainLayout.vue):
<template>
<div class="flex h-screen w-full bg-slate-900 font-sans text-slate-900 overflow-hidden">
<Sidebar
:current-view="currentView"
@update:current-view="setCurrentView"
:is-presentation-mode="isPresentationMode"
/>
<div class="flex-1 flex flex-col bg-slate-50 relative overflow-hidden">
<DashboardView
v-if="currentView === 'dashboard'"
@update:current-view="setCurrentView"
@update:current-step="setCurrentStep"
/>
<ProjectListView
v-if="currentView === 'projects'"
@update:current-view="setCurrentView"
@update:current-step="setCurrentStep"
/>
<EngagementView
v-if="currentView === 'engagement'"
:current-step="currentStep"
@update:current-step="setCurrentStep"
@update:current-view="setCurrentView"
:is-presentation-mode="isPresentationMode"
@update:is-presentation-mode="setIsPresentationMode"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Sidebar } from './Sidebar.vue';
import DashboardView from '@/pages/DashboardView.vue';
import ProjectListView from '@/pages/ProjectListView.vue';
import EngagementView from '@/pages/EngagementView.vue';
import type { ViewMode, Step } from '@/types';
const currentView = ref<ViewMode>('projects');
const currentStep = ref<Step>('setup');
const isPresentationMode = ref(false);
const setCurrentView = (view: ViewMode) => {
currentView.value = view;
};
const setCurrentStep = (step: Step) => {
currentStep.value = step;
};
const setIsPresentationMode = (mode: boolean) => {
isPresentationMode.value = mode;
};
</script>
阶段五:页面组件迁移(5-7 天)
5.1 SetupStep 组件示例
React 版本关键代码:
export const SetupStep: React.FC<SetupStepProps> = ({ setCurrentStep }) => {
const toast = useToast();
const [projectName, setProjectName] = useState('');
const [selectedIndustries, setSelectedIndustries] = useState<string[]>([]);
const [errors, setErrors] = useState({});
useEffect(() => {
// 验证逻辑
}, [projectName, selectedIndustries, touched]);
return (
<div className="p-8">
<input
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
/>
</div>
);
};
Vue 版本 (src/pages/engagement/SetupStep.vue):
<template>
<div class="p-8 h-full flex flex-col overflow-y-auto animate-fade-in">
<div class="text-center mb-8">
<h2 class="text-3xl font-bold text-slate-900">项目初始化配置</h2>
<p class="text-slate-500 mt-2">请填写项目基本信息,系统将为您配置数据资产盘点环境</p>
</div>
<div class="max-w-4xl mx-auto w-full space-y-6 mb-8">
<!-- Project Name -->
<div class="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
<label class="block text-sm font-bold text-slate-700 mb-3">
项目名称 <span class="text-red-500">*</span>
</label>
<input
type="text"
v-model="projectName"
@blur="touched.projectName = true"
:class="[
'w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
errors.projectName ? 'border-red-500' : 'border-slate-300'
]"
placeholder="请输入项目名称"
/>
<p v-if="errors.projectName" class="text-red-500 text-sm mt-1">
{{ errors.projectName }}
</p>
</div>
<!-- Industries Selection -->
<div class="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
<label class="block text-sm font-bold text-slate-700 mb-3">
所属行业 <span class="text-red-500">*</span>
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="industry in industries"
:key="industry"
@click="toggleIndustry(industry)"
:class="[
'px-4 py-2 rounded-lg border transition-colors',
selectedIndustries.includes(industry)
? 'bg-blue-50 border-blue-500 text-blue-700'
: 'bg-white border-slate-300 text-slate-700 hover:border-blue-300'
]"
>
{{ industry }}
</button>
</div>
<p v-if="errors.industries" class="text-red-500 text-sm mt-2">
{{ errors.industries }}
</p>
</div>
</div>
<!-- Actions -->
<div class="max-w-4xl mx-auto w-full flex justify-end gap-4">
<button
@click="handleNext"
:disabled="!isValid"
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
下一步
<ArrowRight :size="18" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { ArrowRight } from 'lucide-vue-next';
import { useToastStore } from '@/stores/toast';
import type { Step } from '@/types';
interface Props {
setCurrentStep?: (step: Step) => void;
}
const props = defineProps<Props>();
const toast = useToastStore();
const projectName = ref('');
const companyDescription = ref('');
const owner = ref('');
const selectedIndustries = ref<string[]>([]);
const errors = ref<{
projectName?: string;
companyDescription?: string;
industries?: string;
}>({});
const touched = ref<{
projectName?: boolean;
companyDescription?: boolean;
industries?: boolean;
}>({});
const industries = [
'零售 - 生鲜连锁',
'零售 - 快消品',
'金融 - 商业银行',
// ... 更多行业
];
// 实时验证
watch([projectName, companyDescription, selectedIndustries, touched], () => {
const newErrors: typeof errors.value = {};
if (touched.value.projectName && !projectName.value.trim()) {
newErrors.projectName = '请填写项目名称';
}
if (touched.value.companyDescription && !companyDescription.value.trim()) {
newErrors.companyDescription = '请填写企业及主营业务简介';
}
if (touched.value.industries && selectedIndustries.value.length === 0) {
newErrors.industries = '请至少选择一个所属行业';
}
errors.value = newErrors;
}, { deep: true });
const isValid = computed(() => {
return projectName.value.trim() !== '' &&
companyDescription.value.trim() !== '' &&
selectedIndustries.value.length > 0;
});
const toggleIndustry = (industry: string) => {
const index = selectedIndustries.value.indexOf(industry);
if (index > -1) {
selectedIndustries.value.splice(index, 1);
} else {
selectedIndustries.value.push(industry);
}
touched.value.industries = true;
};
const handleNext = () => {
if (!isValid.value) {
toast.error('请完成所有必填项');
return;
}
toast.success('项目配置已保存');
props.setCurrentStep?.('inventory');
};
</script>
5.2 InventoryStep 组件迁移要点
useMemo 转换:
// React
const sortedData = useMemo(() => {
// 排序逻辑
}, [sortField, sortDirection]);
// Vue
const sortedData = computed(() => {
if (!sortField.value || !sortDirection.value) return inventoryData;
// 排序逻辑
});
useEffect 转换:
// React
useEffect(() => {
if (inventoryMode === 'processing') {
// 处理逻辑
}
}, [inventoryMode]);
// Vue
watch(() => props.inventoryMode, (newMode) => {
if (newMode === 'processing') {
// 处理逻辑
}
}, { immediate: true });
阶段六:主应用入口迁移(1 天)
6.1 main.ts 文件
React 版本 (src/main.tsx):
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
Vue 版本 (src/main.ts):
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './index.css'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#root')
6.2 App.vue
React 版本 (src/App.tsx):
function App() {
const [currentView, setCurrentView] = useState<ViewMode>('projects');
// ...
return (
<ToastProvider>
<MainLayout ... />
</ToastProvider>
);
}
Vue 版本 (src/App.vue):
<template>
<MainLayout
:current-view="currentView"
@update:current-view="setCurrentView"
:current-step="currentStep"
@update:current-step="setCurrentStep"
:is-presentation-mode="isPresentationMode"
@update:is-presentation-mode="setIsPresentationMode"
/>
<ToastContainer />
</template>
<script setup lang="ts">
import { ref } from 'vue';
import MainLayout from '@/layouts/MainLayout.vue';
import ToastContainer from '@/components/ToastContainer.vue';
import type { ViewMode, Step } from '@/types';
const currentView = ref<ViewMode>('projects');
const currentStep = ref<Step>('setup');
const isPresentationMode = ref(false);
const setCurrentView = (view: ViewMode) => {
currentView.value = view;
};
const setCurrentStep = (step: Step) => {
currentStep.value = step;
};
const setIsPresentationMode = (mode: boolean) => {
isPresentationMode.value = mode;
};
</script>
阶段七:图标库迁移(1 天)
7.1 全局替换图标导入
查找替换规则:
// 从
import { IconName } from 'lucide-react';
<IconName size={20} />
// 到
import { IconName } from 'lucide-vue-next';
<IconName :size="20" />
批量替换脚本 (scripts/replace-icons.sh):
#!/bin/bash
# 替换所有文件中的 lucide-react 导入
find src -type f \( -name "*.vue" -o -name "*.ts" -o -name "*.tsx" \) -exec sed -i 's/lucide-react/lucide-vue-next/g' {} \;
# 替换图标属性 size={} 为 :size=""
find src -type f -name "*.vue" -exec sed -i 's/size={\([^}]*\)}/:size="\1"/g' {} \;
阶段八:测试与验证(3-5 天)
8.1 功能测试清单
- Dashboard 页面渲染正常
- 项目列表显示正常
- 项目搜索和筛选功能
- 项目作业台所有步骤正常
- 步骤导航功能
- 表单验证功能
- Toast 通知功能
- 演示模式切换
- 响应式布局
- 所有交互功能
8.2 性能测试
- 首屏加载时间
- 组件渲染性能
- 大数据量表格性能
- 内存使用情况
📝 代码转换对照表
Hooks 转换对照
| React | Vue 3 |
|---|---|
useState(value) |
ref(value) 或 reactive({}) |
useEffect(() => {}, [deps]) |
watch(() => deps, () => {}) 或 watchEffect(() => {}) |
useMemo(() => value, [deps]) |
computed(() => value) |
useCallback(() => {}, [deps]) |
computed(() => () => {}) 或直接定义函数 |
useContext(Context) |
inject(key) 或 Pinia store |
useRef(initial) |
ref(initial) |
useReducer(reducer, initial) |
ref(initial) + 自定义函数或 Pinia |
事件处理转换
| React | Vue 3 |
|---|---|
onClick={handler} |
@click="handler" |
onChange={(e) => {}} |
@change="handler" 或 v-model="value" |
onSubmit={handler} |
@submit.prevent="handler" |
onKeyDown={handler} |
@keydown="handler" |
条件渲染转换
| React | Vue 3 |
|---|---|
{condition && <Component />} |
<Component v-if="condition" /> |
{condition ? <A /> : <B />} |
<A v-if="condition" /><B v-else /> |
{items.map(item => <Item key={id} />)} |
<Item v-for="item in items" :key="id" /> |
Props 传递转换
| React | Vue 3 |
|---|---|
<Component prop={value} /> |
<Component :prop="value" /> |
<Component onClick={handler} /> |
<Component @click="handler" /> |
{...props} |
v-bind="props" |
🔧 配置文件更新清单
1. package.json
- 移除所有 React 相关依赖
- 添加 Vue 3 相关依赖
- 更新构建脚本
- 更新 lint 脚本
2. vite.config.ts
- 替换
@vitejs/plugin-react为@vitejs/plugin-vue - 添加路径别名配置
- 保持服务器配置
3. tsconfig.json
- 更新
jsx选项为"preserve" - 添加 Vue 文件类型支持
- 添加路径别名
4. 创建 env.d.ts
- 添加 Vue 模块声明
- 添加 Vite 客户端类型
5. ESLint 配置
创建 .eslintrc.cjs:
module.exports = {
root: true,
env: {
node: true,
browser: true,
es2021: true,
},
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
],
parserOptions: {
ecmaVersion: 2021,
},
rules: {
// 自定义规则
},
};
🚀 迁移执行步骤
第一步:备份当前代码
git checkout -b vue-migration
git commit -am "Backup before Vue migration"
第二步:安装新依赖
npm uninstall react react-dom lucide-react @vitejs/plugin-react @types/react @types/react-dom
npm install vue@^3.4.0 pinia@^2.1.0 vue-router@^4.2.0 lucide-vue-next@^0.344.0 @vueuse/core@^10.7.0
npm install -D @vitejs/plugin-vue@^5.0.0 vue-tsc@^1.8.25 @vue/eslint-config-typescript@^12.0.0 eslint-plugin-vue@^9.20.0
第三步:更新配置文件
按照上述配置文件更新清单逐一更新。
第四步:迁移组件(按阶段执行)
- 先迁移简单组件(ProgressBar、SidebarItem)
- 迁移工具组件(Toast)
- 迁移布局组件(Sidebar、MainLayout)
- 迁移页面组件(按复杂度从低到高)
第五步:测试验证
- 运行
npm run dev检查编译错误 - 运行
npm run type-check检查类型错误 - 手动测试所有功能
第六步:清理
- 删除所有
.tsx文件 - 删除 React 相关配置
- 更新文档
⚠️ 常见问题与解决方案
问题 1: 图标组件不显示
解决方案: 确保使用 lucide-vue-next 并正确导入:
import { IconName } from 'lucide-vue-next';
// 使用 :size 而不是 size
<IconName :size="20" />
问题 2: TypeScript 类型错误
解决方案:
- 确保
env.d.ts文件存在 - 检查
tsconfig.json配置 - 使用
vue-tsc进行类型检查
问题 3: 响应式数据不更新
解决方案:
- 使用
ref()包装基本类型 - 使用
reactive()包装对象 - 访问 ref 值时使用
.value
问题 4: 事件处理不工作
解决方案:
- 使用
@click而不是onClick - 使用
@submit.prevent阻止默认行为 - 事件参数通过
$event访问
📊 迁移进度跟踪
组件迁移清单
基础组件
- ProgressBar.vue
- SidebarItem.vue
- TableCheckItem.vue
- Toast.vue
- ToastContainer.vue
布局组件
- Sidebar.vue
- MainLayout.vue
页面组件
- DashboardView.vue
- ProjectListView.vue
- EngagementView.vue
工作流步骤组件
- SetupStep.vue
- InventoryStep.vue
- ContextStep.vue
- ValueStep.vue
- DeliveryStep.vue
状态管理
- stores/toast.ts (Pinia)
- stores/app.ts (可选,全局状态)
配置文件
- package.json
- vite.config.ts
- tsconfig.json
- env.d.ts
- .eslintrc.cjs
- main.ts
- App.vue
🎯 验收标准
功能完整性
- ✅ 所有原有功能正常工作
- ✅ 所有交互行为保持一致
- ✅ 所有样式保持一致
代码质量
- ✅ 无 TypeScript 类型错误
- ✅ 无 ESLint 错误
- ✅ 代码符合 Vue 3 最佳实践
性能指标
- ✅ 首屏加载时间 ≤ 2s
- ✅ 页面切换流畅
- ✅ 无内存泄漏
📚 参考资源
📅 时间估算
| 阶段 | 工作量 | 说明 |
|---|---|---|
| 阶段一:项目配置 | 1-2 天 | 配置文件更新 |
| 阶段二:类型定义 | 1 天 | 类型文件检查 |
| 阶段三:工具组件 | 2-3 天 | 简单组件迁移 |
| 阶段四:布局组件 | 2-3 天 | 布局组件迁移 |
| 阶段五:页面组件 | 5-7 天 | 复杂页面迁移 |
| 阶段六:应用入口 | 1 天 | 主文件迁移 |
| 阶段七:图标库 | 1 天 | 图标替换 |
| 阶段八:测试验证 | 3-5 天 | 功能测试和修复 |
| 总计 | 16-25 天 | 约 3-5 周 |
📝 更新记录
- 2025-01-XX: 初始迁移方案创建
👥 贡献者
- Finyx AI Team