在实际项目中将技术栈从 Vue 2 迁移到 Vue 3,整个过程充满了探索与挑战。无论是前期环境搭建,还是开发中 API 的灵活运用,再到最终的构建部署,都踩了不少“坑”。本文旨在分享这些实战经验,帮助准备或正在进行升级的开发者们更顺畅地完成过渡。
前期准备
由于 Vite 在开发态是基于 ESM(ECMAScript Modules)进行模块化开发,而 ESM 的浏览器兼容版本有限,具体兼容性可以参阅 Can I use 的统计数据。

因此,如果你打算使用 Vite 作为构建工具进行开发,至少要确保浏览器版本符合要求。如果你和我一样,手头有一个较低版本的 Chrome 用于兼容性测试,同时又不想升级主浏览器,那么安装一个独立的 Chromium 是个不错的选择。这样就能在一台电脑上拥有两个不同内核的 Chrome 环境。

你可能会问,什么是 ESM?简单来说,在开发阶段,我们加载的是模块化的 .ts 或 .js 文件(使用 <script type=“module”>),而在打包构建后,产物通常会转换为 CommonJS 或其它兼容性更好的格式。下图直观展示了这种差异:
开发态(ESM):

生产构建后:

此外,你需要将 node 环境升级到 14 以上版本。如果你的开发机仍是 Windows 7,可能会遇到第二个问题:如何在 Windows 7 下安装 node 14? 一个可行的方案是,将下载的 node 包放在指定的 nvm 目录下,并将系统环境变量 NODE_SKIP_PLATFORM_CHECK 的值设置为 1。
组件准备:为了保持项目UI风格的一致性,并实现更灵活的定制,我们基于 Ant Design Vue 对组件进行了二次封装,并发布到了私有的 npm 仓库。
关于组件自动引入插件 unplugin-vue-components:上述封装操作带来了一个新的问题,即可能导致 unplugin-vue-components 插件无法自动导入我们封装的组件。为此,我提交了一个 issues 和一个 PR,希望 AntDesignVueResolver 能够支持动态设置组件名,预计下个版本会得到支持。
你可能要习惯的和 Vue2 的不同
在实际开发中,从 Vue 2 升级到 Vue 3,有几个核心变化需要适应。
组合式 API
组合式 API 是 Vue 3 和 Vue 2.7 的内置功能,对于更老的 Vue 2 项目,可以使用 @vue/composition-api 插件。它主要包括以下几类 API:

<script setup> 是在单文件组件中使用组合式 API 的编译时语法糖。个人感觉,不使用这个语法糖的写法更接近 Vue 2 的 Options API,而使用语法糖则让代码更加简洁和“丝滑”。下面是两种写法的对比:
不使用 <script setup> 语法糖:

使用 <script setup> 语法糖:

响应式系统的差异
数组的响应式
在 Vue 3 中,让数组变得响应式主要有两种方式。从个人使用体验来看,ref 包裹数组(写法一)在某些场景下操作更直接。

<script setup lang="ts">
// 写法一
const arrData1 = ref([2])
// 写法二
let arrData2 = reactive([2])
// 数组新增一个元素
function handleAddArrayItem(item) {
arrData1.value[arrData1.value.length] = item;
arrData2.push(item);
}
// 整个数组变更
function handleChangeArray(newArr) {
arrData1.value = [...newArr];
arrData2 = Object.assign(arrData2, newArr);
}
</script>
响应式代理 vs 原始对象
你可能注意到,在上面对整个数组进行变更时,我使用了 Object.assign。这是因为只有通过这种方式(或直接修改属性),才能保持 reactive 对象的响应式特性。这与 Vue 2 有所不同,官方文档在 响应式代理 vs. 原始值 中有详细说明,其根本原因在于 Vue 3 使用了基于 Proxy 的响应式系统。

