2 to 3 Migration – A Practical Journey

About Me

  • Core member of Vue team
  • Not a JavaScript or front-end developer by profession
  • OSS proponent

Koel (rememer to switch to the app Nao)

First, Package Upgrade EzPz

-    "vue": "^2.5.0",
+    "vue": "^3",
-    "vue-loader": "^13.0.5",
+    "vue-loader": "^17",
-    "eslint-plugin-vue": "^6.2.2",
+    "eslint-plugin-vue": "^8",

Mounting the App

// App.ts
import { createApp } from 'vue'
import { clickaway, droppable, focus } from '@/directives'
import App from './App.vue'

createApp(App)
  .directive('koel-focus', focus)
  .directive('koel-clickaway', clickaway)
  .directive('koel-droppable', droppable)
  .mount('#app')

v3

// App.ts
import Vue, { VNode, CreateElement } from 'vue'
import { clickaway, droppable, focus } from '@/directives'
import App from './app.vue'

Vue.config.productionTip = false

new Vue({
  el: '#app',
  render: (h: CreateElement): VNode => h(App)
})

Vue.directive('koel-focus', focus)
Vue.directive('koel-clickaway', clickaway)
Vue.directive('koel-droppable', droppable)

v2

The Case of Event Bus

// eventBus.ts
export const eventBus = {
  all: new Map(),

  on (name: string, cb: Function) {
    this.all.has(name) ? this.all.get(name).push(cb) : this.all.set(name, [cb])
    return this
  },

  emit (name: string, ...args: any) {
    this.all.get(name)?.forEach((cb: Function) => cb(...args))
    return this
  }
}

v3

// App.vue
import { eventBus } from '@/utils'


eventBus.on('KOEL_READY', () => initEqualizer())
eventBus.emit('KOEL_READY')
// eventBus.ts
import Vue from 'vue'

export const eventBus = {
  bus: new Vue(),
  
  on (name: string, cb: Function) {
    this.bus.$on(name, cb)
    return this
  },
 
  emit (name: string, ...args: any) {
    this.bus.$emit(name, ...args)
    return this
  }
}

v2

Reactivity SuperCharged

// SongStore.ts
import Vue from 'vue'

Vue.set(song, 'lyrics', '')
Vue.set(song, 'playCount', 0)
Vue.set(song, 'liked', false)
Vue.set(song, 'playbackState', 'Stopped')

v2

// SongStore.ts
import { reactive } from 'vue'

const song = reactive(fetchedData)

song.lyrics = ''
song.playCount = 0
song.liked = false
song.playbackState = 'Stopped'

v3

Life Before Composition API: Mixins

<!-- SongContextMenu.vue -->
<template>
  <ul>
    <li>
      Add To
      <ul>
        <li @click="queueSongsAfterCurrent">After Current Song</li>
        <li @click="queueSongsToBottom">Bottom of Queue</li>
        <li @click="queueSongsToTop">Top of Queue</li>
        <li @click="addSongsToFavorite">Favorites</li>
        <li v-for="playlist in playlists" @click="addSongsToExistingPlaylist(playlist)">
          {{ playlist.name }}
        </li>
      </ul>
    </li>
  </ul>
</template>

<script lang="ts">
import mixins from 'vue-typed-mixins'
import songMenu from '@/mixins/SongMenu.ts'

export default mixins(songMenu).extend({
  //...
})
</script>
// mixins/SongMenu.ts
export default Vue.extend({
  props: {
    songs: {
      type: Array,
      required: true
    } as PropOptions<Song[]>
  },

  methods: {
    queueSongsAfterCurrent () {
      queueStore.queueAfterCurrent(this.songs)
    },

    queueSongsToBottom () {
      queueStore.queue(this.songs)
    },

    queueSongsToTop () {
      queueStore.queueToTop(this.songs)
    },

    addSongsToFavorite () {
      favoriteStore.like(this.songs)
    },

    addSongsToExistingPlaylist (playlist: Playlist) {
      playlistStore.addSongs(playlist, this.songs)
    }
  }
})

v2

<!-- SongContextMenu.vue -->
<template>
  <ul>
    <li>
      Add To
      <ul>
        <li @click="queueSongsAfterCurrent">After Current Song</li>
        <li @click="queueSongsToBottom">Bottom of Queue</li>
        <li @click="queueSongsToTop">Top of Queue</li>
        <li @click="addSongsToFavorite">Favorites</li>
        <li v-for="playlist in playlists" @click="addSongsToExistingPlaylist(playlist)">
          {{ playlist.name }}
        </li>
      </ul>
    </li>
  </ul>
</template>

<script lang="ts" setup>
import { useSongMenu } from '@/composables'

const {
  queueSongsAfterCurrent,
  queueSongsToBottom,
  queueSongsToTop,
  addSongsToFavorite,
  addSongsToExistingPlaylist
} = useSongMenu(toRef(context, 'songs') as Ref<Song[]>)
</script>
// composables/useSongMenu.ts
export const useSongMenu = (songs: Ref<Song[]>) => {
  const queueSongsAfterCurrent = () => queueStore.queueAfterCurrent(songs.value)
  const queueSongsToBottom = () => queueStore.queue(songs.value)
  const queueSongsToTop = () => queueStore.queueToTop(songs.value)
  const addSongsToFavorite = () => favoriteStore.like(songs.value)
  const addSongsToExistingPlaylist = (playlist: Playlist) => playlistStore.addSongs(playlist, songs.value)

  return {
    queueSongsAfterCurrent,
    queueSongsToBottom,
    queueSongsToTop,
    addSongsToFavorite,
    addSongsToExistingPlaylist
  }
}

