Vue 2 → Vue 3 + Element UI → Element Plus 迁移手册(通用版)
一个自包含、可独立分享的迁移手册。覆盖从 Vue 2 + Element UI(webpack / Vue CLI)升级到 Vue 3 + Vite + Element Plus 的完整模式、检测命令、修复写法和验证流程。适用于:
- 把老 Vue 2 项目现代化迁移到 Vue 3;
- 把 Element UI 升级为 Element Plus;
- 迁移后排查运行时问题(白屏、
el-dialog取消/提交不关闭、下拉框宽度异常或向后端传参错误、控制台报错); - 对已迁移代码做"坑点审计"。
本手册不依赖任何其它技能文件,可直接复制、提交到 git 或上架技能市场。
1. 推荐迁移顺序(先扫后改,分批进行)
- 先全量扫描:用第 2 节的 grep 命令列出所有命中文件,建立"待改清单"。
- 先修会崩/白屏的硬伤(slot-scope、
this.、<style>注释、$vnode、重复路由name、require雷),这些会直接阻断渲染。 - 再修组件 API 与数据绑定(el-dialog
v-model、el-table 选区、el-date-picker格式、自定义 select 包装组件传参)。 - 最后做验证(控制台报错 + 弹框关闭 + 下拉宽度与传参)。
不要一次全改完再验证——分批改、每批抽几个页面本地 npm run dev 验证。
2. 检测:先用 grep 扫描整个代码库
下面这些命令能快速定位绝大多数坑(在 src/ 根目录执行):
# —— 组件语法 / 模板 ——
grep -rn "slot-scope" src/ # 旧 scoped slot 语法(必崩)
grep -rn "scope=\"scope\"" src/ # <template scope="scope"> 旧语法
grep -rn "visible.sync" src/ # .sync 修饰符(已废弃)
grep -rn "v-bind\$listeners\|\$listeners" src/ # $listeners 已移除
grep -rn "\$vnode" src/ # $vnode 已移除
grep -rn "this\." src/ --include=*.vue | grep "<template" # 模板里写 this.(白屏)
# —— Element Plus 重命名 ——
grep -rn "el-dialog|el-table|el-date-picker|el-select" src/ # 大体量组件,逐个核对 API
grep -rn "size=\"mini\"" src/ # Element Plus 无 mini size
grep -rn "getSelectionRows\|\.selection" src/ # el-table 选区 API
grep -rn "value-format\|format=" src/ | grep "el-date-picker" # Day.js 大写
# —— 自定义 select 包装组件(传参大坑)——
grep -rn "organization-select|emp-select|product-supplier-select" src/
grep -rn "OrganizationSelect|EmpSelect|ProductSupplierSelect" src/
# —— 路由 / 指令 / 模块系统 ——
grep -rn "name:" src/router* # 同名 name 静默覆盖
grep -rn "vue-clipboard3\|v-clipboard" src/ # clipboard 指令需重注册
grep -rn "require(" src/ # Vite ESM 无 CommonJS
# —— echarts 在弹框/悬停框内(初始化时序 + 裁剪)——
grep -rn "echarts.init\|\$echarts.init" src/ | grep -i "popover\|tooltip\|dialog\|show" # 弹框内图表
grep -rn "el-popover" src/ | grep -i "chart\|echarts" # 悬停弹框嵌图表
# —— 样式 ——
grep -rn "//" src/ --include=*.vue | grep "<style" # <style> 无 lang 禁 // 注释
# —— echarts 实例放 data()(响应式劫持,致命)——
grep -rn "echarts.init\|\$echarts.init" src/ # 找全部图表初始化点
grep -rn "data()" src/ | grep -i "chart\|echarts" # 实例是否声明在 data() 里
# —— el-dialog 与内部 el-table 同名 ref ——
# 人工核对:同一个 ref 名是否同时用在了 el-dialog 与它的子 el-table 上
# —— this.$set 已移除(Vue 3 无此方法)——
grep -rn "this.\$set\|\$set(" src/ # Vue3 直接赋值即可
# —— 自定义弹窗:裸 v-model vs 命名 v-model:visible ——
grep -rn "v-model:visible\|update:visible" src/ # 子组件用 visible+update:visible 时
grep -rn "<xxx-dialog v-model=" src/ | grep -i "dialog" # 父级是否误用裸 v-model 传 visible
提示:上面若干检测需结合人工核对(如 ref 同名、v-model 模式),grep 仅给出候选。
⚠️ kebab-case 标签
emp-select与 PascalCaseEmpSelect(import/注册名)不会同时命中,扫完标签后务必再扫一遍 PascalCase,避免漏掉"死 import"。
3. 模式目录(含检测 + 修复)
P1. <template scope="scope"> 旧语法 → 具名插槽
- 问题:Vue 3 中
<template scope="scope">直接崩。 - 修复:改为
<template #default="scope">(或<template v-slot:default="scope">)。 - 注意同时把
slot="xxx"属性改为#xxx具名插槽。
P2. .sync 修饰符 → v-model:prop / update:prop
- 问题:Vue 3 移除
.sync。 - 修复:
:visible.sync="x"→v-model:visible="x";子组件this.$emit('update:visible', v)不变。
P3. slot-scope → v-slot(#)
- 问题:
slot-scope已废弃。 - 修复:
slot="header" slot-scope="props"→<template #header="props">。
P4. $listeners 已移除 → 用 v-bind="$attrs" + inheritAttrs: false
- 问题:
this.$listeners在 Vue 3 不存在,组件透传事件失效。 - 修复:父组件透传用
v-bind="$attrs";子组件inheritAttrs: false后手动绑定到根元素。
P5. $vnode 已移除
- 问题:
this.$vnode是undefined,访问即报错。 - 修复:用
this.$.vnode或this.$options.name替代(后者拿组件名最稳)。
P6. 模板里禁写 this.(白屏)
- 问题:Vue 3 模板作用域不再暴露
this,<template>里写this.xxx会白屏/报错。 - 修复:模板里直接写
xxx(数据、计算属性、方法均可直接引用)。this.只在<script>方法体内保留。
P7. <style> 无 lang 时禁止 // 单行注释
- 问题:纯 CSS 不支持
//,PostCSS 报Unexpected '/'、编译失败。 - 修复:用
/* */块注释;或给<style>加lang="scss"。
P8. Element Plus 无 mini size → small
- 问题:
size="mini"在 Element Plus 无效,回退为默认default,布局变松散。 - 修复:统一改为
size="small"(或保留default并调整间距)。全局可在app.use(ElementPlus, { size: 'small' })设置。
P9. el-dialog 的 v-model 受控约定(弹框不关闭的根因)
- 问题:父组件
<PlanDialog v-model="visible" />实际传modelValueprop 并监听update:modelValue。若子组件自己定义visibleprop + 本地dialogVisible镜像 +emit('update:visible'),与父级v-model不匹配,导致"取消/提交"关闭事件传不回父级。 - 修复:子组件直接受控于
modelValue:<!-- 子组件模板 --> <el-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)"> <!-- script --> props: { modelValue: { type: Boolean, default: false } } methods: { handleClose() { this.$emit('update:modelValue', false) } } - 若弹框是页面级(父组件就是
el-dialog本身),确认v-model="dialogVisible"绑的是响应式数据,关闭逻辑this.dialogVisible = false或@close正常。
P10. el-table 选区 API 变更
- 问题:
this.$refs.table.selection在 Element Plus 可能为空或不稳定。 - 修复:用
this.$refs.table.getSelectionRows()获取选中行(返回数组)。
P11. el-date-picker 的 value-format 用 Day.js 大写 YYYY-MM-DD
- 问题:Element Plus 用 Day.js,
value-format/format必须是大写YYYY-MM-DD HH:mm:ss(小写y/d输出乱码、无法选日期、传参错)。 - 坑中坑:如果项目里有自定义的
Date.prototype.format方法(如new Date().format('yyyy-MM-dd')),那是另一套只认小写的实现,不要把它的调用改成大写——两者互不影响,按各自 API 来。
P12. 自定义 el-select 包装组件的传参 bug(高频大坑)★★★★★
- 背景:很多项目自己包了一层
<organization-select>/<emp-select>/<product-supplier-select>(内部用el-select+ 调接口加载选项,emit 整个 option 对象)。 - 典型缺陷:
- 内部
value数据、emit 整个{value, label}或 emp 对象; - Emp 组件 option 用
:value="item.id",但父级@change里写obj.value→ 取到的undefined,向后端传参静默成空(这是最常见的"查不出数据"根因); - 组件内
el-select是width:100%,在 inline 表单里没有显式宽度,导致宽度塌陷。
- 内部
- 推荐修复(彻底去包装):替换为原生
el-select+ 本地数据数组,用v-model直接绑字段。- 数据 API 映射(按项目实际路径调整):
- 组织:
organizationsSelectData(@/api/hr/organization/organization)→res为数组,元素{value, label}。 - 员工:
empsPage(@/api/mdm/emp/emp)→res.data为数组,元素含id、productType.explanation、productDescription。 - 供应商:
productSuppliersSelectData(@/api/mdm/product-supplier/product-supplier)→res为数组,元素{value, label}。
- 组织:
- 模板写法:
<!-- organization / product-supplier --> <el-select v-model="form.orgCode" :clearable="true" filterable placeholder="请选择" style="width:200px"> <el-option v-for="item in organizations" :key="item.value" :label="item.label" :value="item.value" /> </el-select> <!-- emp:注意 value 用 item.id --> <el-select v-model="form.empId" :clearable="true" filterable placeholder="请选择" style="width:200px"> <el-option v-for="item in emps" :key="item.id" :label="'('+item.productType.explanation+')'+item.productDescription" :value="item.id" /> </el-select> - script:
- 删掉对
OrganizationSelect/EmpSelect/ProductSupplierSelect的 import 和components注册; data加organizations: [], emps: [], productSuppliers: [];created()加载:organizationsSelectData().then(r => this.organizations = r || [])、empsPage({}).then(r => this.emps = (r && r.data) || [])、productSuppliersSelectData().then(r => this.productSuppliers = r || []);- 绑定字段必须与原
@change里赋值的字段一致(原obj.value→ 绑那个字段;Emp 原obj.id→ 绑item.id); - 删掉原
@change处理器;若原 change 除设字段外还做联动,把那部分挪到el-select的 native@change; - 保留其它未点名的 ElementSelect 组件(Team/ProductionLine/Machine/Company 等)不动;
- 若原组件用
:is-init-data="false"+ 手动getData(params):把params传给上面的 API 调用(在created或弹框打开方法里);若是动态联动(选 A 后调this.$refs.x.getData(p)重新加载 B 的选项):写一个reloadX(params)方法用相同 API 重新赋值本地数组,并在原触发点调用,替代this.$refs.x.getData(...); reset方法里删掉this.$refs.xxx.value=''这类行(v-model绑定字段会在重置对象里被清空)。
- 删掉对
- 数据 API 映射(按项目实际路径调整):
- 收尾:替换完后务必再 grep 一次 PascalCase(
OrganizationSelect|EmpSelect|ProductSupplierSelect),清掉残留的死 import /components注册(不影响运行但要干净)。
P13. vue-router@4 同名 name 静默覆盖
- 问题:路由配置里若两条路由
name相同,后者静默覆盖前者,菜单点击对应项无反应(仿佛失效)。path不动,只改name。 - 修复:给同名路由加模块前缀(如
gjg1-xxx/gjg2-xxx),保证全局唯一。 - 关联坑:路由
meta.roles引用的角色必须在后端真实存在,否则整棵子树被静默过滤 → 404("幽灵角色"问题)。
P14. vue-clipboard3 需手动重注册 v-clipboard 指令
- 问题:迁移后
v-clipboard指令静默失效(复制没反应)。 - 修复:在入口手动
app.directive('clipboard', Clipboard)重新注册(Clipboard来自vue-clipboard3的导出),否则指令不生效。
P15. require() 在 Vite / ESM 下未定义(访问即崩)★★★
- 问题:Element Plus 项目里仍有几处用
require('element-ui/package.json')、require('script-loader!jsonlint')、require('minio')等 CommonJS 写法,Vite 是 ESM,运行到该行直接ReferenceError: require is not defined,对应页面打开即崩。 - 排查:
grep -rn "require(" src/(排除node_modules与@/、相对路径的合法动态 import)。 - 修复:
- 读取 JSON 版本:
import pkg from 'element-ui/package.json'然后pkg.version(或import { version } from 'element-ui'若导出支持); jsonlint:npm i jsonlint后用import jsonlint from 'jsonlint';minio:改用import * as Minio from 'minio'(注意 minio 是浏览器端 SDK,需确认打包兼容,必要时走后端代理)。
- 读取 JSON 版本:
- 仅这几页会崩,修完即可,不碰其它页面。
- ⚠️ 关键辨析:require 只在文件被 import/执行时才崩。如果含
require()的文件全项目没有任何地方 import 它(死代码),那行require永远不执行,当前不会崩——此时直接删除文件比改require更干净(先用grep -rn "from '@/xxx'" src/确认 0 引用再删)。反之,若文件被业务页面引用(如JsonEditor被表单页用component: () => import(...)懒加载),则访问即崩,必须修。 - 已验证的"真雷"判定法:先
grep出所有require(,再对每个命中文件grep其导入点——有活的 import = 真雷;无 import = 死代码可删。
P16. 组件注册 / 插件注册
- 问题:Vue 2 的
Vue.use(XXX)、new Vue()写法在 Vue 3 不再适用。 - 修复:入口改为
createApp(App).use(ElementPlus).use(Router).mount('#app');全局组件用app.component('Xxx', Xxx)注册。
P17. 函数式组件(functional)改写
- 问题:Vue 2
functional: true组件在 Vue 3 需重写为普通组件或用<script setup>+ 函数式渲染。 - 修复:移除
functional: true,用<script setup>或render函数(接收props/ctx而非h, context)。
P18. echarts 放在 el-popover / el-tooltip 内:图表被裁剪 + 初始化时序 ★★★
- 问题:页面"鼠标悬停弹出框显示图表"时,图表部分内容被裁剪、不在弹框内,或图表宽高为 0。两重根因:
- 弹框宽度不够:
<el-popover>不设width时按内容自适应,但图表div是固定宽(如width:600px),弹框比图表窄 → 图表溢出被裁。 - 初始化时机早:在
mounted或数据返回时立即echarts.init(dom),但 popover 此时display:none/尚未布局,dom尺寸为 0,init拿到 0×0,图表画不出来或错位。
- 弹框宽度不够:
- 排查:
grep -rn "echarts.init\|\$echarts.init" src/ # 找所有图表初始化点 grep -rn "el-popover" src/ | grep -i "chart\|echarts" # 弹框内嵌图表 - 修复(按下面 4 步):
- 给
<el-popover>设显式width(≥ 图表宽度,如width="640"),避免裁剪; - 监听 popover 的
@show事件,在显示后再初始化图表(不要在mounted直接 init); - 每次显示前先
chart.dispose()旧实例(或在data里缓存实例、显示时判断已存在则dispose),避免实例叠加导致内存泄漏与渲染错乱; - init 必须包在
this.$nextTick(() => { ... })里,确保 popover DOM 已展开、尺寸已就绪,最后调chart.resize()。
// 推荐写法骨架 chartShow(item) { this.$nextTick(() => { const el = this.$refs['chart' + item.type][0] if (!el) return const old = this['chart' + item.type] // 缓存的旧实例 if (old) old.dispose() const chart = this.$echarts.init(el) chart.setOption(/* ... */) chart.resize() this['chart' + item.type] = chart }) }- 注意
v-for里ref是数组,取[0];循环内变量名不要和外层参数item冲突(改名it)。 - 若图表在
el-dialog里而非 popover,同样要在@opened后nextTick再 init(dialog 也是先隐藏后显示)。
- 给
P19. 冗余/过时迁移文档与配置(项目整洁度,非运行时坑)
- 问题:迁移过程中容易留下重复或过时的文档与配置:
- 根目录散落的
MIGRATION.md与documents/下的同名/同主题文档内容重叠,且有的把"未修复"写成"已修复"(误导后人); - 前端子目录自建的
.gitignore与父目录.gitignore规则 100% 重复,造成维护双份; - 文档里声称某
require已修,但代码里那行require还在(如 JsonEditor)。
- 根目录散落的
- 修复约定(跨项目通用):
- 项目说明文档统一放一个目录(如
documents/),别在根目录散放; - 子目录不重复建
.gitignore,统一用父目录那份(gitignore 的无前导斜杠规则本就匹配任意层级); - 合并文档时先用 grep 核实代码真实状态,文档陈述必须以代码为准(尤其"已修复"类断言)。
- 项目说明文档统一放一个目录(如
P20. echarts 实例严禁放入 data()(Vue3 响应式劫持,致命)★★★
- 问题:Vue 3 中
data()返回的对象会被reactive()包成 Proxy。若把echarts.init(dom)的实例存进data(),整个 ECharts 实例被深度响应式劫持,其内部 model/状态读写全部经 Proxy 转发;交互(点击图例 / 悬浮 tooltip)时 ECharts 解析model.type得到undefined,抛Cannot read properties of undefined (reading 'type')(echarts.js:1402)。与 Vue2/Vue3 无关,凡把图表实例塞进响应式状态都会触发。 - 排查:
grep -rn "data()" src/ | grep -i "chart\|echarts" # 图表实例是否声明在 data() grep -rn "echarts.init\|\$echarts.init" src/ # 全部初始化点 - 修复:图表实例保持在 Vue 响应式系统之外:
// Options API:在 created() 里声明为普通实例属性(data() 之外的属性不会被 reactive 化) created() { this.xxxChart = null }, methods: { initCharts() { const dom = this.$refs.xxxChartRef const chart = this.$echarts.init(dom) this.xxxChart = chart } }- 等价手段:
shallowRef()/markRaw()包裹实例亦可(const chart = shallowRef(null)或markRaw(echarts.init(dom)))。 - 数据同理:传给
setOption的 series 数据若来自响应式对象(如data.finished),先.slice()抽成普通数组(toPlainArray),避免响应式追踪/序列化干扰。 - 迁移脚本坑(CRLF 项目尤其注意):把实例从
data()搬到created()的脚本必须用逐行split(/\r\n/)+ 括号深度匹配定位,不要用"正则删 data 块"——正则会从data起匹配、不吃前导空格、破坏下一行缩进,且data()还有其它字段时只重建块内行会丢文件其余内容。正确做法:行级定位data起止 → 删字段行 → 空则整块删 → 按mounted/methods前导空格注入created()。
- 等价手段:
P21. el-dialog 与内部 el-table 不可同 ref
- 问题:同一个
ref名同时用在了el-dialog与它的子el-table上,Vue 的 ref 被后者覆盖,导致想拿 dialog 实例却拿到 table(或反之),getSelectionRows()、关闭等方法失效/报错。 - 修复:dialog 与内部 table 用不同的 ref 名(如
ref="dialogRef"与ref="tableRef")。
P22. Vue 3 已移除 this.$set → 直接赋值
- 问题:Vue 2 的
this.$set(obj, key, val)在 Vue 3 不存在,调用会抛this.$set is not a function。Vue 3 响应式基于 Proxy,给响应式对象/数组的子属性直接赋值即可自动触发更新。 - 修复:
// 错误 this.$set(this.form, 'field', value) this.form.list[0].name = 'x' // Vue2 需 $set,Vue3 直接写即可生效 // 正确(Vue3) this.form.field = value this.form.list[0].name = 'x' - 排查:
grep -rn "this.\$set\|\$set(" src/。
P23. 自定义弹窗用 visible + update:visible 时,父组件须 v-model:visible(命名),不能裸 v-model
- 背景:有些自定义弹窗组件没有用
modelValue,而是显式定义visibleprop +emit('update:visible', v)。这与 P9 的modelValue受控是两套约定。 - 问题:若子组件用
visible/update:visible,父组件却写裸v-model="x",裸v-model实际传的是modelValue/update:modelValue,与子组件的visibleprop 对不上 →v-model穿透到子组件内部的el-dialog(因为 el-dialog 自己吃modelValue),表现为弹框"空白 / 暂无数据"或根本不受控。 - 修复:父组件用命名
v-model匹配子组件的 prop 名:<!-- 子组件定义 props: { visible }, emits: ['update:visible'] --> <MyDialog v-model:visible="dialogVisible" /> <!-- 正确:对应 update:visible --> <!-- 错误:<MyDialog v-model="dialogVisible" /> 会传 modelValue,不透不透 -->- 判断依据:看子组件
props里是modelValue还是visible——是modelValue用裸v-model(P9),是visible用v-model:visible(本模式)。 - 关联原则(通用反模式):不要从父组件用
this.$refs.xxx.formData.xxx = ...直改子组件内部响应式数据。子组件若watch了modelValue/visible在打开时initFormData → resetForm,父级的直改会被清空;且弹窗内显示应读自己的v-model。需要预填值就通过 prop/v-model传,不要改内部状态。
- 判断依据:看子组件
4. 验证流程(迁移后必做)
项目约定:AI 不跑
vite build(除非用户贴出构建错误堆栈)。改动做完即止,由用户本地npm run dev验证。以下验证清单交给用户或自动化脚本。
4.1 控制台零报错
- 用 Playwright / 浏览器打开关键页面,监听
console与pageerror:- 重点确认没有
ReferenceError: require is not defined、this.$vnode is undefined、slot-scope编译错误; - 确认没有因残留
this.$refs.xxxSelectRef.getData(...)(已删组件的 ref)导致的运行时报错。
- 重点确认没有
4.2 弹框交互
- 打开任意
el-dialog弹框 → 点"取消"应关闭,点"提交/确认"执行完业务后也应关闭(验证 P9 的v-model受控是否回传父级)。
4.3 下拉框(自定义 select 包装组件替换后)
- 宽度:原生
el-select应显式style="width:200px"(或父容器约束),不再塌陷; - 传参:选 Emp 后网络请求里
empId应是真实的item.id(不再是undefined); - 联动:原
getData联动(如选组织后重载团队/产线)仍正常工作。
4.4 表格 & 日期
- 涉及多选的
el-table,getSelectionRows()返回正确选中行; el-date-picker选值后回显与向后端传参格式正确(大写YYYY-MM-DD)。
4.5 验证清单模板(交付给用户)
每改完一批,输出:
文件绝对路径 | 原用法(组件名/ref/@change 处理器) | 新用法(原生 el-select 绑定字段 + 加载位置 + 是否保留 disabled/multiple/联动) | 备注
5. 常见"症状 → 原因 → 修复"速查表
| 症状 | 根因 | 对应模式 |
|---|---|---|
| 页面白屏 | 模板里写了 this. | P6 |
| 打开页面直接崩 | require() 在 ESM 未定义 | P15 |
| 弹框点取消/提交关不掉 | 子组件 visible 镜像与父级 v-model 不匹配 | P9 |
| 下拉查不出数据 | Emp 取 obj.value 得 undefined,传参空 | P12 |
| 下拉宽度塌陷 | 包装组件 width:100% 无显式宽 | P12 |
| 日期选择乱码/无法选 | value-format 用了小写 y/d | P11 |
| 菜单点击无反应 | 路由 name 重名被覆盖 | P13 |
| 复制按钮没反应 | v-clipboard 指令未重注册 | P14 |
| 表格拿不到选中行 | .selection 不可靠 | P10 |
| 布局变松散 | size="mini" 失效 | P8 |
| 编译报 Unexpected '/' | <style> 无 lang 用了 // | P7 |
| 悬停弹框图表被裁剪/画不出 | el-popover 无 width + echarts 初始化早于布局 | P18 |
| 图表交互崩 reading 'type' | echarts 实例存进 data() 被响应式劫持 | P20 |
| 选不了行 / 关闭失效 | dialog 与内部 table 同 ref 被覆盖 | P21 |
| this.$set is not a function | Vue3 无 $set,需直接赋值 | P22 |
| 弹框"空白/暂无数据" | 子组件用 visible 但父级误用裸 v-model | P23 |
| 文档说已修但实际未修 | 文档陈述未以代码 grep 为准 | P19 |
6. 通用注意事项(跨项目)
- 不要改动项目里其它仍在用的 ElementSelect 包装组件(Team / ProductionLine / Machine / Company 等),只处理明确点名的三类。
- 替换自定义 select 时,绑定字段必须与原
@change赋值字段一致,否则表单提交会丢掉值。 - 任何"先扫后改"——用第 2 节的 grep 建立清单,避免漏掉 PascalCase 死 import。
- 迁移是分批进行的,每批改完抽页面本地验证,不要攒到最后一起验证。
- 不要在本环境跑
vite build做验证(除非用户提供构建错误);本地npm run dev才是验证入口。
微信扫一扫