How to Migrate from Vue 2 to Vue 3 in Large-Scale Applications

Introduction

Hello. I’m katuo, an engineering leader at Cloud Attendance. (Seeking new challenges, I will be transferring to a new team on July 1st.) In our product, we have adopted Vue.js as our front-end technology, and with the end of support (EOL) for Vue 2, we migrated to Vue 3.

In this article, I will explain the specific tactics we used to safely and efficiently migrate from Vue 2 to Vue 3 in our medium to large-scale service. I’ll share in detail our experience with the necessary code changes for the Vue 3 upgrade, library updates, and the points we felt we should have done better. I hope this information will be valuable to engineers operating and considering migrating to Vue 3 in similar-scale applications.

Why We Chose to Migrate to Vue 3

First, let me explain why we decided to migrate to Vue 3.

Some time has passed since the release of Vue 3, but in our development roadmap, certain feature developments were prioritized, and due to the nature of our product being backend-centric, complex features were not necessary for the frontend. As a result, we continued to use Vue 2.

On the other hand, with Vue 2 set to reach its end of life (EOL) at the end of 2024, we have decided to consider the following three options:

  1. Maintain Vue.js 2.x
  2. Upgrade to Vue.js 3.x
  3. Migrate to React.js

Maintaining Vue 2 involved significant risks, including:

  • After 2024, applying security patches becomes impossible (without external support).
  • The longer we postpone migration, the greater the required effort.
  • Hiring new engineers may become difficult.
  • Long-term productivity may decline.
  • Available libraries become limited.

We could not accept these risks as a team, so we narrowed it down to two options: upgrading to Vue.js 3.x or migrating to React.js.

After consideration, we chose Vue 3 over React for the following reasons:

  • Considering our team’s recent roadmap, allocating the necessary effort for migrating to React (including implementation and testing) was not feasible.
  • Migrating to React in time to address the Vue 2 EOL by the end of 2023 was too challenging.
  • We found no significant negative factors in adopting Vue 3.
  • Our product is primarily focused on backend development, and the front-end does not require advanced features.
  • The evolution of AI technology (e.g., models like ChatGPT) may make it easier to migrate to React in the future, and we judged that the manual migration effort was not worthwhile at this stage.

Migration Strategy

Target Pages and Components

In migrating from Vue 2 to Vue 3, the following components and pages needed updates:

  • Number of components: 109
  • Number of pages: 35

We also needed to modify the Vue.js code used through Stimulus.js, not just generic components but these parts as well.

Migration Overview

We divided the migration process into two stages:

Step 1: Rewriting the API

  • Rewriting from the Options API to the Composition API

Step 2: Adjustments for Syntax and Behavior Changes

  • Adapting to new syntax and behavior changes in Vue 3
  • Updating and changing libraries

We were using Vue 2.7 in Cloud Attendance, so it was possible to rewrite to the Composition API while still in Vue 2. Therefore, as Step 1, we first rewrote to the Composition API, which is relatively compatible with Vue 3. Then, in Step 2, we officially upgraded to Vue 3. During this upgrade, we made additional code changes and library updates necessary due to incompatibilities in the Composition API with Vue 3.

Problems Encountered in Migration

In migrating from Vue 2 to Vue 3, we faced the following three main challenges:

1. Extensive Impact and Complex Operation Patterns

We had diverse operation patterns, making it very difficult to perform tests covering all scenarios. This led to scope leaks in testing, increasing the risk of post-release bugs.

2. Risks of Big Bang Release

Releasing all updates at once in a “Big Bang Release” could potentially impact a large number of users simultaneously if bugs occurred. This could lead to a surge in customer support inquiries, straining our support system.

3. Difficulty in Implementing Sequential Releases

Implementing releases in small, phased steps required repeated testing and releases, increasing the cost of context switching and release. Moreover, Vue 3 required a wholesale update, making it impossible to operate some components on Vue 2 and others on Vue 3 without introducing some mechanism.

Implementing Feature Toggle × Canary Release

To address the three challenges mentioned above, we introduced a mechanism that combines Feature Toggle and Canary Release for Step 1, and a Canary Release mechanism for coexisting Vue 2 and Vue 3 for Step 2.

Step 1:

We embedded the same Switcher in each component, allowing us to choose whether to use the Options API or the Composition API.

Switchers can be registered with the following two conditions:

  • Condition 1: Whether to use the Composition API
  • Condition 2: Which businesses are allowed to use the Composition API

Only if both conditions are met, the Composition API is used for rendering. This allowed us to deploy code and make it available to users separately.

We first tested the Composition API with certain businesses to a significant extent. If there were no problems, we would proceed to make it available to all businesses using the Composition API.

