Vue.js team released Vue 3.0 in September 2020. This new version came with many new features, optimizations, but also comes with a few breaking changes.

In June 2021, Vue.js team released the Vue 3.1 version. This new release comes with @vue/compat (aka "the migration build"). It allows to migrate large projects smoothly by running a Vue 2 codebase along with Vue 3 changes.

Migrating to Vue 3 can be an important task (depending on your project's size). At Crisp, we recently migrated our app (250K lines of code) from Vue 2.6 to Vue 3.2 in about 2 weeks.

We prepared this migration by reading the official Vue Migration Tutorial, but we discovered many different caveats while migrating our project.

We felt it would be good to share our learnings so other companies can benefit from our experience. This article will tell you:

  • The major differences between Vue 2 and Vue 3
  • Dilemmas we've encountered and how we dealt with them
  • Our Vue.js migration strategy

Some background around Vue 3

Created in 2013, Vue.js initial philosophy was to create a minimalistic alternative to AngularJS 1.

At the time, Angular was a bulky framework, with a ton of new features. Vue.js first versions were like a mini-angular framework with Templating, Data Binding, Filters and Directives.

Vue 2 was then released in 2o16 (almost at the same time as Angular 2), and offered a great alternative to AngularJS. In fact, many developers using Angular 1 decided to move to Vue 2 because they didn't like Angular 2: Vue 2 was offering something as simple as AngularJS, but with better performances.

Vue 3 was created with performances in mind. It's an evolution rather than a revolution. Most new features and breaking changes have been released for performance reasons.

The internal reactivity system has been reworked from the ground, and for this reason, IE 11 support has been dropped (which is not a huge loss 😂).

Finally, this new version is meant to be more modular and introduces features like the Composition API.

Biggest changes on Vue 3

Vue global API

The Vue global API has been deprecated. While it is still supported with @vue/compat, reworking your app will be required in order to fully support Vue 3.

It means it won't be possible to use APIs like Vue.set or Vue.delete. As Vue 3 comes with a new reactivity system, using those APIs becomes useless.

Instead of using Vue.set(object, key, value) you will have to directly use object[key] = value.Same goes for Vue.delete(object, key), which can be replaced with delete object[key].

Filters

As explained earlier, Vue.js has been created as an alternative to Angular 1. It's why Filters were supported initially.

The biggest problem with Filters is performance: the filter function has to be executed every-time data is updated. For this reason, filters support has been dropped with Vue 3.

This means it won't be possible to use stuff like {{ user.lastName | uppercase }} in Vue 3 templates.

Instead, you will have to use computed properties like {{ uppercasedLastName }} or methods like {{uppercase(lastName)}}.

Vue & Plugins instantiation

Starting Vue 3, your app and plugins are no longer instantiated globally. It means you can have multiple Vue apps within the same project.

Using Vue 2:

import Vue from "vue";

new Vue({
  router,
  render: h => h(App)
}).$mount("#app");

Using Vue 3:

import { createApp, h } from "vue";

const app = createApp({
  render: () => h(App)
});

app.use(router);

app.mount("#app");

v-if + v-for

Using v-if conditions with v-for lists used to be possible with Vue 2. For performance reasons, this behavior has been disabled on Vue 3.

Starting Vue 3, you will have to use computed list properties.

v-model

The v-model API changed a bit on Vue 3.

First the property value has been renamed to modelValue

<ChildComponent v-model="pageTitle" />

ChildComponent needs to be rewritten like this:

props: {
  modelValue: String // previously was `value: String`
},

emits: ['update:modelValue'],

methods: {
  changePageTitle(title) {
    this.$emit('update:modelValue', title)
  }
}

The cool thing is that it's now possible having multiple v-model custom values along, for example v-model:valueA, v-model:valueB, etc.

$emit

In Vue 2, it was possible to use a Vue instance to create a global EventBus with vm.$on and vm.$off. Starting Vue 3, it's not possible to do so anymore, as vm.$on and vm.$off were removed from the Vue instance.

As a replacement, we suggest using the Mitt library:

mounted() {
  this.eventbus = mitt();

  eventbus.on("ready", () = {
    console.log("Event received");
  });

  eventbus.emit("ready");
}

The same code using Vue 2 would be:

mounted() {
  this.$on("ready", () = {
    console.log("Event received");
  });

  this.$emit("ready");
}

It is still possible to emit events from components to their parents, however, all event have to be declared via the new emit option (it is very similar to the existing props option).

For instance, if your component has a @click property, emitted using  this.$emit("click"), you will have to declare the "click" event in your component:

props: {
  name: {
    type: String,
    default: ""
  },
},

emits: ["click"], // events have to be declared here

data() {
  return {
    value: ""
  }
}

Migration Strategy

Our app is called Crisp. It is a big business messaging app used every day by 300K companies all around the world. Companies use Crisp to reply to their customers using a Team Inbox that centralizes chat, emails and many other channels.

As our current app is used by many different users, it is obviously important for us to not break anything. Thus, we also needed to improve our software on a daily basis, so we can iterate on bugs and new features.

So we needed to have two different branches:

  • our principal branch, for Vue 2.6, with our current release lifecycle
  • a vue3 branch, to work on the codebase migration to Vue 3

Additionally, we released every day a beta running our Vue 3 codebase, and backported almost everyday changes made on the Vue 2 branch into the Vue 3 one, to prevent from having headache once merging the Vue 3 codebase.

Finally, we chose not to rely on new Vue APIs such as the Composition API, and only migrate what's necessary. The reason behind this was to reduce the risk of introducing regressions.

Updating Vue Build Tools

Our app relies on Vue Cli (webpack). Again, we chose to not migrate yet to Vite, preventing us from introducing new issues, as our build system is pretty complex.

Migrating Vue Cli to support Vue 3 is pretty easy.

You will have first to edit your package.json file in order to update Vue.js and its dependencies.

So we replace "vue": "^2.6.12" with "vue": "^3.2.6".

Additionally, we will have to use "@vue/compat": "^3.2.6" , allowing to smoothly migrate the codebase from Vue 2 to Vue 3.

We will also have to upgrade "vue-template-compiler": "^2.6.12" to "@vue/compiler-sfc": "^3.2.6".

Now, we will have to update our vue.config.js file and edit the chainWebpack function. It will force all your existing libraries to use the @vue/compat package.

  // Vue 2 > Vue 3 compatibility mode
  config.resolve.alias.set("vue", "@vue/compat");

  config.module
    .rule("vue")
    .use("vue-loader")
    .loader("vue-loader")
    .tap(options => {
      // Vue 2 > Vue 3 compatibility mode
      return {
        ...options,
        compilerOptions: {
          compatConfig: {
            // default everything to Vue 2 behavior
            MODE: 2
          }
        }
      };
    });

Updating our main.js file

We now need to instantiate Vue like this:

import { createApp, h, configureCompat } from "vue";

const app = createApp({
  render: () => h(App)
});

app.use(router);
app.use(store);
// Initiate other plugins here

configureCompat({
  // default everything to Vue 2 behavior
  MODE: 2
});


app.mount("#app");

Updating Vue Router

We will have to use Vue Router latest version "vue-router": "4.0.11"

Using the latest Vue.js Router isn't much different. The main difference is that you will have to manually enable history mode using:

import { createWebHistory, createRouter } from "vue-router";

var router = createRouter({
  history: createWebHistory(),
  routes: [
    // all your routes
  ]
});

Migrating Filters

Our existing app relies a lot on filters (around 200 usages). The first thing was to find how many filters we were using. As modern IDEs (VSCode, SublimeText) supports Regex search, we made a regex so we can find out all Vue filters usages in our templates: {{ (.*?)\|(.*?) }}

Regex Search Using Sublime Text

As Vue 3 completely drops Filters support, we tried to find an elegant way to still use Filters. The solution was migrating Vue filters to custom Singletons Helpers.

For instance on Vue 2:

Vue.filter("uppercase", function(string) {
  return string.toUpperCase();
});

Becomes on Vue 3:

uppercase(string) {
  return string.toUpperCase();
}

export { uppercase };

Then we globally instantiated the StringsFilter class:

import { uppercase } from "@/filters/strings";

app.config.globalProperties.$filters = {
  uppercase: uppercase
};

Finally, we can use our filter:

{{ firstName | uppercase }} becomes {{ $filters.uppercase(firstName) }}

Fix errors

As your migration process may progress, you will spot errors in your browser's console. Vue 3 Compatibility mode comes with different logs helping you to migrate your app to Vue 3.

Vue 3 compat mode comes with explicit warnings

Rather than trying to migrate your app feature by feature or template by template, we recommend migrating API by API.

For instance, if you see the WATCH_ARRAY deprecation notice in the logs, we recommend having a look to the migration guide provided in the logs.

Then, migrate step by step every watch array.

Once migrating all the watch arrays is done, you can then turn off the compatibility mode for this feature:

import { createApp, h, configureCompat } from "vue";

const app = createApp({
  render: () => h(App)
});

// Initiate all plugins here

configureCompat({
  // default everything to Vue 2 behavior
  MODE: 2,

  // opt-in to Vue 3 behavior for non-compiler features
  WATCH_ARRAY: false
});


app.mount("#app");

Updating Libraries

Vue 3 comes with the @vue/compat package, supporting both Vue 2 and Vue 3 libraries. However, the @vue/compat mode also comes with degraded performances.

Using the compatibility mode shall only be done while converting your app to Vue 3 and all its libraries. Once all the project and libraries have been converted, you can get rid of the compat mode.

So we can achieve this, we will have to migrate all libraries to Vue 3 compatible ones.

All official Vue libraries (Vuex, Vue Router) have been ported to Vue 3, while most popular packages already have Vue 3 compatible versions.

Around 20% of community libraries we use didn't have a Vue 3 version, so we choose to fork all libraries that haven't been ported to Vue 3 yet, for instance vue-router-prefetch.

Porting a Vue 3 library is usually quite simple and takes from 30 minutes to a half-day, depending on the library complexity.

Once we are done migrating a library to support Vue 3, we create a "pull request" to the original library, so that others can benefit from our work.

About Performances

Vue 3 comes with many different performance improvements thanks to the new internal reactivity system. The Javascript heap size has been reduced from 20-30% in most cases and from 50% for some complex lists. For our messaging use-case, we saw big CPU improvements.

Our new Crisp app feels faster to use and lighter.

Conclusion

As we are writing this article, we are deploying the new Crisp app in production. Migrating this app took us around 2 weeks, being 2 developers, almost full-time.

As a comparison, moving from Angular 1 to Vue 2 took us around 9 months.

Switching from Vue 2 to Vue 3 is an important but not an impossible task. This new Vue version offers some great performance improvements.

To thank the Vue.js community, we've started to contribute to the project as a gold sponsor. As a company, we feel we have to empower open-source project to build a better world where software and environment can talk to each other.