com um clique
vue2-to-vue3-page-migration
// 指导将单个页面从Vue2升级到Vue3的完整迁移流程,包括分析源页面、类型定义、API方法、页面代码转换、路由注册和UI验证。适用于mall-app-vue3项目的页面迁移工作。
// 指导将单个页面从Vue2升级到Vue3的完整迁移流程,包括分析源页面、类型定义、API方法、页面代码转换、路由注册和UI验证。适用于mall-app-vue3项目的页面迁移工作。
| name | vue2-to-vue3-page-migration |
| description | 指导将单个页面从Vue2升级到Vue3的完整迁移流程,包括分析源页面、类型定义、API方法、页面代码转换、路由注册和UI验证。适用于mall-app-vue3项目的页面迁移工作。 |
本项目从 mall-app-web(Vue2)迁移至 mall-app-vue3(Vue3),采用按页面逐个迁移的策略。每迁移一个页面,确保功能完整、UI 一致后再进行下一个。
| 项目 | 路径 | 技术栈 |
|---|---|---|
| Vue2 源项目 | D:\developer\github\mall-app-web | Vue2 + Vuex + JavaScript |
| Vue3 目标项目 | d:\developer\gitee\mall-app-vue3 | Vue3 + Pinia + TypeScript |
详细的页面迁移进度和迁移顺序请查看 docs/migration-progress.md。
每个页面的迁移遵循以下 7 个阶段,严格按顺序执行:
目标:理解 Vue2 页面的功能、依赖和结构,制定迁移方案。
步骤:
D:\developer\github\mall-app-web\src\pages\<module>\<page>.vuethis.$api.xxx 或 import 的 API 方法)mapState、mapMutations、mapGetters)import 和 components 注册)mixins)docs/api/ 目录下对应的 Controller Markdown 文档,确认该页面涉及的接口定义和返回类型输出:一份包含功能点、依赖关系、文件清单的迁移分析
目标:在 src/types/ 中定义页面所需的 TypeScript 类型。
步骤:
docs/api/ 目录下对应的 Controller Markdown 文档中查找接口定义和数据模型apis/ 目录对应(如 order.ts ↔ order.d.ts)type 定义,不使用 interface/** 注释 */),置于字段上方OmsOrder → 前端 OmsOrder 或省略前缀)/** 前端扩展字段:xxx */ 注释Param 结尾(单数形式)示例:
/**
* 订单
* 对应后端 OmsOrder 类型
*/
export type OmsOrder = {
/** 订单ID */
id: number
/** 订单编号 */
orderSn: string
/** 订单状态 */
status: number
// ...
}
/** 创建订单请求参数 */
export type CreateOrderParam = {
/** 购物车ID列表 */
cartIds: string[]
/** 收货地址ID */
memberReceiveAddressId: number
// ...
}
目标:在 src/apis/ 中定义页面所需的 API 请求方法。
步骤:
types/ 目录对应API 结尾(如 getOrderListAPI)/** 注释 */ 说明用途http<T>() 声明返回数据类型params: { ids: '1,2' })Param 结尾示例:
import { http } from '@/utils/http'
import type { OmsOrder } from '@/types/order'
import type { CreateOrderParam, PageParam } from '@/types/order'
import type { CommonPage } from '@/types/common'
/** 获取订单列表 */
export const getOrderListAPI = (params: PageParam) => {
return http<CommonPage<OmsOrder>>({
method: 'GET',
url: '/order/list',
data: params,
})
}
/** 创建订单 */
export const createOrderAPI = (data: CreateOrderParam) => {
return http<OmsOrder>({
method: 'POST',
url: '/order/create',
data,
})
}
目标:创建 Vue3 页面文件,完成核心代码迁移。
步骤:
src/pages/<module>/<page>.vue<template> → <script setup lang="ts"> → <style lang="scss" scoped> 顺序upx 替换为 rpx,检查 SCSS 变量计算单位一致性// ===== Vue2 (Vuex) =====
import { mapState, mapMutations } from 'vuex'
export default {
computed: {
...mapState(['userInfo']),
hasLogin() {
return !!this.userInfo
},
},
methods: {
...mapMutations(['login', 'logout']),
handleLogin() {
this.login(data)
},
},
}
// ===== Vue3 (Pinia) =====
import { useMemberStore } from '@/stores/member'
// 获取会员store
const memberStore = useMemberStore()
// 是否登录
const hasLogin = computed(() => !!memberStore.memberInfo)
// 登录
await memberStore.memberLogin(username, password)
// 退出
memberStore.memberLogout()
// ===== Vue2 =====
data() {
return {
cartList: [],
totalPrice: 0,
allChecked: false,
empty: false
}
}
// ===== Vue3 =====
// 购物车中商品列表
const cartList = ref<CartItem[]>([])
// 购物车中商品总价
const totalPrice = ref(0)
// 购物车中商品是否全部选中
const allChecked = ref(false)
// 购物车中商品是否为空
const empty = ref(false)
// ===== Vue2 =====
export default {
onLoad(options) { ... },
onShow() { ... },
onReady() { ... },
onHide() { ... },
onUnload() { ... },
}
// ===== Vue3 =====
import { onLoad, onShow } from '@dcloudio/uni-app'
// 页面加载时执行
onLoad((options) => { ... })
// 页面显示时调用
onShow(() => { ... })
// ===== Vue2 (Promise 链式调用) =====
getCartList()
.then((response) => {
this.cartList = response.data
this.calcTotal()
})
.catch((e) => {
console.error(e)
})
// ===== Vue3 (async/await) =====
// 加载购物车数据
const loadData = async () => {
try {
const res = await getCartListAPI()
cartList.value = res.data
calcTotal()
} catch (e) {
console.error('加载购物车失败', e)
}
}
// ===== Vue2 =====
computed: {
hasLogin() {
return !!this.userInfo
}
}
// ===== Vue3 =====
// 是否登录
const hasLogin = computed(() => !!memberStore.memberInfo)
// ===== Vue2 =====
watch: {
'cartList.length'(len) {
this.empty = len === 0
}
}
// ===== Vue3 =====
// 监听购物车列表变化
watch(
() => cartList.value.length,
(len) => {
empty.value = len === 0
},
)
// ===== Vue2 =====
methods: {
handleCheck(index) {
this.cartList[index].checked = !this.cartList[index].checked
this.calcTotal()
}
}
// ===== Vue3 =====
// 选择商品
const handleCheck = (index: number) => {
cartList.value[index].checked = !cartList.value[index].checked
calcTotal()
}
// ===== Vue2 =====
this.$refs.list
// ===== Vue3 =====
const list = ref(null) // 在 script 中声明
// 模板中:<view ref="list">
// ===== Vue2 =====
import UniNumberBox from '@/components/uni-number-box.vue'
export default {
components: { UniNumberBox },
}
// ===== Vue3 =====
// easycom 已开启,uni- 组件无需手动导入
// 自定义组件仍需 import
import uniNumberBox from '@/components/uni-number-box.vue'
uni-app 的路由 API 在 Vue2 和 Vue3 中保持一致:
uni.navigateTo({ url: '/pages/order/order' })
uni.switchTab({ url: '/pages/index/index' })
uni.redirectTo({ url: '/pages/public/login' })
uni.navigateBack()
目标:注册页面路由,确保项目可正常编译。
步骤:
src/pages.json 中添加页面路径和导航栏配置npm run dev:h5,确保无编译错误pages.json 路由配置示例:
{
"path": "pages/order/order",
"style": {
"navigationBarTitleText": "我的订单",
"navigationStyle": "custom"
}
}
目标:确保迁移后的项目可正常编译,无语法和类型错误。UI 对比与功能验证交由用户自行进行。
步骤:
npm run build:h5,确保无编译错误目标:在 docs/migration-progress.md 中记录迁移完成情况,保持进度表与实际一致。
步骤:
docs/migration-progress.md☐ 待迁移 改为 ✅ 已完成YYYY-MM-DD,如 2026-05-06)示例:
假设当前迁移的是商品详情页(pages/product/product),属于第四阶段,更新如下:
### 第四阶段:商品浏览
| 序号 | 页面路径 | 功能 | 源文件 | 代码量 | 状态 | 完成日期 |
| ---- | ------------------------ | -------- | --------------------------- | ------ | --------- | ---------- |
| 8 | `/pages/product/product` | 商品详情 | `pages/product/product.vue` | 32.9KB | ✅ 已完成 | 2026-05-06 |
同时更新迁移总览:
| 统计项 | 数量 | 页面 |
| -------- | ------- | -------------------------------------- |
| 总页面数 | 24 | |
| 已完成 | 19 | ✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅ |
| 待迁移 | 5 | ☐☐☐☐☐ |
| 完成率 | **79%** | |
注意事项:
YYYY-MM-DD✅ 已完成、🔧 进行中、☐ 待迁移、❌ 阻塞Math.floor(已完成 / 总页面数 * 100)迁移新页面时,可直接复制以下骨架,按需填充:
<template>
<view class="container">
<!-- 页面内容 -->
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { useMemberStore } from '@/stores/member'
// ===== 导入 =====
// import { getXxxAPI } from '@/apis/xxx'
// import type { XxxType } from '@/types/xxx'
// ===== Store 相关 =====
// 获取会员store
const memberStore = useMemberStore()
// 是否登录
const hasLogin = computed(() => !!memberStore.memberInfo)
// ===== 页面数据 =====
// 数据列表
const list = ref<XxxType[]>([])
// 是否为空
const empty = ref(false)
// 加载中
const loading = ref(false)
// ===== loadData =====
// 加载页面数据
const loadData = async () => {
if (!hasLogin.value) return
loading.value = true
try {
// const res = await getXxxAPI()
// list.value = res.data
// empty.value = list.value.length === 0
} catch (e) {
console.error('加载数据失败', e)
} finally {
loading.value = false
}
}
// ===== onLoad =====
// 页面加载
onLoad((options) => {
// 处理页面参数
})
// ===== onShow =====
// 页面显示
onShow(() => {
loadData()
})
// ===== watch =====
// ===== 事件处理方法 =====
// 处理xxx操作
const handleXxx = () => {
// ...
}
// ===== 其他方法 =====
</script>
<style lang="scss" scoped>
.container {
// 页面样式
}
</style>
| 问题 | 原因 | 解决方案 |
|---|---|---|
| upx 单位不生效 | uni-app Vue3 不支持 upx | 全部替换为 rpx |
| SCSS 变量计算单位不一致 | 如 $font-sm + 2upx 混用 | 确保同一表达式中单位一致,如 28rpx |
| 边框样式(.b-b / .b-t)缺失 | Vue2 定义在 App.vue 全局样式中 | 在 Vue3 的 App.vue 中补齐伪元素边框样式(transform: scaleY(0.5)) |
| 文字单行省略不生效 | 缺少 .clamp 全局样式 | 在 App.vue 中补充 .clamp 样式(overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block) |
| flex 布局比例不对 | flex-basis: 0% 覆盖了 width | 明确设置 width 和 flex-basis,避免冲突 |
| 图片尺寸异常 | 缺少宽高约束 | 为 image 添加 width: 100%; height: 100% |
| 问题 | 原因 | 解决方案 |
|---|---|---|
ref<T>() 模板中类型推断报错 | ref<T>() 类型为 T | undefined,TS 无法安全推断 | 改为 ref<T | null>(null),模板中用 v-if="xxx" 做 truthy 检查 |
| API 返回类型访问出错 | CommonResult<T> 需通过 .data 访问实际数据 | const res = await xxxAPI() → res.data 才是 T 类型数据 |
uni.request 返回类型不匹配 | UniApp 类型定义不完整 | 使用 as any 绕过类型检查 |
| 导航栏按钮类型错误 | H5 端 titleNView.buttons 仅 App 有效 | H5 端通过 document.querySelectorAll 操作 DOM |
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 删除 API 返回 404 | 参数放在 body 中 | 删除类 API 参数必须用 params(URL 查询字符串)传递 |
| 登录 token 拼接失败 | 后端返回 tokenHead + token | const token = \${loginData.tokenHead}${loginData.token}`` |
| 请求头 Content-Type 不对 | 登录接口需要 form 格式 | 设置 header: { 'content-type': 'application/x-www-form-urlencoded;charset=utf-8' } |
| 问题 | 原因 | 解决方案 |
|---|---|---|
maxlength 不生效 | Vue3 中需动态绑定 | 使用 :maxlength="11" 而非 maxlength="11" |
| 密码框类型不对 | type="password" 在 uni-app 中不通用 | 使用 password 属性 |
| 字体图标不显示 | Vue3 未迁移 fonts.scss 图标定义 | 在 src/styles/fonts.scss 中补齐图标类名 |
| 全局样式未生效 | Vue2 全局样式定义在 App.vue | 确认 Vue3 的 App.vue 已包含所需全局样式 |
页面精简迁移时,组件文件不做任何清理,全部保留,为后续恢复其他页面提供复用基础。
核心业务逻辑(如登录、支付等)放在 Pinia Store 中,页面只负责 UI 交互:
Store:memberLogin(username, password) → 纯业务逻辑(调API、存token、存用户信息)
Page:handleLogin() → UI交互(loading、toast、跳转)+ 调用 store 方法
@/ 代替 src/src/static/ 下src/styles/fonts.scss 中,新增图标需在此文件中添加使用 #ifdef / #endif 处理多端差异:
<!-- #ifdef MP -->
<view>仅小程序端显示</view>
<!-- #endif -->
<!-- #ifdef H5 -->
<view>仅 H5 端显示</view>
<!-- #endif -->