Step 2:

We placed JS assets built with Vue 2.7 and Vue 3.x on S3, and the server-side determined which JS asset to use, notifying the client-side. This allowed us to switch the JS asset used by each business.

This system enabled us to release the JS asset upgraded to Vue 3 to specific businesses only. After making it publicly available in production and waiting for a while without any major issues, we would then release it to all businesses.

Implementing Feature Toggle × Canary Release

Step 1:

As shown in the previous diagram:

.
└── HogeComponent/
├── v3/
│ └── hoge-component.vue
├── v2/
│ └── hoge-component.vue
├── switcher.vue
└── index.ts
Here's an introduction to the actual Switcher code:
// switcher.vue
<script lang="ts">
import v3 from './v3/hoge-component.vue'
import v2 from './v2/hoge-component.vue'
import { isEnableCompositionApi } from '@/sample/composition-api-switcher'

export default {
  components: {
    // switchable between using old and new components
    DistributedComponent: isEnableCompositionApi('HogeComponent') ? v3 : v2,
  },
  props: {
    // all props the component has
    messages: {
      type: Array,
      required: true,
    },
    state: {
      type: String,
      required: true,
    },
  }
}
</script>

<template>
  <!-- pass all props -->
  <DistributedComponent :messages="messages" :state="state"/>
</template>
// index.ts
export { default as MinutesInput } from './MinutesInput.vue'
// composition-api-switcher.ts
/**
 * For Canary Release
 */
const meta = document.querySelector('meta[name=use_vue3_component]') as HTMLMetaElement
const isCanaryRelease = meta?.content === 'true' ? true : false

/**
 * For Ordinary Feature Toggle Release
 */
// add component names to enable composition api
const ENABLE_COMPOSITION_API_COMPONENTS = ['HogeComponent']

export const isEnableCompositionApi = (componentName: string) => {
  return ENABLE_COMPOSITION_API_COMPONENTS.includes(componentName)
}

The meta tag part is set so that if it is a Canary Release target business, the content of the meta tag will be true, allowing us to check if it is a target business.

Step 2:

We managed two JS files simultaneously by deploying and managing Vue 2.7 and Vue 3.x assets on S3.

Specifically, when updates are made to the designated Vue 3 deployment branch, a job is triggered to deploy to stg/prod S3 through a CI/CD setup.

By changing the Manifest.json used on the application side, we could specify whether to use Vue 2.7 or Vue 3.x for each business.

Next, I’ll briefly introduce how we implemented the switch for Manifest.json.

// Manifest.json
{
  "my_page/root/index/entry.css": "/packs/my_page/root/index/entry-2205d47ccd51d46fa5f8.css",
  "my_page/root/index/entry.js": "/packs/my_page/root/index/entry-cc3bc89a075a61c4e1f0.ja.js",
  ...
}

Our product assembles the broad framework of html on the backend side with Rails and slim, and mounts the vue.js embedded in slim on the client side as needed. (Effectively performing SSR+CSR)

When rendering on the server side, it is necessary to specify the JavaScript and CSS files to be used by referring to the Manifest.

// slim
= manifest_pack_tag
// ruby helper
  def manifest_pack_tag(locale = I18n.locale)
    tag('link', rel: 'manifest', href: asset_url(manifest_file_path(locale)))
  end

  def manifest(locale)
    if is_vue3_canary_release && !is_local
      Webpack.manifest(locale, "https:#{asset_url(manifest_file_path(locale))}")
    else
      Webpack.manifest(locale)
    end
  end

  def manifest_file_path(locale)
    code_version = if is_vue3_canary_release
                     Figaro.env.vue3_canary_release_asset_hash
                   elsif Figaro.env.code_version
                     Figaro.env.code_version
                   end

    basename = if code_version && !is_local
                 "manifest-#{code_version}.#{locale}.json"
               else
                 "manifest.#{locale}.json"
               end

    public_path = Rails.configuration.x.webpack.dig(:manifest, :publicPath)

    File.join(public_path, basename)
  end

Additionally, to facilitate rollback operations, which businesses to enable for Canary Release are managed by the environment variable vue3_canary_release_asset_hash controlled by k8s. This configuration allowed us to quickly revert without needing to revert the entire application if a disruption occurred immediately after release.

Through this system, we controlled the impact range in case of bugs by switching between Vue 2.7 and Vue 3.x code depending on the business.

Changes Needed

Code Modification Process

We followed this process to convert Vue 2 code to Vue 3 compatible code:

  1. Create a development branch and force the version of Vue 3.x in package.json.
  2. Make minimal temporary code corrections to all code so that the build passes after updating to Vue 3.x.
  3. Fix code that requires handling of breaking changes.
  4. Modify the temporarily corrected code.
  5. Execute tests and fix code that requires modifications.