<script setup lang="ts">
let ob = reactive({a: '1'})
function handleChangeOb() {
// X 无法实现数据响应式
ob = reactive({...ob, b: '2'})
// X 无法实现数据响应式
ob = {...ob,b: '2'}
// √ 可以数据响应式
ob.b = '2'
// √ 可以数据响应式
Object.assign(ob, {b: '2'});
}
</script>
v-model 双向绑定的实现
在 Vue 3 中,v-model 的底层逻辑有所变化,支持多个 v-model 绑定。子组件需要显式地声明 modelValue prop 并发出 update:modelValue 事件。
父组件示例:
<template>
<div class="hello">
<h1 @click="showModal">打开弹窗</h1>
<Modal v-model="visible"></Modal>
</div>
</template>
<script setup lang="ts">
import Modal from './modal-setup.vue'
defineProps<{ msg: string }>()
const visible = ref(false)
const showModal = () => {
visible.value = true
}
</script>
<style scoped>
.hello {
position: relative;
width: 100px;
}
</style>
子组件示例:
<template>
<teleport to="#app">
<div class="modal" @click="hideModal" v-show="visible">
modal
</div>
</teleport>
</template>
<script setup lang="ts">
const props = defineProps<{ modelValue: Boolean }>()
const emit = defineEmits(['update:modelValue'])
const visible = computed({
get: () => props.modelValue,
set: val => {
emit('update:modelValue', val)
}
})
const hideModal = () => {
visible.value = false
}
</script>
<style scoped>
.modal {
position: absolute;
top: 0;
right: 0;
background: #999;
width: 300px;
height: 100vh;
}
</style>
在动态元素上使用 ECharts
当需要在 v-for 循环动态渲染的元素上挂载 ECharts 实例时,需要巧妙地使用 ref 函数来获取 DOM 引用。
<template>
<div v-for="(card, index) in cardList":key="`${card.id}-${index}`">
<div class="card">
<!-- 当你放置echart的元素是动态渲染时, 需要动态挂载元素-->
<template v-if="card.type === 1">
<div :ref="(el) => setEchartRef(el, index)" class="chart"></div>
</template>
<div v-else>empty-box</div>
</div>
</div>
</template>
<script setup lang="ts">
import * as echarts from 'echarts/core';
import { PieChart } from 'echarts/charts';
import { CanvasRenderer } from 'echarts/renderers';
import { GridComponent, TooltipComponent } from 'echarts/components';
echarts.use([GridComponent, PieChart, CanvasRenderer, TooltipComponent]);
const cardList = ref([]);
const echartsRef = ref<HTMLElement[]>([]);
function setEchartRef = (el: HTMLElement, index: number) => {
echartsRef.value[index] = el;
}
function drawEchart(index) {
cardList.value[index].echart = echarts.init(echartsRef?.value?.[index] as unknown as HTMLElement);
cardList.value[index].echart.setOption({
// ...
})
}
function setEchartData() {
cardList.value[index].type = 1;
await nextTick();
drawEchart(index);
}
</script>
关于构建部署踩的坑
-
混用 require 和 import
如果项目中同时存在 CommonJS (require) 和 ES 模块 (import) 的写法,构建时可能会报错 Uncaught ReferenceError: require is not defined。可以使用 @originjs/vite-plugin-commonjs 插件并启用 transformMixedEsModules 配置进行临时修复。
import { defineConfig } from 'vite'
import { viteCommonjs } from '@originjs/vite-plugin-commonjs';
export default defineConfig({
// ...
plugins: [
viteCommonjs({
transformMixedEsModules: true,
}),
]
})
这类似于 Babel 中处理混合模块类型的配置。根本的解决之道是统一模块语法,避免混用。因此,原来项目中用 h 函数渲染图片的 require 写法也需要改为 ES 模块导入:
import exampleImg from './assets/example.png'
import { h } from 'vue';
function renderModal() {
Modal.confirm({
title: '操作确认',
icon: null,
content: () =>
h('div', { style: 'text-align: center;padding-bottom: 32px;' }, [
// 原来vue2的写法 h('img', {attrs: {src: require('./assets/example.png')}})
h('img', { src: exampleImg })]),
});
}
-
浏览器兼容性问题
Vite 的 build.target 配置项用于设定需要兼容的浏览器最低版本或 ES 版本。build.cssTarget 则专门针对 CSS 压缩设置目标浏览器,通常用于处理非主流浏览器的兼容问题。例如,安卓微信 Webview 曾不支持 #RGBA 十六进制颜色符号,将 cssTarget 设为 chrome61 可以阻止 Vite 将 rgba() 颜色转换为十六进制形式。

此外,可以使用 @vitejs/plugin-legacy 插件处理更广泛的浏览器兼容问题。例如,在某个旧版本 360 浏览器(Chrome 69 内核)中遇到了 Uncaught ReferenceError: globalThis is not defined 的错误。除了网上那些“热修复”方案,更优雅的方式是通过该插件的 modernPolyfills 配置来解决:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import legacy from '@vitejs/plugin-legacy'
export default defineConfig({
server: {
port: 8080
},
build: {
target: 'es2015', // js兼容处理
cssTarget: 'chrome49', // css兼容处理
}
plugins: [
vue(),
legacy({
targets: ['chrome 49'],
modernPolyfills: ['es.global-this'], // 解决浏览器端 globalThis is not defined 报错
}),
]
})
踩了这么多坑,后悔在新项目中使用 Vue 3 了吗?我的答案是没有。对于一个不算特别重度的新项目,如果你想尝试并掌握 Vue 3,这或许是一个不错的开始。整个升级过程虽然充满挑战,但解决问题的同时也加深了对新特性与前端工程化的理解。希望本文的经验能对你的 Vue.js 升级之路有所帮助。如果你在迁移过程中也遇到了其他有趣的“坑”,欢迎在 云栈社区 与其他开发者交流分享。