В статье хочу поделиться опытом переписывания существующих классовых компонентов vue на новый синтаксис vue-composition-api.

Немного о нашем стеке.

Наше приложение написано на nuxt2 + vue-class-components + typescript. Из-за стека переезд на новый nuxt затруднился тем, что прежде чем сменить версию nuxt со 2 на 3 нам нужно переписать все наши компоненты. Тут нас очень спасла библиотека vuejs/composition-api и nuxtjs-composition-api

В статье разберем случаи от самых примитивных до менее примитивных.

Стоит сразу отметить, что в composition-api вся магия происходит внутри метода setup , который включает в себя 2 хука жизненного цикла vue компонента: beforeCreate и created
Помимо основных примеров я покажу как будет работать типизация в тех или иных кейсах.

* Все названия переменных вымышлены и не используются на продуктиве)

Поехали!

  1. State компонента
    *
    В примерах localValue будет являться часть component state

    В классовых компонентах стейт компонента представлен как свойства класса.

    @Component({})
    export default class ExampleClass extends Vue {
     localValue: string = null
    }

    nuxtjs/composition-api - примеры кода буду показывать с использованием данной библиотеки. В базе она использует тот же vuejs/composition-api и добавляет ряд своих методов для интеграции с nuxt.

    import {
      ref,
      defineComponent
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleClass',
      
      setup() {
        const localValue = ref(null)
    
        return {
          localValue
        }
      }
    })

    из setup возвращается объект с теми свойствами, которые далее нужны будут или в template или к ним будут обращаться из родительских компонентов.

  2. Типизация state
    *
    Типизируем объект objectvalue

    В классовых компонентах стейт компонента типизируется внутри класса.

    interface IStateObject {
      name: string,
      value: number
    }
    
    @Component({})
    export default class ExampleClass extends Vue {
     objectvalue: IStateObject = {
       name: 'example',
       value: 2
     }
    }

    nuxtjs/composition-api

    import {
      ref,
      defineComponent
    } from '@nuxtjs/composition-api'
    
    interface IStateObject {
      name: string,
      value: number
    }
    
    export default defineComponent({
      name: 'ExampleClass',
      
      setup() {
        const objectvalue = reactive<IStateObject>({
           name: 'example',
           value: 2
         })
    
        return {
          objectvalue
        }
      }
    })

    На примерах видно, что переменной состояния задается дефолтное значение

    { name: 'example', value: 2 }

  3. Пропсы компонента
    *
    В примерах пропсом будет являться значение exampleProps

    В классовых компонентах пропсы передаются в декораторе @Component.

    @Component({
      props: {
        exampleProps: {
          type: Number,
          default: 1
        }
      }
    })
    export default class ExampleClass extends Vue {
      readonly exampleProps: number
      
     localValue: string = nul
    }

    nuxtjs/composition-api - пропсы описываются так же как и во vue2. Чтобы иметь доступ к пропсам внутри setup их нужно превратить в стейт компонента. Для этого используется метод toRefs

    import {
      ref,
      toRefs,
      defineComponent
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleClass',
    
      props: {
        exampleProps: {
          type: Number,
          default: 1
        }
      },
      
      setup(props) {
        const { exampleProps } = toRefs(props)
        
        const localValue = ref(null)
    
        return {
          localValue
        }
      }
    })

    Возвращать пропсы из setup не нужно. Они и так будут доступны в template.

  4. Типизация пропсов
    *
    Типизируем объект objectProps

    В классовых компонентов типизация пропсов проиходит внутри класса.

    interface IObjectProps {
      name: string,
      value: number
    }
    
    @Component({
      props: {
        objectProps: {
          type: Object,
          required: true
        }
      }
    })
    export default class ExampleClass extends Vue {
      readonly objectProps: IObjectProps
    }

    nuxtjs/composition-api - пропсы типизируются с помощью PropType

  5. Computed properties или вычисляемые свойства
    *
    В примерах isExamplePropsEqualsTwo является вычисляемым свойством

    В классовых компонентах вычисляемые свойства обозначаются как get метод

    @Component({
      props: {
        exampleProps: {
          type: Number,
          default: 1
        }
      }
    })
    export default class ExampleClass extends Vue {
      readonly exampleProps: number
    
      get isExamplePropsEqualsTwo () {
        return this.exampleProps === 2
      }
    }

    nuxtjs/composition-api - вычисляемые свойства создаются с помощью метода computed

    import {
      toRefs,
      computed,
      defineComponent
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleClass',
    
      props: {
        exampleProps: {
          type: Number,
          default: 1
        }
      },
      
      setup(props) {
        const { exampleProps } = toRefs(props)
    
        const isExamplePropsEqualsTwo = computed(() => {
          return exampleProps.value === 2
        })
    
        return {
          isExamplePropsEqualsTwo
        }
      }
    })

    Из-за особенности работы ref , чтобы получить значение переменной, нужно обратиться к ее свойству value

  6. Типизация computed properties
    В целом в большинстве случаев указывать тип вычисляемую свойству нет необходимости, потому что он правильно определяется, но бывают случаи когда его нужно указать явно.

    * Типизируем вычисляемое свойство isExamplePropsEqualsTwo

    Классовые компоненты

    @Component({
      props: {
        exampleProps: {
          type: Number,
          default: 1
        }
      }
    })
    export default class ExampleClass extends Vue {
      readonly exampleProps: number
    
      get isExamplePropsEqualsTwo (): number {
        return this.exampleProps === 2
      }
    }

    nuxtjs/composition-api

    import {
      toRefs,
      computed,
      defineComponent
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleClass',
    
      props: {
        exampleProps: {
          type: Number,
          default: 1
        }
      },
      
      setup(props) {
        const { exampleProps } = toRefs(props)
    
        const isExamplePropsEqualsTwo = computed<number>(() => {
          return exampleProps.value === 2
        })
    
        return {
          isExamplePropsEqualsTwo
        }
      }
    })

  7. Сеттер для вычисляемого свойства
    *
    В примерах innerValue является вычисляемым свойством

    В классовых компонентах сеттер для вычисляемого свойства, как можно догадаться, назначается с использованием set

    @Component({
      props: {
        value: {
          type: String,
          default: null
        }
      }
    })
    export default class ExampleClass extends Vue {
      readonly value: string
    
      get innerValue (): string {
        return value
      }
    
      set innerValue (value: number) {
        this.$emit('input', value)
      }
    }

    nuxtjs/composition-api

    import {
      toRefs,
      computed,
      defineComponent
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleClass',
    
      props: {
        value: {
          type: String,
          default: null
        }
      },
      
      setup(props, { emit }) {
        const { value } = toRefs(props)
    
        const innerValue = computed({
          get: () => value.value,
          set: (value) => emit('input', value)
        })
    
        return {
          innerValue
        }
      }
    })

    Про emit пока не думаем. Его разберем далее по статье

  8. Методы
    Тут все достаточно банально.

    * В примерах sayHello является методом.

    Классовые компоненты - методы это методы класса.

    @Component({})
    export default class ExampleClass extends Vue {
      sayHello () {
        console.log("hello world")
      }
    }

    nuxtjs/composition-api

    import {
      defineComponent
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleClass',
      
      setup() {
        const sayHello = () => {
          console.log("hello world")
        }
    
        return {
          sayHello
        }
      }
    })

  9. Хуки жизненного цикла
    В composition-api список хуков жизненного цикла обновился. Хуков beforeCreated и created теперь нет, они имплементированы в setup

    * Хук created

    Классовые компоненты

    @Component({})
    export default class ExampleClass extends Vue {
      created () {
        console.log("created")
      }
    }

    nuxtjs/composition-api

    import {
      defineComponent
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleClass',
      
      setup() {
        console.log("created")
      }
    })


    * mounted/onMounted

    Классовые компоненты

    @Component({})
    export default class ExampleClass extends Vue {
      mounted () {
        console.log("mounted")
      }
    }

    nuxtjs/composition-api

    import {
      onMounted,
      defineComponent
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleClass',
      
      setup() {
        onMounted(() => {
          console.log("mounted")
        })
      }
    })

    Тут может возникнуть недопоминае касательно того где же теперь делать асинхронные запросы. Во vue документации говорится о Suspense компонентах - это компоненты, которые имеют async setup
    Не будем останавливаться на этом моменте сейчас. Просто оставлю ссылку на соотвествующую документацию.

  10. Отписка от нативных событий window
    Решила вынести эту тему отдельно, потому что классовых компонентах очень удобно реализована возможность добавления события в хук beforeDestroy, а в новом синтаксисе отписка от нативных событий начинает выглядеть совершенно иначе.

    Классовые компоненты - на мовй взгляд очень элегантная реализация получается благодаря this.$on

    @Component({})
    export default class ExampleClass extends Vue {
      isVisible = false
      
      mounted () {
        const timeoutId = setTimeout(() => {
            this.isVisible = true
          }, 300)
    
          this.$on('hook:beforeDestroy', () => {
            clearTimeout(timeoutId)
          })
      }
    }

    nuxtjs/composition-api

    import {
      ref,
      onMounted,
      onBeforeUnmount,
      defineComponent
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleClass',
      
      setup() {
        let timeoutId = null
    
        const isVisible = ref(false)
    
        onMounted(() => {
          timeoutId = setTimeout(() => {
            isVisible.value = true
          }, 300)
        })
    
        onBeforeUnmount(() => {
          clearTimeout(timeoutId)
        })
      }
    })

    Решение не такое изящное, так как приходится выносить локальную переменную в общую кучу.

  11. Watch
    * Отслеживаемое свойство localValue

    В классовых компонентах watch назначается внутри декоратора @Component

    @Component({
      watch: {
        localValue (value: string) {
          console.log('localValue was updated', value)
        }
      }
    })
    export default class ExampleClass extends Vue {
     localValue: string = null
    }

    nuxtjs/composition-api - отслеживаемые свойства назначаются с помощью метода watch. Причем на каждое свойство назначается отдельный watch.

    import {
      ref,
      watch,
      defineComponent
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleClass',
      
      setup() {
        const localValue = ref(null)
    
        watch(localValue, (value: string) => {
          console.log('localValue was updated', value)
        })
    
        return {
          localValue
        }
      }
    })

    watch также может принимать 3м параметром объект с настройками такими как deep, immediate

  12. Emit событий
    *
    Эмитируем событие input

    В классовых компонентах $emit доступен внутри комнтекста класса компонента

    @Component({})
    export default class ExampleClass extends Vue {
      notifyOthers () {
        this.$emit('input', 'new Value')
      }
    }

    nuxtjs/composition-api - emit является свойством объекта, который передается вторым параметром в setup

    import {
      defineComponent
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleClass',
      
      setup(_props, { emit }) {
        const notifyOthers = () => {
          emit('input', 'new Value')
        }
      }
    })

  13. Контекст
    * Значением из контекста store

    В классовых компонентах все что лежит в контексте доступно по ключевому слову this

    @Component({})
    export default class ExampleClass extends Vue {
     get somethingFromStore () {
       return this.$store.state.app.value
     }
    }

    nuxtjs/composition-api - для доступа к значением контекст необходимо использовать метод useContext

    import {
      computed,
      useContext,
      defineComponent
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleClass',
      
      setup() {
        const { store } = useContext()
        
        const somethingFromStore = computed(() => {
          return store.state.app.value
        })
    
        return {
          somethingFromStore
        }
      }
    })

  14. Ref - сохранение ссылки на html элемент/компонент
    * В примерах сохраним ссылку на input и childComponent

    В классовых компонентах для обращения к ссылкам на переменные используется свойство $refs, в котором указывается список всех элементов, к которой компонент будет обращаться

    @Component({})
    export default class ExampleClass extends Vue {
     $refs: {
       input: HTMLInputElement,
       childComponent: SomeComponent
     }
    
      emptyInput () {
        this.$refs.input.value = null
      }
    
      callSomeChildMethod () {
        this.$refs.childComponent.exampleMethod()
      }
    }

    nuxtjs/composition-api - ссылка на элемент это тот же ref, то есть часть состояния компонента. Чтобы свойство компонента связалось с компонентом необходимо ее вернуть из setup

    import {
      ref,
      defineComponent
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleClass',
      
      setup() {
        const input = ref<HTMLInputElement>(null)
        const childComponent = ref(null)
      
        const emptyInput = () => {
          input.value.value = null
        }
      
        const callSomeChildMethod = () => {
          childComponent.value.exampleMethod()
        }
    
        return {
          input,
          childComponent
        }
      }
    })

    Тут отмечу, что конструкция input.value.value появилась из-за особенности ref

  15. Типизация ref компонентов
    * Типизируем свойство childComponent из примера 14

    Для классовых компонентов ничего не поменяется если childComponent остается классовым компонентом. Если же childComponent уже переписан под новый синтаксис, то для него необходимо интерфейс, описывающий какие свойства и методы возвращаются из setup у childComponent

    * Интерфейс для childComponent лучше хранить в самом компоненте childComponent

    ExampleClass

    import { ISomeChildComponent } from './SomeComponent.vue'
    
    @Component({})
    export default class ExampleClass extends Vue {
     $refs: {
       childComponent: ISomeChildComponent
     }
    
      callSomeChildMethod () {
        this.$refs.childComponent.exampleMethod()
      }
    }

    SomeChildComponent

    import {
      ref,
      defineComponent
    } from '@nuxtjs/composition-api'
    
    // наследуемся от Element так как в классовых компонентах ожидается,
    // что ref будет расширенной версией Element  
    export interface ISomeChildComponent extends Element {
      someLocalValue: string,
    
      someLocalMethod: () => void
    }
    
    export default defineComponent({
      name: 'SomeChildComponent',
      
      setup() {
        const someLocalValue = ref(null)
      
        const someLocalMethod = () => {
          console.log("hello")
        }
    
        return {
          someLocalValue,
          someLocalMethod
        }
      }
    })

    nuxtjs/composition-api - тут такая же история как и для классовых компонентов. Если компонент, на который есть ссылка является классовым, то ничего не меняем. Если же компонент уже переписан, то компонент необходимо описать в интерфейсе.

  16. Рекомендации по сохранению ссылки на самого себя

    В некоторых случаях нам нужно обратиться к родительскому блоку компонента

    В классовых компонентах ссылка на главный блок компонента хранится в this.$el

    @Component({})
    export default class ExampleClass extends Vue {
      findChildElements () {
        // находим все span элементы внутри данного компонента
        console.log(this.$el.querySelector('span'))
      }
    }

    nuxtjs/composition-api - Тут есть несколько вариантов как обратиться к текущему элементу. Рассмотрим вариант с ref

    import {
      ref,
      defineComponent
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleClass',
      
      setup() {
        const root = ref(null)
      
        const findChildElements = () => {
          // находим все span элементы внутри данного компонента
          console.log(root.value.querySelector('span'))
        }
    
        return {
          // обязательно возвращаем root
          root,
          findChildElements
        }
      }
    })
    
    <template>
      <div ref="root">
        <span> 1 </span>
        <span> 2 </span>
        <span> 3 </span>
      </div>
    </template>

    В пункте 17 будем рассматривать работу с currentInstance и это будет вторым способом обращения к блоку текущего элемента

  17. CurrentInstance - обратиться к контексту текущего компонента

    Для классовых компонентов контекст всегда доступен по ключевому слову this, поэтому все что будет далее описано для composition-api можно смело получить через this.

    nuxtjs/composition-api - будем использовать метод getCurrentInstance

    import {
      defineComponent,
      getCurrentInstance
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleClass',
      
      setup() {
        const instance = getCurrentInstance()
      
        const findChildElements = () => {
          // находим все span элементы внутри данного компонента
          console.log(instance.proxy.$el.querySelector('span'))
        }
    
        return {
          findChildElements
        }
      }
    })

    Данный способ получения instance очень пригождается, когда есть необходимость обратиться к таким свойствам как например $vnode

  18. Задачка: Нужно вручную создать и сохранить инстанс компонента через код

    Когда переписывала эту часть приложения пришлось пошевелить мозгами и порыть доки.
    Такой функционал нам нужен был, чтобы для карты создавать попап и передавать код созданного попапа карте, чтобы уже карта установила его в необходимое ей место.

    Классовые компоненты

    @Component({})
    export default class ExampleClass extends Vue {
      createComponentFromCode (el) {
        // в el приходит ссылка на блок, куда будет смонтирован компонент
        const examplePopup = new ExamplePoopup({ parent: this }).$mount(el)
      }
    }

    nuxtjs/composition-api - тут мы прибегнем к некоторым хакам работы vue + getCurrentInstance

    import {
      defineComponent,
      getCurrentInstance
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleClass',
      
      setup() {
        const instance = getCurrentInstance()
      
        const createComponentFromCode = (el) => {
          // в el приходит ссылка на блок, куда будет смонтирован компонент
    
          const Popup = Vue.extend(ExamplePoopup)
    
          const examplePopup = new ExamplePoopup({ parent: instance.proxy }).$mount(el)
        }
      }
    })

  19. Inject/Provide
    * В примерах будет передаваться и инджектиться объект exampleInject

    В классовых компонентах provide/inject описывается в декораторе @Component

    ExampleProvideClass

    @Component({
      provide () {
        return {
          exampleInject: this.exampleInject
        }
      }
    })
    export default class ExampleProvideClass extends Vue {
      exampleInject = {
        name: 'example',
        value: 'inject'
      }
    }

    ExampleInjectClass

    @Component({
      inject: ['exampleInject']
    })
    export default class ExampleInjectClass extends Vue {
      // для таких случаев конечнолучше написать интерфейс
      exampleInject: { name: string, value: string }
    }

    nuxtjs/composition-api - будем использовать методы provide/inject

    ExampleProvideClass

    import {
      ref,
      provide,
      defineComponent
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleProvideClass',
      
      setup() {
        const exampleInject = ref({
          name: 'example',
          value: 'inject'
        })
        
         provide('exampleInject', exampleInject.value)
    
      }
    })

    ExampleInjectClass

    import {
      inject,
      defineComponent
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleInjectClass',
      
      setup() {
        // вторым параметром можно указать значение по умолчанию 
        // Для типизации лучше написать интерфейс
         const exampleInject = inject<{ name: string, value: string }>('exampleInject', null)
    
      }
    })

  20. Что-то из приложения
    * В примерах значение exampleFeature будет браться из контекста приложения

    Мы не все и всегда записываем в контекст, что-то просто инджектится в приложение.

    exampleFeature

    import { Plugin } from '@nuxt/types'
    
    const plugin: Plugin = (_, inject) => {
      const feature = {
        someMethod: () => { console.log("Very cool feature")}
      }
    
      inject('exampleFeature', feature)
    }

    В классовых компонентах нет разделения контекстов. Все что было вписано в приложение будет доступно по ключевому слову this

    @Component({})
    export default class ExampleClass extends Vue {
      getSomethingFromApp () {
        console.log(this.$exampleFeature.someMethod())
      }
    }

    nuxtjs/composition-api - контекст приложения изначально недоступен в компоненте. Для получения контекста приложения необходимо использовать свойство app из useContext

    import {
      useContext,
      defineComponent
    } from '@nuxtjs/composition-api'
    
    export default defineComponent({
      name: 'ExampleClass',
      
      setup() {
        const { app } = useContext()
        // через деструктуризацию забираем необходимое нам значение из app
        const { $exampleFeature } = app 
    
        const getSomethingFromApp = () => {
          console.log($exampleFeature.someMethod())
        }
      }
    })

  21. Типизация чего-то из приложения

    Чтобы оповестить typescript о том, что в объекте app появилась новая фича, необходимо через declare описать название фичи для NuxtAppOptions

    import { Plugin } from '@nuxt/types'
    
    const plugin: Plugin = (_, inject) => {
      const feature = {
        someMethod: () => { console.log("Very cool feature")}
      }
    
      inject('exampleFeature', feature)
    }
    
    declare module '@nuxt/types' {
      interface NuxtAppOptions {
        // *** тут лучше написать интерфейс для фичи
        $exampleFeature: { someMethod: () => void }
      }
    }
    

Спасибо, что дочитали статью доконца. Надеюсь она поможет вам без проблем зарефакторить свою приложение на новый vue синтаксис.

Делитесь своими находнами вовремя рефакторинга в комментариях)

Источники:
Vue - https://vuejs.org/
vuejs/composition-api - https://github.com/vuejs/composition-api
nuxtjs/compiosition-api - https://github.com/nuxt-community/composition-api