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-clivite

切换的方法比较简单,用 vite 创建一个使用 vue + ts 的空工程,根据里面的配置文件,对当前的工程配置进行修改。

主要修改的部分包括:

  • package.json 里的 scriptsdevDependencies,把 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 2Vue 3 代码写法切换

首先,对工程中依赖的第三方依赖进行分析,发现需要更新或替换的有:

  • portal-vue

这个库的作用是将组件中的子视图渲染至任意位置;Vue 3 自带 <Teleport /> 组件,可以满足需求;因此,这个库会被移除

  • vue-class-componentvue-property-decorator

这两个库是为了在 Vue 2 中使用 TypeScriptclass 来编写组件的;Vue 3 中,可以是用 Composition API 来替换掉 class 写法;因此,这两个库会被移除

  • vue-router

需要升级成 4.x 的版本

结合 Vue 3 迁移指南和实际工程,代码修改的顺序大致为:

  1. main.ts
  2. 全局组件代码以及引入方式
  3. 公共组件
  4. 各个页面代码

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 2Options 写法,而且相对独立,比较好升级。

更改前:

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);

公共组件

公共组件使用 TypeScriptclass 写法,经过修改后改成新的 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 的代码抛出异常,具体原因未知。

解决的办法就是使用 Teleportdisabled 属性,在需要的时候才将组件元素传送到其他位置。

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 相关的问题。

  1. sfc 中引入的仅用于标注的类型需要加 type 修饰
  2. 如果同时引入了 Component 类型,并且模版中使用了动态组件 <component>,那么需要重命名。

例子:

<template>
    <component :is="xxx">
</template>

<script setup>
import { type Component as Comp } from "vue";
// ...
</script>

总结

本次升级总体来看难度不大,一是因为依赖较少的 vue 生态的第三方库;二是工程使用 TypeScriptVue 3、插件和编辑器也对它有很好的支持,可以快速发现需要修改的地方。在制定好升级方案后,整个过程比较平稳,大部分时间都花在写法上的修改。在解决问题的过程中,也加深了对 Vue 3 的熟悉程度。后续就方便多人经手这个项目啦~