Changes Needed in Syntax

Here are some changes that were necessary for our product from Vue 2.7 to Vue 3.x:

Breaking Changes Listed in Official Documentation

First, we compiled the “Breaking Changes” listed in the official documentation and identified the necessary adjustments.

Global APIMethod of Creating Vue Instances (new Vue→createApp)Required
Vue.prototype abolished (used in vuex with Vue.prototype.store)Required
Vue.extend abolished (rewrite with script setup)Not required (already handled in Step 1)
Abolished global APIs (Vue.nextTick, Vue.observable, etc.)Not required
Template Directivev-model’s value→modelValueRequired
.sync abolished (use v-model)Required
If a <template> tag has a v-for, place the key attribute in the same tagRequired
If v-if and v-for are in the same tag, v-if takes priority (opposite in 2 and 3)Not required
In the same tag, if an id attribute is written, v-bind cannot overwrite the id attributeNot required
v-on.native modifier removedNot required
ComponentsDynamic ComponentsNot required
Asynchronous ComponentsNot required
Component internals must declare emitsRequired
Changes in how to write render functionsNot required
$scopedSlots removed, $slotsNot required
$listeners removedRequired
$attr now includes class and style attributesNot required
Custom ElementsChanges when using external custom elements in Vue; Vue.config.ignoredElements abolishedNot required
Behavior when passing external custom elements with the is attribute of built-in elementsNot required
Other Minor ChangesLifecycle event name changes (destroyed→unmounted, beforeDestroy→beforeUnmount)Not required (changed in Step 1)
In setting default values for props, you can no longer access this in the functionNot required
Changes in how to write custom directivesRequired
data is always treated as a functionNot required (changed in Step 1)
When mixin data merges with component data, it is now a shallow copyNot required
=”false” behavior change (previously, the attribute itself would not display; now, “false” is passed as a string)Not required
Changes in transition class specificationsNot required
Default value changes in tagNot required
When watching an array, the watch is not called unless the entire array is replaced (if you want it called when parts are changed, set the deep property)Required
<template> tags are now renderedNot required
The target element remains as the parent element even after the Vue application mounts on it (previously, it replaced the target element)Not required
Change in lifecycle hook event prefixesNot required
Removed APIsKeyCode can no longer be used with v-on modifiersNot required
Event API’s $on, $off, $once methods removed. Related to EventBus.Not required
filters removedNot required
inline-template attribute removedNot required
this.$children removed. Use refs insteadRequired
propsData removedNot required
this.$set removedNot required (changed in Step 1)
this.$destroy removedNot required

We methodically replaced the necessary adjustments with tasks.

While we were progressing with the migration work, we were also conducting feature development in parallel. Since we decided to use the Composition API for new development, there were fewer parts that needed to be rewritten for Vue 3. By maintaining close communication within the team, we were able to proceed smoothly.

Individual Handling of Breaking Changes

Beyond what was explicitly stated in the official documentation, we encountered some code that did not function correctly when actually running it and required adjustments. Here are a few examples.

Example 1:

There was a significant change in how the Vue application mounts DOM elements due to the version upgrade. Specifically, in Vue 2, the specified mount element itself was replaced by the Vue application’s DOM, whereas in Vue 3, the Vue application is placed as a child element of the mount element.

For example, if you are generating a Vue instance in a “multi_select_controller.ts” Stimulus.js controller and inserting it on the slim side,

= f.select  data: { controller: 'utilities--multi-select'}, ...
In Vue 2.7, the f.select DOM element is completely replaced by Vue. However, after migrating to Vue 3, the Vue instance is rendered under the select tag, which caused rendering issues.

As a workaround, we changed the target DOM element to a div and mounted the Vue DOM on this div, writing the following code on the Stimulus.js side:

