la-vista 升级 Vue 3 历程
概述
la-vista
是基于 Vue 2 + TypeScript
开发的工程。现在 Vue 3
已经正式发布了,带来了 Composition API
以及更好的 TypeScript
支持;同时项目构建工具切换到了 Vite
,开发相应速度大大提升。现在将本项目的 Vue 2
升级成 Vue 3
,便于后期维护,同时也是对 Vue 3
的一次练手。
由于历史遗留问题,本项目包含两个主要分支,对应不同的用户群体;项目中存在一个 features
分支用于开发公共的代码,公共功能开发完后分别合并至两个分支,并作差异化微调。现在经过评估,可以将两个分支合并,方便后续功能迭代。这也是为了便于完成整个工程的 Vue 3
升级,否则,两个分支都需要大量的工作量。
因此,整个工程升级的流程大致分为:基于一个主要分支进行升级 —— 差异化准备 —— 合并另一个主要分支。
步骤
构建工具切换 vue-cli
→ vite
切换的方法比较简单,用 vite
创建一个使用 vue + ts
的空工程,根据里面的配置文件,对当前的工程配置进行修改。
主要修改的部分包括:
package.json
里的scripts
和devDependencies
,把vue-cli
相关的部分替换成vite
对应的内容;vue
依赖版本更新vue.config.js
替换成vite.config.ts
.env.*
文件中VUE_APP_*
替换成VITE_*
,对应代码里引用process.env.VUE_APP_*
替换成import.meta.env.VITE_*
- 动态图片引入修改
更改前:
return require(`../../assets/images/order/status_${orderStatus}.png`);
更改后:
return new URL(`../../assets/images/order/status_${orderStatus}.png`, import.meta.url).href;
VSCode 插件替换
由于 Vue 3
写法的不同,仅更新库版本还是无法正常启动项目的;该项目使用了 ts
,如果编辑器能够直接在打开的文件中标注出有问题的部分,修改起来就十分方便了。
幸运的是,官方推荐的插件 Vue Language Feautures (Volar)
支持 Vue 3 + ts
,还能对 template
中的代码进行类型检测。
在升级的过程中,把每个文件中被这个插件标注错误的部分进行修改;完成之后,基本上就能启动项目了。
Vue 2
→ Vue 3
代码写法切换
首先,对工程中依赖的第三方依赖进行分析,发现需要更新或替换的有:
portal-vue
这个库的作用是将组件中的子视图渲染至任意位置;Vue 3
自带 <Teleport />
组件,可以满足需求;因此,这个库会被移除
vue-class-component
和vue-property-decorator
这两个库是为了在 Vue 2
中使用 TypeScript
的 class
来编写组件的;Vue 3
中,可以是用 Composition API
来替换掉 class
写法;因此,这两个库会被移除
vue-router
需要升级成 4.x 的版本
结合 Vue 3
迁移指南和实际工程,代码修改的顺序大致为:
main.ts
- 全局组件代码以及引入方式
- 公共组件
- 各个页面代码
main.ts
中的修改
- vue app 实例
更改前:
const router = new VueRouter({
routes: routes,
});
const myApp = new Vue({
el: '#app',
render: (h) => h(App),
router,
});
更改后:
const router = VueRouter.createRouter({
routes: routes,
history: VueRouter.createWebHashHistory(),
});
const myApp = createApp(App);
myApp.use(router);
// ...
myApp.mount('#app');
- 全局属性
更改前:
declare module 'vue/types/vue' {
interface Vue {
readonly $appConfig: AppConfig;
$navigate(path: string, query?: { [key: string]: any }, replace?: boolean): void;
// ...
}
}
const prototype = Vue.prototype;
prototype.$appConfig = appConfig;
更改后:
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
readonly $appConfig: AppConfig;
$navigate(path: string, query?: { [key: string]: any }, replace?: boolean): void;
// ...
}
}
myApp.config.globalProperties.$appConfig = appConfig;
- mixins
更改前:
Vue.mixin({
components: {
// ...
},
methods: {
$navigate(path: string, query: { [key: string]: string } = {}, replace: boolean = false) {
// ...
},
// ...
}
});
更改后:
const installMixin = (app) => app.mixin({
components: {
// ...
},
methods: {
$navigate(path: string, query: { [key: string]: string } = {}, replace: boolean = false) {
// ...
},
// ...
}
});
installMixin(myApp);
全局组件
la-vista
工程中包含许多全局组件,是之前为了移除第三方UI库依赖,而实现的简单版本组件。这些组件基于 Vue 2
的 Options
写法,而且相对独立,比较好升级。
更改前:
import Vue from "vue";
export default Vue.extend({
components: {
// ...
},
props: {
// ...
},
render(h) {
// ...
}
})
更改后:
import { defineComponent, h } from "vue";
export default defineComponent({
components: {
// ...
},
props: {
// ...
},
emits: ['input', 'change'],
render() {
// ...
}
})
这些全局组件原先是通过 Vue.mixin
方式引入到全局的,也需要进行修改。
更改前:
// appSupport
Vue.mixin({
components: {
f7Page: Page,
// ...
},
});
更改后:
// appSupport
export default {
install(app: App) {
app.mixin({
components: {
f7Page: Page,
// ...
},
});
// ...
}
}
// main.ts
myApp.use(appSupport);
公共组件
公共组件使用 TypeScript
的 class
写法,经过修改后改成新的 Composition API
写法。
更改前:
<script lang="ts">
import Vue from "vue";
import Component from "vue-class-component";
import { Prop } from "vue-property-decorator";
import OtherComponent from "./other-component.vue";
@Component({
components: {
OtherComponent
}
})
export default class SomeComponent extends Vue {
@Prop()
someProp: string;
someStaticData = 1;
someReactiveData = 2;
get someComputed(): string {
return // ...
}
someMethod() {
// ...
this.$emit('someEvent', something);
}
}
</script>
更改后:
<script lang="ts" setup>
import { computed, ref } from "vue";
import OtherComponent from "./other-component.vue";
const props = defineProps<{
someProp: string;
}>();
const emit = defineEmits<{
(e: 'someEvent', value: SomeDataType): void;
}>();
const someStaticData = 1;
const someReactiveData = ref(2);
const someComputed = computed(() => {
return // ...
});
function someMethod() {
// ...
emit('someEvent', something);
}
</script>
各个页面代码
- 模版
页面代码的修改和上面公共组件的基本相同,需要额外修改的就是模版中 slot
的使用方式。其实 Vue 2.6
中就引入了新的写法,只不过仍然兼容旧的。Vue 3
中就必需使用新的写法了。
更改前:
<some-component>
<div slot="start">hi</div>
</some-component>
更改后:
<some-component>
<template #start>
<div>hi</div>
</template>
</some-component>
- mixin
由于页面代码改成了使用 Composition API
,不能使用 mixin
和直接访问组件根元素 $el
,导致原先一个需要直接操作 dom 的 mixin
无法使用。Vue 3
提出了 Composable
的概念,可以使用这种方式来代替。
更改前:
// mixin
@Component({
// ...
})
export class SomeServiceMixin extends Vue {
mounted() {
this.someService = someFnToCreateService(this.$el)
// ...
}
}
// page
@Component({
// ...
})
export default class SomePage extends mixins(SomeServiceMixin) {
// ...
someMethod() {
this.someService.doSomething() // ...
}
}
更改后:
// mixin
export function useSomeService() {
const el = ref<Element | null>(null);
// ...
onMounted(() => {
// create service
});
return [el, someService]
}
// page
const [el, someService] = useSomeService();
<template>
<div>
<div ref="el" />
</div>
</template>
- 全局属性
由于前面已经更新了全局属性的写法,因此在模版中能正常使用,不需要修改。比如:
<template>
<div v-if="$appConfig.someProp">
hello
</div>
</template>
但是在 <script setup>
中,是无法直接访问全局变量的,先使用一个临时解决方案,后续把代码中直接访问全局属性的方式修改掉。
import { getCurrentInstance } from "vue";
const proxy = getCurrentInstance()!.proxy!;
proxy.$appConfig.something // ...
至此,工程就可以启动了,进行调试。
合并另一个分支准备
la-vista
工程的两个主要分支,最终发布到同样的域名下,但是其中一个的代码会发到额外一个子目录下。即一个分支发到 https://somehost/la-vista/
,另一个发到 https://somehost/la-vista/rhino/
。
如果要合并两个分支,就要兼容两个发布地址,在代码运行时,根据当前网址路径来区分走不同的逻辑。而且在开发的时候,可以直接在网页路径中添加 /rhino/
路径来调试代码。
- 区分
为了区分不同逻辑,可以在代码初始化的时候进行检测,生成配置变量,在需要的地方使用。同时,将这个配置变量引入到全局属性中,方便页面中使用。
// target-selection
const isToA = test(location.pathname);
// main
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
// ...
readonly $isToA: boolean;
// ...
}
}
Object.assign(myApp.config.globalProperties, {
$isToA: targetSelection.isToA,
// ...
})
// page
if (proxy.$isToA) {
// ...
}
<template>
<div v-if="$isToA">...</div>
</template>
- 开发时调试
Vite
支持 Multi-Page App
,只需要在工程根目录下新增子目录,并编写 index.html
,开发时就可以直接访问子路径下的代码。
- 构建发布
正常构建生成 dist
文件夹,复制此文件夹重命名为 rhino
,然后将它移动至 dist
文件夹中。发布时直接拷贝 dist
文件夹即可。
合并另一个分支
除了解决冲突,合并完另一个分支后,依照上述方法,对新增的文件进行修改。
完成后,本次升级 Vue 3
过程就接近尾声了。
移除全局属性
虽然 Vue 3
提供了 globalProperties
实现全局属性的访问,但官方推荐使用 Provide / Inject
的方式来实现依赖的注入。前面提到在 <script setup>
中访问全局属性的临时解决方案,就可以使用新的方式替代了。
更改前:
// main
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
readonly $appConfig: AppConfig;
// ...
}
}
myApp.config.globalProperties.$appConfig = appConfig;
// ...
// page
import { getCurrentInstance } from "vue";
const proxy = getCurrentInstance()!.proxy!;
proxy.$appConfig // ...
更改后:
// globals
export function useGlobals() {
return {
...inject(globalPropertiesKey)!,
// ...
};
}
// main
myApp.provide(globalPropertiesKey, {
$appConfig: appConfig,
// ...
});
// page
const { $appConfig } = useGlobals();
至此,本次升级就全部结束了。
问题
在本次升级过程中,遇到了许多问题。其中,大部分都可以查阅官方迁移指南 Vue 3 Migration Guide 进行解决。这里仅列举几个比较值得关注的问题。
Custom Directives
Vue 3
中指令的语法进行了改动,那么按照新的语法对现有的指令进行改造即可。然而,由于页面组件使用了 <script setup>
,指令无法访问内部变量。
举个例子,下面是 Vue 2
中的写法:
// directive
bind(el, binding, vnode) {
const vm = vnode.context;
console.log(vm['expr']); // 可以读取
}
// component
// ...
computed: {
expr() {
return // ...
}
}
</script>
指令中是可以正常访问组件实例属性的。但是 Vue 3
中,无法获取数据。
// directive
mounted(el, binding, vnode) {
const vm = binding.instance;
console.log(vm['expr']); // 无法读取
}
// component
const expr = computed(() => { // ... });
本次修改,采用的解决方法是使用 binding.arg
来传递额外动态数据。比如,在模版中可以这样写:
<div v-custom-directive:[expr]="something" />
指令中这么访问:
updated(el, binding) {
const expr = binding.arg;
},
Refs inside v-for
Vue 3.2.25
及以上版本中,可以使用 ref
获取 v-for
渲染的元素数组。
<script setup>
import { ref, onMounted } from 'vue'
const list = ref([
/* ... */
])
const itemRefs = ref([])
onMounted(() => console.log(itemRefs.value)) // 能打印非空数组
</script>
<template>
<ul>
<li v-for="item in list" ref="itemRefs">
{{ item }}
</li>
</ul>
</template>
但是,在实际工程运行中发现,无法获取在子组件 slot
中的 v-for
数组。当时 Vue
版本是 3.2.31。
<template>
<child-component-with-slot>
<div v-for="item in list" ref="itemRefs">
{{ item }}
</div>
</child-component-with-slot>
</template>
上面的 itemRefs
一直是空数组。解决方法是使用 Function Refs
,即 :ref="(el) => { /* save el */ }"
。当然,后面官方可能会解决这个问题。
Teleport
项目中使用了第三方库 rrweb
,用在投保可回溯场景下。在调试代码的时候发现,如果页面中同时有两个组件使用 Teleport
到同一个目标下,会导致 rrweb
的代码抛出异常,具体原因未知。
解决的办法就是使用 Teleport
的 disabled
属性,在需要的时候才将组件元素传送到其他位置。
Reactivity
举个例子:
const obj = { /* some properties */ };
const arr = ref([]);
arr.value.push(obj);
console.log(arr.value.indexOf(obj));
上述打印结果为 -1
。一个解决办法是:
const obj = reactive({ /* some properties */ });
// ...
event arguments
Vue 2
中,我们可以在模版中使用 arguments
来访问子组件 $emit
出来的事件的数据。
<child-component @some-event="myFn('tag', arguments[0], arguments[1])">
但是 Vue 3
中,无法这么使用了,好在插件有提示错误,不然都不好查找原因。代替 arguments
的使用有多种方式,这里就不举例了。
TypeScript
这里提一下跟 TypeScript
相关的问题。
- sfc 中引入的仅用于标注的类型需要加
type
修饰 - 如果同时引入了
Component
类型,并且模版中使用了动态组件<component>
,那么需要重命名。
例子:
<template>
<component :is="xxx">
</template>
<script setup>
import { type Component as Comp } from "vue";
// ...
</script>
总结
本次升级总体来看难度不大,一是因为依赖较少的 vue
生态的第三方库;二是工程使用 TypeScript
,Vue 3
、插件和编辑器也对它有很好的支持,可以快速发现需要修改的地方。在制定好升级方案后,整个过程比较平稳,大部分时间都花在写法上的修改。在解决问题的过程中,也加深了对 Vue 3
的熟悉程度。后续就方便多人经手这个项目啦~