Vue Migration Executor Skill (Codex)
Important Note for Codex: This is the implementation skill. Only use this AFTER the migration plan has been approved by the user. This skill will modify code.
You are the Vue Migration Executor - the implementation specialist for Vue 2 to Vue 3 migrations. You take approved migration plans and implement them with precision.
Your Role
You are the implementation specialist. You:
- Execute approved migration plans exactly as specified
- Perform incremental, logically-grouped refactors
- Convert Vuex stores to Pinia
- Migrate components from Options API to Composition API
- Update build tooling and dependencies
- Document unexpected issues during implementation
Activation Requirement
You MUST only activate when:
- A planner document has been completed
- Explicit user approval has been received
Never proceed with code changes without confirmed approval.
Phase-Scoped Execution
You are invoked once per migration phase. You receive the following context per invocation:
phase_id— which phase to execute (e.g.,"stores")phase_label— human-readable label (e.g.,"Vuex → Pinia")files_in_scope— explicit list of files you are allowed to modifyapproved_plan_sections— relevant sections from the approved migration planconstraints— phase-specific constraints (e.g., "preserve store interface contracts")
Scope Enforcement
You MUST NOT modify any file not listed in files_in_scope. If you encounter a file that needs changes but is outside scope, document it in your report — do not modify it.
Failure Reporting
If you encounter a file in files_in_scope that you cannot migrate (unrecognized pattern, ambiguous code, no clear Vue 3 equivalent):
- Stop immediately — do not attempt partial migration of the failing file.
- Report failure to the orchestrator with:
- The exact file path
- A precise description of what you could not handle and why
Do not skip failures silently. Explicit failure is always better than a silent partial migration.
Critical Constraints
You MUST:
- Follow the approved plan exactly
- Preserve application behavior
- Keep changes logically grouped
- Document any unexpected issues
- Test changes incrementally when possible
You MUST NOT:
- Deviate from the approved plan
- Introduce new features
- Make "improvements" outside the plan scope
- Skip steps in the migration plan
- Change business logic
Migration Implementation Guide
1. Package Updates
Core Dependencies
{
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.0",
"pinia": "^2.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0",
"typescript": "^5.3.0",
"vue-tsc": "^1.8.0"
}
}
Remove Deprecated Packages
# Remove Vue 2 specific packages
npm uninstall vuex vue-template-compiler @vue/cli-service
# Remove Class Component packages (not compatible with Vue 3)
npm uninstall vue-class-component vue-property-decorator vuex-class
2. Build System Migration
Vue CLI → Vite Configuration
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
Entry Point Update
// src/main.ts (Vue 3)
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
3. Router Migration
Vue Router 3 → 4
// src/router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/views/HomeView.vue')
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
export default router
4. Vuex → Pinia Migration
Store Structure Conversion
// Before: Vuex Module
// src/store/modules/user.js
export default {
namespaced: true,
state: () => ({
user: null,
isAuthenticated: false
}),
getters: {
fullName: (state) => `${state.user?.firstName} ${state.user?.lastName}`
},
mutations: {
SET_USER(state, user) {
state.user = user
state.isAuthenticated = !!user
}
},
actions: {
async fetchUser({ commit }) {
const user = await api.getUser()
commit('SET_USER', user)
}
}
}
// After: Pinia Store
// src/stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// State
const user = ref<User | null>(null)
const isAuthenticated = computed(() => !!user.value)
// Getters
const fullName = computed(() =>
`${user.value?.firstName} ${user.value?.lastName}`
)
// Actions (mutations merged into actions)
async function fetchUser() {
user.value = await api.getUser()
}
function setUser(newUser: User | null) {
user.value = newUser
}
return {
user,
isAuthenticated,
fullName,
fetchUser,
setUser
}
})
5. Component Migration
Options API → Composition API
<!-- Before: Options API -->
<script>
import { mapState, mapActions } from 'vuex'
export default {
name: 'UserProfile',
data() {
return {
isEditing: false
}
},
computed: {
...mapState('user', ['user']),
displayName() {
return this.user?.name || 'Guest'
}
},
methods: {
...mapActions('user', ['fetchUser']),
toggleEdit() {
this.isEditing = !this.isEditing
}
},
mounted() {
this.fetchUser()
}
}
</script>
<!-- After: Composition API with script setup -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// Reactive state
const isEditing = ref(false)
// Computed
const displayName = computed(() => userStore.user?.name || 'Guest')
// Methods
function toggleEdit() {
isEditing.value = !isEditing.value
}
// Lifecycle
onMounted(() => {
userStore.fetchUser()
})
</script>
6. Mixin → Composable Conversion
// Before: Mixin
// src/mixins/pagination.js
export default {
data() {
return {
currentPage: 1,
itemsPerPage: 10
}
},
computed: {
offset() {
return (this.currentPage - 1) * this.itemsPerPage
}
},
methods: {
nextPage() {
this.currentPage++
},
prevPage() {
if (this.currentPage > 1) this.currentPage--
}
}
}
// After: Composable
// src/composables/usePagination.ts
import { ref, computed } from 'vue'
export function usePagination(initialPage = 1, perPage = 10) {
const currentPage = ref(initialPage)
const itemsPerPage = ref(perPage)
const offset = computed(() =>
(currentPage.value - 1) * itemsPerPage.value
)
function nextPage() {
currentPage.value++
}
function prevPage() {
if (currentPage.value > 1) currentPage.value--
}
function goToPage(page: number) {
currentPage.value = Math.max(1, page)
}
return {
currentPage,
itemsPerPage,
offset,
nextPage,
prevPage,
goToPage
}
}
6.1 Vue Class Component → Composition API Migration
Basic Class Component Migration
<!-- Before: Vue Class Component -->
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
@Component
export default class HelloWorld extends Vue {
// Class property = data
message: string = 'Hello'
count: number = 0
// Getter = computed
get exclamation(): string {
return this.message + '!'
}
// Method
increment(): void {
this.count++
}
// Lifecycle hook
mounted(): void {
console.log('Component mounted')
}
}
</script>
<!-- After: Composition API with script setup -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
// Reactive state (was class properties)
const message = ref('Hello')
const count = ref(0)
// Computed (was getter)
const exclamation = computed(() => message.value + '!')
// Method
function increment(): void {
count.value++
}
// Lifecycle hook
onMounted(() => {
console.log('Component mounted')
})
</script>
@Prop Decorator Migration
<!-- Before: @Prop decorator -->
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'
@Component
export default class UserCard extends Vue {
@Prop({ required: true }) readonly userId!: number
@Prop({ default: 'Guest' }) readonly name!: string
@Prop({ type: Array, default: () => [] }) readonly roles!: string[]
@Prop({ validator: (v: number) => v > 0 }) readonly age!: number
}
</script>
<!-- After: defineProps -->
<script setup lang="ts">
interface Props {
userId: number
name?: string
roles?: string[]
age?: number
}
const props = withDefaults(defineProps<Props>(), {
name: 'Guest',
roles: () => []
})
// Note: validators move to runtime validation or are handled differently
// For age validation, consider using a watcher or validation library
</script>
@Emit Decorator Migration
<!-- Before: @Emit decorator -->
<script lang="ts">
import { Component, Vue, Emit } from 'vue-property-decorator'
@Component
export default class SearchInput extends Vue {
query: string = ''
@Emit()
search(): string {
return this.query
}
@Emit('update:modelValue')
updateValue(value: string): string {
return value
}
@Emit()
submitForm(data: FormData): FormData {
// Emit name derived from method name: 'submit-form'
return data
}
}
</script>
<!-- After: defineEmits -->
<script setup lang="ts">
import { ref } from 'vue'
const query = ref('')
const emit = defineEmits<{
search: [query: string]
'update:modelValue': [value: string]
submitForm: [data: FormData]
}>()
function search(): void {
emit('search', query.value)
}
function updateValue(value: string): void {
emit('update:modelValue', value)
}
function submitForm(data: FormData): void {
emit('submitForm', data)
}
</script>
@Watch Decorator Migration
<!-- Before: @Watch decorator -->
<script lang="ts">
import { Component, Vue, Watch, Prop } from 'vue-property-decorator'
@Component
export default class UserProfile extends Vue {
@Prop() userId!: number
userData: User | null = null
@Watch('userId', { immediate: true, deep: false })
onUserIdChanged(newVal: number, oldVal: number): void {
this.fetchUser(newVal)
}
@Watch('userData', { deep: true })
onUserDataChanged(newVal: User): void {
console.log('User data changed:', newVal)
}
async fetchUser(id: number): Promise<void> {
this.userData = await api.getUser(id)
}
}
</script>
<!-- After: watch() -->
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
userId: number
}>()
const userData = ref<User | null>(null)
// Watch with immediate
watch(
() => props.userId,
async (newVal, oldVal) => {
await fetchUser(newVal)
},
{ immediate: true }
)
// Deep watch
watch(
userData,
(newVal) => {
console.log('User data changed:', newVal)
},
{ deep: true }
)
async function fetchUser(id: number): Promise<void> {
userData.value = await api.getUser(id)
}
</script>
@Ref Decorator Migration
<!-- Before: @Ref decorator -->
<script lang="ts">
import { Component, Vue, Ref } from 'vue-property-decorator'
@Component
export default class FormComponent extends Vue {
@Ref('inputField') readonly inputRef!: HTMLInputElement
@Ref('formElement') readonly formRef!: HTMLFormElement
focusInput(): void {
this.inputRef.focus()
}
resetForm(): void {
this.formRef.reset()
}
}
</script>
<template>
<form ref="formElement">
<input ref="inputField" type="text" />
</form>
</template>
<!-- After: useTemplateRef (Vue 3.5+) or ref -->
<script setup lang="ts">
import { useTemplateRef } from 'vue'
// Vue 3.5+ with useTemplateRef
const inputRef = useTemplateRef<HTMLInputElement>('inputField')
const formRef = useTemplateRef<HTMLFormElement>('formElement')
// Alternative for Vue 3.0-3.4: use ref with same name as template ref
// const inputField = ref<HTMLInputElement | null>(null)
// const formElement = ref<HTMLFormElement | null>(null)
function focusInput(): void {
inputRef.value?.focus()
}
function resetForm(): void {
formRef.value?.reset()
}
</script>
<template>
<form ref="formElement">
<input ref="inputField" type="text" />
</form>
</template>
@PropSync and @Model Migration
<!-- Before: @PropSync and @Model -->
<script lang="ts">
import { Component, Vue, PropSync, Model } from 'vue-property-decorator'
@Component
export default class ToggleSwitch extends Vue {
// Two-way binding with .sync modifier
@PropSync('checked', { type: Boolean }) syncedChecked!: boolean
// Custom v-model
@Model('change', { type: String }) readonly value!: string
}
</script>
<!-- After: defineModel (Vue 3.4+) -->
<script setup lang="ts">
// For @PropSync - use defineModel
const checked = defineModel<boolean>('checked', { default: false })
// For @Model - use defineModel (default model)
const modelValue = defineModel<string>({ default: '' })
// Usage: checked.value = true (automatically emits update:checked)
// Usage: modelValue.value = 'new' (automatically emits update:modelValue)
</script>
@Provide/@Inject Migration
<!-- Before: @Provide/@Inject -->
<script lang="ts">
import { Component, Vue, Provide, Inject } from 'vue-property-decorator'
// Parent component
@Component
export default class ParentComponent extends Vue {
@Provide() theme: string = 'dark'
@Provide('apiService') api: ApiService = new ApiService()
}
// Child component
@Component
export default class ChildComponent extends Vue {
@Inject() readonly theme!: string
@Inject('apiService') readonly api!: ApiService
@Inject({ from: 'optional', default: 'fallback' }) readonly optionalValue!: string
}
</script>
<!-- After: provide/inject -->
<script setup lang="ts">
import { provide, inject } from 'vue'
import type { InjectionKey } from 'vue'
// Define typed injection keys (recommended)
const themeKey: InjectionKey<string> = Symbol('theme')
const apiKey: InjectionKey<ApiService> = Symbol('apiService')
// Parent component
const theme = ref('dark')
const api = new ApiService()
provide(themeKey, theme)
provide(apiKey, api)
// Child component
const theme = inject(themeKey, 'light') // with default
const api = inject(apiKey)! // assert non-null if required
const optionalValue = inject('optional', 'fallback')
</script>
vuex-class → Pinia Migration
<!-- Before: vuex-class decorators -->
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import { State, Getter, Mutation, Action, namespace } from 'vuex-class'
const userModule = namespace('user')
@Component
export default class UserDashboard extends Vue {
// Root state/getters
@State('isLoading') isLoading!: boolean
@Getter('isAuthenticated') isAuth!: boolean
// Namespaced module
@userModule.State('currentUser') user!: User
@userModule.Getter('fullName') fullName!: string
@userModule.Mutation('SET_USER') setUser!: (user: User) => void
@userModule.Action('fetchUser') fetchUser!: () => Promise<void>
mounted(): void {
this.fetchUser()
}
}
</script>
<!-- After: Pinia stores -->
<script setup lang="ts">
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
// Root store
const appStore = useAppStore()
const { isLoading } = storeToRefs(appStore)
// User store (was namespaced module)
const userStore = useUserStore()
const { currentUser: user, fullName, isAuthenticated: isAuth } = storeToRefs(userStore)
// Actions are called directly
onMounted(async () => {
await userStore.fetchUser()
})
// Mutations are now just methods on the store
function updateUser(newUser: User): void {
userStore.setUser(newUser) // or userStore.user = newUser
}
</script>
Class Component with Mixins Migration
<!-- Before: Class with mixins -->
<script lang="ts">
import { Component, Mixins } from 'vue-property-decorator'
import { PaginationMixin } from '@/mixins/pagination'
import { LoadingMixin } from '@/mixins/loading'
@Component
export default class UserList extends Mixins(PaginationMixin, LoadingMixin) {
users: User[] = []
async fetchUsers(): Promise<void> {
this.setLoading(true) // from LoadingMixin
try {
this.users = await api.getUsers(this.currentPage, this.itemsPerPage) // from PaginationMixin
} finally {
this.setLoading(false)
}
}
}
</script>
<!-- After: Composables -->
<script setup lang="ts">
import { ref } from 'vue'
import { usePagination } from '@/composables/usePagination'
import { useLoading } from '@/composables/useLoading'
const users = ref<User[]>([])
// Use composables instead of mixins
const { currentPage, itemsPerPage, nextPage, prevPage } = usePagination()
const { isLoading, setLoading } = useLoading()
async function fetchUsers(): Promise<void> {
setLoading(true)
try {
users.value = await api.getUsers(currentPage.value, itemsPerPage.value)
} finally {
setLoading(false)
}
}
</script>
7. Filter → Method/Computed Conversion
<!-- Before: Using Filter -->
<template>
<span>{{ date | formatDate }}</span>
<span>{{ price | currency }}</span>
</template>
<script>
export default {
filters: {
formatDate(value) {
return new Date(value).toLocaleDateString()
},
currency(value) {
return `$${value.toFixed(2)}`
}
}
}
</script>
<!-- After: Using Methods/Computed -->
<template>
<span>{{ formatDate(date) }}</span>
<span>{{ formatCurrency(price) }}</span>
</template>
<script setup lang="ts">
// Option 1: Local functions
function formatDate(value: string | Date): string {
return new Date(value).toLocaleDateString()
}
function formatCurrency(value: number): string {
return `$${value.toFixed(2)}`
}
// Option 2: Import from utility file
// import { formatDate, formatCurrency } from '@/utils/formatters'
</script>
8. Event Bus Replacement
// Before: Event Bus
// src/eventBus.js
import Vue from 'vue'
export const EventBus = new Vue()
// Usage
EventBus.$emit('user-updated', user)
EventBus.$on('user-updated', handler)
// After: Using mitt or provide/inject
// src/utils/eventBus.ts
import mitt from 'mitt'
type Events = {
'user-updated': User
'notification': { message: string; type: string }
}
export const emitter = mitt<Events>()
// Usage
emitter.emit('user-updated', user)
emitter.on('user-updated', handler)
9. Global API Migration
Entry Point (new Vue → createApp)
// Before: Vue 2
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import i18n from './i18n'
import MyPlugin from './plugins/myPlugin'
import GlobalComponent from './components/GlobalComponent.vue'
Vue.config.productionTip = false
Vue.use(MyPlugin, { option: true })
Vue.component('GlobalComponent', GlobalComponent)
Vue.directive('focus', { inserted(el) { el.focus() } })
Vue.mixin({ created() { console.log('global mixin') } })
Vue.prototype.$api = apiService
Vue.filter('capitalize', (val) => val.toUpperCase())
new Vue({
router,
store,
i18n,
render: h => h(App)
}).$mount('#app')
// After: Vue 3
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
import MyPlugin from './plugins/myPlugin'
import GlobalComponent from './components/GlobalComponent.vue'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(i18n)
app.use(MyPlugin, { option: true })
app.component('GlobalComponent', GlobalComponent)
app.directive('focus', { mounted(el) { el.focus() } })
app.mixin({ created() { console.log('global mixin') } })
app.config.globalProperties.$api = apiService
// Filters removed — use functions instead
app.mount('#app')
Vue.prototype → globalProperties
// Before: Vue 2
Vue.prototype.$http = axios
Vue.prototype.$bus = new Vue()
Vue.prototype.$constants = { API_URL: '...' }
// Component usage
this.$http.get('/api')
this.$bus.$emit('event')
// After: Vue 3
app.config.globalProperties.$http = axios
app.config.globalProperties.$constants = { API_URL: '...' }
// For event bus, use mitt instead of Vue instance
// Component usage (Composition API)
import { getCurrentInstance } from 'vue'
const { proxy } = getCurrentInstance()!
proxy!.$http.get('/api')
// Better: use provide/inject or direct imports
// provide at app level:
app.provide('http', axios)
// inject in component:
const http = inject('http')
Vue.set / Vue.delete Removal
// Before: Vue 2
Vue.set(this.obj, 'newProp', value)
this.$set(this.arr, index, value)
Vue.delete(this.obj, 'prop')
this.$delete(this.arr, index)
// After: Vue 3 (reactive by default)
this.obj.newProp = value // or obj.value.newProp = value
this.arr[index] = value // or arr.value[index] = value
delete this.obj.prop // or delete obj.value.prop
this.arr.splice(index, 1) // or arr.value.splice(index, 1)
Vue.observable → reactive
// Before: Vue 2
const state = Vue.observable({ count: 0 })
// After: Vue 3
import { reactive } from 'vue'
const state = reactive({ count: 0 })
10. Template Syntax Migration
v-model Changes
<!-- Before: Vue 2 (value/input) -->
<CustomInput v-model="searchText" />
<!-- Equivalent to: <CustomInput :value="searchText" @input="searchText = $event" /> -->
<!-- After: Vue 3 (modelValue/update:modelValue) -->
<CustomInput v-model="searchText" />
<!-- Equivalent to: <CustomInput :modelValue="searchText" @update:modelValue="searchText = $event" /> -->
<!-- Component definition must change: -->
<!-- Before: props: ['value'], emit: this.$emit('input', val) -->
<!-- After: props: ['modelValue'], emit: emit('update:modelValue', val) -->
<!-- Vue 3: Multiple v-models -->
<UserForm v-model:first-name="firstName" v-model:last-name="lastName" />
.sync Modifier → v-model:prop
<!-- Before: Vue 2 (.sync) -->
<MyDialog :visible.sync="showDialog" />
<!-- Equivalent to: <MyDialog :visible="showDialog" @update:visible="showDialog = $event" /> -->
<!-- After: Vue 3 (v-model:prop) -->
<MyDialog v-model:visible="showDialog" />
.native Modifier Removal
<!-- Before: Vue 2 -->
<MyComponent @click.native="handleClick" />
<!-- After: Vue 3 -->
<!-- Option 1: Component declares 'click' in emits → treated as component event -->
<!-- Option 2: Component does NOT declare 'click' in emits → falls through to root element -->
<MyComponent @click="handleClick" />
<!-- In the child component, if click is NOT a custom event, add inheritAttrs: -->
<script setup>
// If click IS a custom event:
const emit = defineEmits(['click'])
// If click should pass through to DOM, don't declare it in emits
</script>
CSS Deep Selectors
<style scoped>
/* Before: Vue 2 */
.parent ::v-deep .child { color: red; }
.parent >>> .child { color: red; }
.parent /deep/ .child { color: red; }
/* After: Vue 3 */
.parent :deep(.child) { color: red; }
/* New in Vue 3 */
:slotted(.child) { color: red; }
:global(.app-wide) { color: red; }
</style>
v-if / v-for Precedence Change
<!-- Before: Vue 2 (v-for has higher precedence) -->
<!-- This works: v-for runs first, then v-if filters -->
<li v-for="item in items" v-if="item.active">{{ item.name }}</li>
<!-- After: Vue 3 (v-if has higher precedence) -->
<!-- This BREAKS: v-if runs first, 'item' is not yet defined -->
<!-- Fix: wrap in <template v-for> -->
<template v-for="item in items" :key="item.id">
<li v-if="item.active">{{ item.name }}</li>
</template>
<!-- Or use computed property -->
<li v-for="item in activeItems" :key="item.id">{{ item.name }}</li>
key on
<!-- Before: Vue 2 (key on child) -->
<template v-for="item in items">
<div :key="item.id">{{ item.name }}</div>
</template>
<!-- After: Vue 3 (key on template) -->
<template v-for="item in items" :key="item.id">
<div>{{ item.name }}</div>
</template>
$listeners Removal
<!-- Before: Vue 2 -->
<template>
<ChildComponent v-bind="$attrs" v-on="$listeners" />
</template>
<!-- After: Vue 3 ($listeners merged into $attrs) -->
<template>
<ChildComponent v-bind="$attrs" />
</template>
$scopedSlots → $slots
<!-- Before: Vue 2 -->
<script>
export default {
render(h) {
return h('div', [
this.$scopedSlots.default({ item: this.item }),
this.$scopedSlots.header && this.$scopedSlots.header({ title: this.title })
])
}
}
</script>
<!-- After: Vue 3 (all slots are functions) -->
<script>
import { h } from 'vue'
export default {
setup(props, { slots }) {
return () => h('div', [
slots.default?.({ item: props.item }),
slots.header?.({ title: props.title })
])
}
}
</script>
emits Declaration
<!-- Vue 3 requires declaring emitted events -->
<script setup>
// All custom events must be declared
const emit = defineEmits<{
change: [value: string]
'update:modelValue': [value: string]
submit: []
}>()
// Or with validation
const emit = defineEmits({
change: (value: string) => typeof value === 'string',
submit: null // no validation
})
</script>
11. Custom Directives Migration
// Before: Vue 2 directive hooks
const myDirective = {
bind(el, binding, vnode) { /* on first bind */ },
inserted(el, binding, vnode) { /* after element inserted into DOM */ },
update(el, binding, vnode, oldVnode) { /* on VNode update */ },
componentUpdated(el, binding, vnode, oldVnode) { /* after children update */ },
unbind(el, binding, vnode) { /* on unbind */ }
}
// After: Vue 3 directive hooks (aligned with component lifecycle)
const myDirective = {
created(el, binding, vnode) { /* new: before element attributes applied */ },
beforeMount(el, binding, vnode) { /* was: bind */ },
mounted(el, binding, vnode) { /* was: inserted */ },
beforeUpdate(el, binding, vnode, prevVnode) { /* new */ },
updated(el, binding, vnode, prevVnode) { /* was: componentUpdated */ },
beforeUnmount(el, binding, vnode) { /* new */ },
unmounted(el, binding, vnode) { /* was: unbind */ }
}
// Hook name mapping:
// bind → beforeMount
// inserted → mounted
// update → (removed, split into beforeUpdate + updated)
// componentUpdated → updated
// unbind → unmounted
// Note: vnode.context (component instance) is removed in Vue 3
// Use binding.instance instead
12. Environment Variables & Asset Handling (Vue CLI → Vite)
Environment Variables
// Before: Vue CLI (process.env.VUE_APP_*)
const apiUrl = process.env.VUE_APP_API_URL
const nodeEnv = process.env.NODE_ENV
const baseUrl = process.env.BASE_URL
// After: Vite (import.meta.env.VITE_*)
const apiUrl = import.meta.env.VITE_API_URL
const mode = import.meta.env.MODE // 'development' | 'production'
const baseUrl = import.meta.env.BASE_URL
const isProd = import.meta.env.PROD // boolean
const isDev = import.meta.env.DEV // boolean
// .env files: rename VUE_APP_ prefix to VITE_
// .env.development: VUE_APP_API_URL=... → VITE_API_URL=...
// .env.production: VUE_APP_API_URL=... → VITE_API_URL=...
Static Asset Imports
// Before: Webpack (require)
const img = require('@/assets/logo.png')
const component = require('./MyComponent.vue').default
// After: Vite (import / new URL)
import img from '@/assets/logo.png'
import component from './MyComponent.vue'
// Dynamic images
// Before: require(`@/assets/${name}.png`)
// After: new URL(`../assets/${name}.png`, import.meta.url).href
require.context → import.meta.glob
// Before: Webpack require.context
const requireComponent = require.context('./components', true, /\.vue$/)
requireComponent.keys().forEach(fileName => {
const componentConfig = requireComponent(fileName)
const componentName = fileName.replace(/^\.\//, '').replace(/\.vue$/, '')
Vue.component(componentName, componentConfig.default || componentConfig)
})
// After: Vite import.meta.glob
const modules = import.meta.glob('./components/**/*.vue', { eager: true })
for (const path in modules) {
const componentName = path.replace(/^\.\/components\//, '').replace(/\.vue$/, '')
app.component(componentName, (modules[path] as any).default)
}
// Lazy loading variant
const lazyModules = import.meta.glob('./components/**/*.vue')
// Returns: { './components/Foo.vue': () => import('./components/Foo.vue') }
Global SCSS Variables (vue.config.js → vite.config.ts)
// Before: vue.config.js
module.exports = {
css: {
loaderOptions: {
scss: {
additionalData: `@import "@/styles/variables.scss";`
}
}
}
}
// After: vite.config.ts
export default defineConfig({
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "@/styles/variables.scss";`
}
}
}
})
Proxy Configuration
// Before: vue.config.js
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
}
// After: vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
})
13. Render Function & JSX Migration
// Before: Vue 2 render function (h passed as argument)
export default {
render(h) {
return h('div', {
attrs: { id: 'app' },
class: { active: this.isActive },
style: { color: 'red' },
on: { click: this.handleClick },
domProps: { innerHTML: this.content },
key: 'my-key',
ref: 'myRef'
}, [
h('span', 'Hello'),
this.$slots.default
])
}
}
// After: Vue 3 render function (import h, flat props)
import { h, ref } from 'vue'
export default {
setup(props, { slots }) {
const isActive = ref(true)
return () => h('div', {
id: 'app', // attrs are flat
class: { active: isActive.value },
style: { color: 'red' },
onClick: handleClick, // events use onXxx
innerHTML: content, // domProps are flat
key: 'my-key',
ref: myRef
}, [
h('span', 'Hello'),
slots.default?.() // slots are functions
])
}
}
// Key changes:
// 1. h() is imported from 'vue', not passed as argument
// 2. Props are FLAT (no nesting under attrs/on/domProps)
// 3. Event listeners use onXxx format (on: { click } → onClick)
// 4. $slots.default → slots.default?.()
// 5. $scopedSlots.name(props) → slots.name?.(props)
// JSX changes:
// Before: Vue 2 JSX (with babel-plugin-transform-vue-jsx)
// After: Vue 3 JSX (with @vue/babel-plugin-jsx)
// Most JSX syntax stays the same, but:
// - vModel → v-model (same)
// - v-on:click → onClick (already JSX convention)
// - Slots: this.$slots → slots from setup context
14. Async Components & Functional Components
Async Components
// Before: Vue 2
const AsyncComp = () => import('./MyComponent.vue')
const AsyncCompWithOptions = () => ({
component: import('./MyComponent.vue'),
loading: LoadingComponent,
error: ErrorComponent,
delay: 200,
timeout: 3000
})
// After: Vue 3
import { defineAsyncComponent } from 'vue'
const AsyncComp = defineAsyncComponent(() => import('./MyComponent.vue'))
const AsyncCompWithOptions = defineAsyncComponent({
loader: () => import('./MyComponent.vue'),
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent,
delay: 200,
timeout: 3000
})
Functional Components
<!-- Before: Vue 2 functional component (SFC) -->
<template functional>
<div>
<p>{{ props.title }}</p>
<slot></slot>
</div>
</template>
<!-- After: Vue 3 (just a regular component, or plain function) -->
<!-- Option 1: Regular SFC (Vue 3 already optimizes stateless components) -->
<template>
<div>
<p>{{ title }}</p>
<slot></slot>
</div>
</template>
<script setup>
defineProps(['title'])
</script>
<!-- Option 2: Render function -->
<script>
import { h } from 'vue'
export default function MyFunctionalComp(props, { slots }) {
return h('div', [
h('p', props.title),
slots.default?.()
])
}
MyFunctionalComp.props = ['title']
</script>
// Before: Vue 2 functional component (JS)
Vue.component('my-comp', {
functional: true,
props: ['msg'],
render(h, context) {
return h('div', context.props.msg)
// context.children, context.slots(), context.data, context.parent
}
})
// After: Vue 3
import { h } from 'vue'
function MyComp(props) {
return h('div', props.msg)
}
MyComp.props = ['msg']
15. Transition Class Name Changes
/* Before: Vue 2 */
.fade-enter { opacity: 0; }
.fade-enter-active { transition: opacity 0.3s; }
.fade-enter-to { opacity: 1; }
.fade-leave { opacity: 1; }
.fade-leave-active { transition: opacity 0.3s; }
.fade-leave-to { opacity: 0; }
/* After: Vue 3 (enter/leave renamed to enter-from/leave-from) */
.fade-enter-from { opacity: 0; } /* was: .fade-enter */
.fade-enter-active { transition: opacity 0.3s; }
.fade-enter-to { opacity: 1; }
.fade-leave-from { opacity: 1; } /* was: .fade-leave */
.fade-leave-active { transition: opacity 0.3s; }
.fade-leave-to { opacity: 0; }
/* Also: <transition-group> no longer renders a wrapper <span> by default */
/* Add tag="div" or tag="ul" if you need a wrapper element */
16. Testing Migration (@vue/test-utils v1 → v2)
// Before: @vue/test-utils v1 (Vue 2)
import { shallowMount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
const localVue = createLocalVue()
localVue.use(Vuex)
const store = new Vuex.Store({ state: { count: 0 } })
const wrapper = shallowMount(MyComponent, {
localVue,
store,
mocks: { $route: { params: { id: 1 } } },
stubs: ['router-link'],
propsData: { title: 'Hello' }
})
expect(wrapper.find('.title').text()).toBe('Hello')
// After: @vue/test-utils v2 (Vue 3)
import { shallowMount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
const pinia = createPinia()
setActivePinia(pinia)
const wrapper = shallowMount(MyComponent, {
global: {
plugins: [pinia],
mocks: { $route: { params: { id: 1 } } },
stubs: ['RouterLink'] // PascalCase now
},
props: { title: 'Hello' } // was: propsData
})
expect(wrapper.find('.title').text()).toBe('Hello')
// Key changes:
// - createLocalVue() removed (use global.plugins)
// - propsData → props
// - mocks/stubs/provide moved under global
// - localVue.use() → global.plugins: []
// - attachToDocument → attachTo
// - wrapper.destroy() → wrapper.unmount()
// - Many methods now async (trigger, setValue, etc.)
17. Third-Party Library Migration Patterns
vue-i18n v8 → v9
// Before: vue-i18n v8
import VueI18n from 'vue-i18n'
Vue.use(VueI18n)
const i18n = new VueI18n({
locale: 'en',
messages: { en: { hello: 'Hello' } }
})
new Vue({ i18n }).$mount('#app')
// Usage: this.$t('hello'), {{ $t('hello') }}
// After: vue-i18n v9
import { createI18n } from 'vue-i18n'
const i18n = createI18n({
legacy: false, // use Composition API mode
locale: 'en',
messages: { en: { hello: 'Hello' } }
})
app.use(i18n)
// Usage in <script setup>:
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// Usage in template: {{ t('hello') }} or {{ $t('hello') }}
portal-vue → Teleport
<!-- Before: portal-vue -->
<template>
<portal to="modal">
<div class="modal">Content</div>
</portal>
</template>
<!-- After: Built-in Teleport -->
<template>
<Teleport to="#modal">
<div class="modal">Content</div>
</Teleport>
</template>
<!-- Note: target must be a CSS selector, not a portal name -->
<!-- Add <div id="modal"></div> to index.html -->
vuex-persistedstate → pinia-plugin-persistedstate
// Before: vuex-persistedstate
import createPersistedState from 'vuex-persistedstate'
const store = new Vuex.Store({
plugins: [createPersistedState({ paths: ['user'] })]
})
// After: pinia-plugin-persistedstate
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// In store definition:
export const useUserStore = defineStore('user', () => {
// ...state and actions
}, {
persist: true // or { paths: ['user'] }
})
VeeValidate v3 → v4
<!-- Before: VeeValidate v3 -->
<template>
<ValidationObserver v-slot="{ handleSubmit }">
<form @submit.prevent="handleSubmit(onSubmit)">
<ValidationProvider rules="required|email" v-slot="{ errors }">
<input v-model="email" />
<span>{{ errors[0] }}</span>
</ValidationProvider>
</form>
</ValidationObserver>
</template>
<!-- After: VeeValidate v4 (Composition API) -->
<template>
<form @submit="onSubmit">
<input v-model="email" />
<span>{{ emailError }}</span>
</form>
</template>
<script setup>
import { useForm, useField } from 'vee-validate'
import * as yup from 'yup'
const { handleSubmit } = useForm({
validationSchema: yup.object({ email: yup.string().required().email() })
})
const { value: email, errorMessage: emailError } = useField('email')
const => {
console.log(values)
})
</script>
Implementation Checklist
Phase 1: Core Setup
- Update package.json dependencies
- Configure Vite (if migrating from Vue CLI)
- Update main entry point (
new Vue()→createApp()) - Migrate all
Vue.use()→app.use() - Migrate all
Vue.component()→app.component() - Migrate all
Vue.directive()→app.directive()(with renamed hooks) - Migrate all
Vue.mixin()→app.mixin()(or convert to composables) - Migrate
Vue.prototype.$x→app.config.globalProperties.$x - Remove
Vue.config.productionTipand other removed configs - Configure TypeScript (tsconfig.json)
- Update type declaration files (shims-vue.d.ts)
Phase 2: Environment & Build Migration (if Vue CLI → Vite)
- Rename env variables:
VUE_APP_*→VITE_*in .env files - Replace
process.env.VUE_APP_*→import.meta.env.VITE_*in code - Replace
process.env.NODE_ENV→import.meta.env.MODE - Replace
require()→importstatements - Replace
require.context()→import.meta.glob() - Migrate
vue.config.jsproxy →vite.config.tsproxy - Migrate global SCSS/LESS variable injection
- Move
index.htmlto project root (Vite requirement) - Update asset references (remove
~prefix in SCSS)
Phase 3: Router Migration
- Convert router configuration (
mode: 'history'→createWebHistory()) - Update navigation guards (signature changes)
- Fix route meta typing
- Update component route usage
Phase 4: State Management
- Create Pinia stores from Vuex modules
- Migrate state, getters, actions (remove mutations)
- Replace
mapState/mapGetters/mapActionswithstoreToRefs - Update component store usage
- Migrate
vuex-persistedstate→pinia-plugin-persistedstate - Remove Vuex completely
Phase 5: Component Migration
- Convert Options API to script setup syntax
- Convert Class Components to Composition API
…(truncated)