export default class extends Controller {
  public connect(): void {
    const wrapper = document.createElement('div')
    this.element.replaceWith(wrapper)

      createApp(MultiSelectVueComponent, {
        name: (this.element as HTMLSelectElement).name,
        ...
      }).mount(wrapper)
  }

Example 2:

Our team provides components as micro frontends to other internal services. However, we faced an issue with Vue 3’s support for Web Components, where styles defined in child components were not inherited. This problem occurred because style inheritance between components did not work properly.

Specifically, when generating a custom element using defineCustomElement, we could not apply the parent component’s style to the child component. Therefore, we had to specify the component’s style directly in the calling code and insert it:

const customElementStyles: string[] = [
  ...ChildComponent.styles,
  ...
]
const CustomElement = defineCustomElement({
  styles: customElementStyles,
  ...
})

Example 3:

In Vue 2, when data passed through props was looped with the v-for directive, updates to props automatically triggered re-rendering. However, in Vue 3, we encountered a problem where updates to props did not directly reflect in re-rendering by v-for.

For example, if there is code receiving items as props and looping through them with v-for, changes made in the parent component were not reflected:

<tr v-for="(item, index) in items" :key="item.id">

To address this issue, we converted the items received through props into reactive data using ref(), and used this reactive data in v-for. This allowed the changes to be reflected immediately in the component:

Changes Needed in Libraries

As we updated from Vue 2.7 to Vue 3.0, we also needed to update libraries. Let me briefly mention the libraries we use in production (dependencies, not devDependencies) and which ones required individual adjustments:

LibraryNecessary Adjustments
bootstrap-vueVersion update only
vue-fontawesomeVersion update only
vue-virtual-scrollerMinor code adjustments required
vee-validatorSignificant code adjustments required
vue-flatpickr-componentMinor code adjustments required
VuexMinor code adjustments required
vue-multiselectMinor code adjustments required
vue2-daterange-pickerReplacement with another library required

vee-validator

The way we defined validation rules and received error states in templates changed significantly, requiring us to rewrite almost all related code. We revised the code based on the official documentation.

Reference: vee-validate 4.x Overview

vue2-daterange-picker

Since it did not support Vue 3, we needed to switch to another library, vue-datepicker. However, the code modification itself was not particularly difficult.

Other Libraries

Vue 3-compatible versions were already available for most other libraries, and updates were sufficient for most, so updating the libraries overall was not too difficult.

Positives and Reflections

Positives

1. Minimized the Impact of Bugs

Although I cannot mention specific numbers in this article, we did release bugs into the production environment. However, since we only released to businesses targeted by the Canary Release, we were able to minimize the impact of the bugs. Also, having the opportunity to reproduce and investigate the bugs using production data with time to spare was very beneficial. Being able to quickly revert in case of bugs was also very advantageous. We fully utilized the benefits of Canary Release, which was excellent.

2. Reduced Costs of Context Switching and Release

Especially in Step 1, even if we merged the code, the rewritten code would not be executed, allowing engineers to confidently merge completed development code into the main branch with minimal testing. Being able to freely choose the timing and scope of public releases also allowed us to control when to start incident response, incident reporting, and code reverting, separate from the code merge. This clear separation between testing and implementation periods enabled developers to focus on testing and implementation, which was also a positive aspect.

3. Improved Development Experience and Technical Options Through Version Upgrading

Upgrading to Vue 3 enabled us to update libraries that required Vue 3 for major updates. Additionally, encountering libraries that support only Vue 3 in the future will increase, and being able to address these in advance was beneficial. The upgrade also enhanced our TypeScript experience, allowed us to write simpler code with new syntax, and use new APIs, broadening our technical options, which was very good.

Reflections

1. Adjusting the Canary Release Period and Target Businesses

While the results were good, it would have been safer to extend the Canary Release period and gradually increase the target businesses. We typically set the Canary Release period to one to two weeks, but extending this period and gradually releasing 10%, 20% each week if there were no issues would have maximized the benefits of the Canary Release.

2. Management of Temporarily Compiled Code

As mentioned earlier, when we first upgraded to Vue 3, we made temporary adjustments to ensure the compilation passed, but these adjustments caused operational bugs that were discovered at the last minute before release. It would have been better to clearly indicate in comments or similar that the changes were temporary to make these more noticeable.

3. Incidence of Coding Errors + Investigation Methods

The problem required a significant investigation effort. Specifically, a memory bloat occurred in data processing for some employees due to a mistake in setting the initial value for the ref method, significantly slowing down the application’s performance on specific screens.

// Incorrect code
const lastStampedStatuses = ref<string[]>([])
// Correct code
const lastStampedStatuses = ref(new Map<number, string>())

// When the initial value is set as an array, a large number of arrays are created internally based on the employee_id value, causing memory bloat.
lastStampedStatuses.value[json.employee_id] = text

We identified this bug using DevTools’ analysis tools, but tracking it down in the source code was extremely challenging and resulted in a lot of wasted time.

Using analysis tools allowed us to quickly approach the actual problem areas. Therefore, for bugs that cause performance issues, these tools should be actively utilized.

Conclusion

While there were both positives and areas for improvement, it was good that we were able to complete the migration from Vue 2 to Vue 3 without causing any major disruptions. For me personally, gaining insights and experience on how to safely proceed with the front-end FW migration of a medium to large-scale product was very beneficial.

Finally, I would like to express my gratitude again to @a-misawa645 and other members who made significant contributions to this project.

Thank you for reading until the end.

Published-date