В статье хочу поделиться опытом переписывания существующих классовых компонентов 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Помимо основных примеров я покажу как будет работать типизация в тех или иных кейсах.
* Все названия переменных вымышлены и не используются на продуктиве)
Поехали!
-
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или к ним будут обращаться из родительских компонентов. -
Типизация state
* Типизируем объектobjectvalue
Вклассовых компонентахстейт компонента типизируется внутри класса.interface IStateObject { name: string, value: number } @Component({}) export default class ExampleClass extends Vue { objectvalue: IStateObject = { name: 'example', value: 2 } }nuxtjs/composition-apiimport { 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 } -
Пропсы компонента
* В примерах пропсом будет являться значение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их нужно превратить в стейт компонента. Для этого используется методtoRefsimport { 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. -
Типизация пропсов
* Типизируем объект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 -
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- вычисляемые свойства создаются с помощью методаcomputedimport { 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 -
Типизация 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-apiimport { 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 } } }) -
Сеттер для вычисляемого свойства
* В примерах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-apiimport { 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пока не думаем. Его разберем далее по статье -
Методы
Тут все достаточно банально.
* В примерахsayHelloявляется методом.Классовые компоненты- методы это методы класса.@Component({}) export default class ExampleClass extends Vue { sayHello () { console.log("hello world") } }nuxtjs/composition-apiimport { defineComponent } from '@nuxtjs/composition-api' export default defineComponent({ name: 'ExampleClass', setup() { const sayHello = () => { console.log("hello world") } return { sayHello } } }) -
Хуки жизненного цикла
Вcomposition-apiсписок хуков жизненного цикла обновился. ХуковbeforeCreatedиcreatedтеперь нет, они имплементированы в setup
* ХукcreatedКлассовые компоненты@Component({}) export default class ExampleClass extends Vue { created () { console.log("created") } }nuxtjs/composition-apiimport { 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-apiimport { onMounted, defineComponent } from '@nuxtjs/composition-api' export default defineComponent({ name: 'ExampleClass', setup() { onMounted(() => { console.log("mounted") }) } })Тут может возникнуть недопоминае касательно того где же теперь делать асинхронные запросы. Во vue документации говорится о
Suspenseкомпонентах - это компоненты, которые имеютasync setup
Не будем останавливаться на этом моменте сейчас. Просто оставлю ссылку на соотвествующую документацию. -
Отписка от нативных событий 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-apiimport { ref, onMounted, onBeforeUnmount, defineComponent } from '@nuxtjs/composition-api' export default defineComponent({ name: 'ExampleClass', setup() { const timeoutId = ref<ReturnType<typeof setTimeout> | null>(null) onMounted(() => { timeoutId.value = setTimeout(() => { this.isVisible = true }, 300) }) onBeforeUnmount(() => { clearTimeout(timeoutId.value) }) } })Решение не такое изящное, так как приходится выносить локальную переменную в общую кучу.
-
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 -
Emit событий
* Эмитируем событиеinput
Вклассовых компонентах$emit доступен внутри комнтекста класса компонента@Component({}) export default class ExampleClass extends Vue { notifyOthers () { this.$emit('input', 'new Value') } }nuxtjs/composition-api-emitявляется свойством объекта, который передается вторым параметром в setupimport { defineComponent } from '@nuxtjs/composition-api' export default defineComponent({ name: 'ExampleClass', setup(_props, { emit }) { const notifyOthers = () => { emit('input', 'new Value') } } }) -
Контекст
* Значением из контекстаstoreВ
классовых компонентахвсе что лежит в контексте доступно по ключевому словуthis@Component({}) export default class ExampleClass extends Vue { get somethingFromStore () { return this.$store.state.app.value } }nuxtjs/composition-api- для доступа к значением контекст необходимо использовать методuseContextimport { 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 } } }) -
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, то есть часть состояния компонента. Чтобы свойство компонента связалось с компонентом необходимо ее вернуть изsetupimport { 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 -
Типизация ref компонентов
* Типизируем свойство childComponent из примера 14Для
классовых компонентовничего не поменяется еслиchildComponentостается классовым компонентом. Если жеchildComponentуже переписан под новый синтаксис, то для него необходимо интерфейс, описывающий какие свойства и методы возвращаются изsetupуchildComponent
* Интерфейс дляchildComponentлучше хранить в самом компонентеchildComponentExampleClass
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- тут такая же история как и для классовых компонентов. Если компонент, на который есть ссылка является классовым, то ничего не меняем. Если же компонент уже переписан, то компонент необходимо описать в интерфейсе. -
Рекомендации по сохранению ссылки на самого себя
В некоторых случаях нам нужно обратиться к родительскому блоку компонента
В
классовых компонентахссылка на главный блок компонента хранится вthis.$el@Component({}) export default class ExampleClass extends Vue { findChildElements () { // находим все span элементы внутри данного компонента console.log(this.$el.querySelector('span')) } }nuxtjs/composition-api- Тут есть несколько вариантов как обратиться к текущему элементу. Рассмотрим вариант сrefimport { 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и это будет вторым способом обращения к блоку текущего элемента -
CurrentInstance - обратиться к контексту текущего компонента
Для
классовых компонентовконтекст всегда доступен по ключевому словуthis, поэтому все что будет далее описано дляcomposition-apiможно смело получить черезthis.nuxtjs/composition-api- будем использовать методgetCurrentInstanceimport { 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 -
Задачка: Нужно вручную создать и сохранить инстанс компонента через код
Когда переписывала эту часть приложения пришлось пошевелить мозгами и порыть доки.
Такой функционал нам нужен был, чтобы для карты создавать попап и передавать код созданного попапа карте, чтобы уже карта установила его в необходимое ей место.Классовые компоненты@Component({}) export default class ExampleClass extends Vue { createComponentFromCode (el) { // в el приходит ссылка на блок, куда будет смонтирован компонент const examplePopup = new ExamplePoopup({ parent: this }).$mount(el) } }nuxtjs/composition-api- тут мы прибегнем к некоторым хакам работы vue +getCurrentInstanceimport { 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) } } }) -
Inject/Provide
* В примерах будет передаваться и инджектиться объектexampleInjectВ
классовых компонентахprovide/inject описывается в декораторе@ComponentExampleProvideClass
@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/injectExampleProvideClass
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) } }) -
Что-то из приложения
* В примерах значениеexampleFeatureбудет браться из контекста приложенияМы не все и всегда записываем в контекст, что-то просто инджектится в приложение.
exampleFeatureimport { 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изuseContextimport { 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()) } } }) -
Типизация чего-то из приложения
Чтобы оповестить
typescriptо том, что в объекте app появилась новая фича, необходимо черезdeclareописать название фичи дляNuxtAppOptionsimport { 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
Комментарии (14)

js_n00b
24.10.2022 09:48Уточнение по поводу пропсов:
toRefsнужен для того, чтобы сделать пропсы реактивными, так как приconst { exampleProps } = propsреактивность потеряется. В любом случае вsetupони доступны в объектеprops. Напримерprops.exampleProps

doomguy49
24.10.2022 12:55nuxtjs/composition-api- примеры кода буду показывать с использованием данной библиотеки. В базе она использует тот жеvuejs/composition-apiи добавляет ряд своих методов для интеграции с nuxt.@JuliWolf
Последняя версияnuxtjs/composition-apiтянет напрямую из Vue. Обновитеnuxtjs/composition-apiдо последней версии и Vue до 2.7, б0льшая часть импортов уйдет, бандл станет весить меньше.setup(props) { ... }
Возвращать пропсы из
setupне нужно. Они и так будут доступны в template.Возвращать из setup ничего не нужно, если использовать синтаксис <script setup lang="ts"></script>. Исключением являются composables. Почему не используете?
setTimeout
Можно использовать useTimeoutFn() из https://vueuse.org/shared/useTimeoutFn/, тогда тянуть хуки из Vue и clearTimeout делать не нужно.
Pinia, Render functions, VueUse не используете на проекте?
JuliWolf Автор
24.10.2022 18:03с учетом того что мы работаем в рамках nuxt2, который за собой тянет такие фишки на asyncData и fetch использовать setup не представляется возможным, но в целом да, если писать именно <script setup lang="ts"></script> то возвращать ничего не нужно.
vueuse это отдельная библиотека, и мы ее не используем. В целом стараемся как можно меньше библиотек тянуть если нет большой необходимости.
На pinia будем переходить не все сразу, в основном проекте пока vuex
Мы один из проектов изначально делали на vite +vue3 + pinia, когда пробовали что такое vue3 вообще. Получилось очень симпатично
doomguy49
24.10.2022 21:09+2с учетом того что мы работаем в рамках nuxt2, который за собой тянет такие фишки на asyncData и fetch использовать setup не представляется возможным
А как это связано между собой? @nuxt/composition-apiпредоставляет useFetch() и useAsync(), да и тэги можно совмещать
<script lang="ts">legacy</script> <script setup lang="ts">modern</script>Я делал то же самое - мигрировал с Class Api Nuxt2 на Composition Api, и это не мешало мне использовать script setup синтаксис - наоборот, сильно помогло
vueuse это отдельная библиотека, и мы ее не используем. В целом стараемся как можно меньше библиотек тянуть если нет большой необходимости.
Всякий мусор - понятно, но эта можно сказать мастхэв для современных проектов на Vue, она поддерживает тришейкинг и упростит миграцию на Nuxt3, я потому и написал что заметил много лишнего кода, где можно было обойтись одной функцией

JuliWolf Автор
24.10.2022 22:55Я делал то же самое - мигрировал с Class Api Nuxt2 на Composition Api, и это не мешало мне использовать script setup синтаксис - наоборот, сильно помогло
А у вас заработали вызовы useAsync useFetch как они должны? У меня с этим были проблемы, замечала что ничто из этого на стороне сервера будето не вызывается
Касательно библиотеки, у меня есть мысля ее посмотреть на предмет реализации некооторых фич и скорее всего какие-то идеи оттуда забрать, но так чтобы прям всю забирать пока наврятли, но в целом на будущее учту)
doomguy49
25.10.2022 10:52А у вас заработали вызовы useAsync useFetch как они должны? У меня с этим были проблемы, замечала что ничто из этого на стороне сервера будето не вызывается
Были проблемы только с этим, я думаю об этом и речь
❌ Top-level
awaitin<script setup>(Vue 2 does not support async component initialization)Решается комбинированными тэгами script с fetch/asyncData и script setup с остальной логикой. Я бы также предпочел запросы вынести во vuex/pinia, а в компонентах только вызовы дергать. И, кстати, vue meta тоже должен использовать обычный скрипт или можно взять useHead() из VueUse, в nuxt3 используется именно он, к слову о библиотеках (https://v3.nuxtjs.org/getting-started/seo-meta/). Резюмируя - ничто из этого реально не может мешать использованию сетапа
но так чтобы прям всю забирать
Я этого и не предлагал, либа тришейкается - тянутся только те функции, которые используете
beatleboy
Как по мне vue 2 с vue-class-component как-то по изящнее выглядит.
JuliWolf Автор
Тут с одной стороны согласна. Выглядит достаточно приятно, но vue3 уже совсем в другую сторону уходит, поэтому что предлагают тем и пользуемся)
UksusoFF
Так с vue 3 vue-class-component тоже можно завести. Но новый проект уже без него начал делать.
Не рассматривали вариант поконтрибьютить и допилить vue-class-component?