v3

Life After Composition API: Composables

Async Components

// ModalWrapper.vue
import Vue from 'vue'

export default Vue.extend({
  components: {
    CreateSmartPlaylistForm: () => import('@/components/CreateSmartPlayListForm.vue'),
    EditSmartPlaylistForm: () => import('@/components/EditSmartPlayListForm.vue'),
    AddUserForm: () => import('@/components/AddUserForm.vue'),
    EditUserForm: () => import('@/components/EditUserForm.vue'),
    EditSongForm: () => import('@/components/EditSongForm.vue'),
    AboutDialog: () => import('@/components/AboutDialog.vue')
  }
})

v2

// ModalWrapper.vue
import { defineAsyncComponent } from 'vue'

const CreateSmartPlaylistForm = defineAsyncComponent(() => import('@/components/CreateSmartPlaylistForm.vue'))
const EditSmartPlaylistForm = defineAsyncComponent(() => import('@/components/EditSmartPlaylistForm.vue'))
const AddUserForm = defineAsyncComponent(() => import('@/components/AddUserForm.vue'))
const EditUserForm = defineAsyncComponent(() => import('@/components/EditUserForm.vue'))
const EditSongForm = defineAsyncComponent(() => import('@/components/EditSongForm..vue'))
const AboutKoel = defineAsyncComponent(() => import('@/components/AboutKoel.vue'))

v3

The Fantastic <Script SetUp>

<!-- YouTubeVideoItem.vue -->
<script lang="ts">
import Vue, { PropOptions } from 'vue'
import { youtubeService } from '@/services'

export default Vue.extend({
  props: {
    video: {
      type: Object,
      required: true
    } as PropOptions<YouTubeVideo>
  },
  
  computed: {
    url (): string {
      return `https://youtu.be/${this.video.id.videoId}`)
    }
  },

  methods: {
    play: (video: YouTubeVideo) => youtubeService.play(video)
  }
})
</script>

v2

<!-- YouTubeVideoItem.vue -->
<script lang="ts" setup>
import { computed, toRefs } from 'vue'
import { youTubeService } from '@/services'

const props = defineProps<{ video: YouTubeVideo }>()
const { video } = toRefs(props)

const url = computed(() => `https://youtu.be/${video.value.id.videoId}`)

const play = () => youTubeService.play(video.value)
</script>

<template>
  <a :href="url" @click.prevent="play">
    <img :src="video.snippet.thumbnails.default.url">
    <h3>{{ video.snippet.title }}</h3>
    <p>{{ video.snippet.description }}</p>
  </a>
</template>

v3

Components and model Bindings

v3

// ViewModeSwitch.vue
import { computed } from 'vue'

const props = withDefaults(defineProps<{ modelValue?: ArtistAlbumViewMode }>(), { modelValue: 'thumbnails' })
const emit = defineEmits(['update:modelValue'])

const value = computed({
  get: () => props.modelValue,
  set: value => emit('update:modelValue', value)
})
<!-- ArtistsScreen.vue -->
<ViewModeSwitch v-model="viewMode"/>
// ViewModeSwitch.vue
props: {
  value: {
    type: String as PropType<ViewMode>
  }
},

data: () => ({
  mutatedValue: null as ViewMode | null
}),

watch: {
  value (mode: ViewMode) {
    this.mutatedValue = mode
  }
},

methods: {
  onInput (e: InputEvent): void {
    this.$emit('input', (e.target as HTMLInputElement).value)
  }
}
<!-- ArtistsScreen.vue -->
<ViewModeSwitch v-model="viewMode"/>

v2

Components and model Bindings

<!-- EditUserProfile.vue -->
<FullName v-model:first-name="user.firstName" v-model:last-name="user.lastName"/>

v3

<!-- FullName.vue -->
<script setup>
defineProps({ firstName: String, lastName: String })
defineEmits(['update:firstName', 'update:lastName'])
</script>

<template>
  <input
    type="text"
    :value="firstName"
    @input="$emit('update:firstName', $event.target.value)"
  />
  <input
    type="text"
    :value="lastName"
    @input="$emit('update:lastName', $event.target.value)"
  />
</template>

About Testing

  • @vue/test-utils now considered low-level: Only use if you are building advanced components that require testing Vue-specific internals.
  • Officially recommended unit testing library is now @testing-library/vue, built on top of @vue/test-utils.
  • Vitest gaining momentum as a blazingly-fast test runner/framework powered by Vite. Support for Vue component is just a matter of one extra plugin.
  • No significant or relevant changes made to E2E test suite.
  • Since @testing-library's philosophy is to make tests resemble the way software is used by the user, the line between component unit testing and E2E can be very thin.

The Aftermath

$ npx cloc --diff v2 v3 --include-ext=ts,vue

-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
SUM:
 same                            4             13             67            704
 modified                       22              0             14            251
 added                         136           1969            416          10002
 removed                       139           2138            580          11845
-------------------------------------------------------------------------------
  • Does it have to be Composition API / <script setup>?
  • Should everyone upgrade?
  • Read more @ https://v3-migration.vuejs.org

Thank You!

koel.dev

github.com/phanan

twitter.com/notphanan

phanan.net

Vue 2 to 3 Migration – A Practical Journey

By Phan An

Vue 2 to 3 Migration – A Practical Journey

  • 321