В статье хочу поделиться опытом переписывания существующих классовых компонентов 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-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 }
-
Пропсы компонента
* В примерах пропсом будет являться значение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. -
Типизация пропсов
* Типизируем объект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
- вычисляемые свойства создаются с помощью метода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
-
Типизация 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 } } })
-
Сеттер для вычисляемого свойства
* В примерах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
пока не думаем. Его разберем далее по статье -
Методы
Тут все достаточно банально.
* В примерах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 } } })
-
Хуки жизненного цикла
В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
Не будем останавливаться на этом моменте сейчас. Просто оставлю ссылку на соотвествующую документацию. -
Отписка от нативных событий 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() { 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
- для доступа к значением контекст необходимо использовать метод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 } } })
-
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
-
Типизация 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
- тут такая же история как и для классовых компонентов. Если компонент, на который есть ссылка является классовым, то ничего не меняем. Если же компонент уже переписан, то компонент необходимо описать в интерфейсе. -
Рекомендации по сохранению ссылки на самого себя
В некоторых случаях нам нужно обратиться к родительскому блоку компонента
В
классовых компонентах
ссылка на главный блок компонента хранится в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
и это будет вторым способом обращения к блоку текущего элемента -
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
-
Задачка: Нужно вручную создать и сохранить инстанс компонента через код
Когда переписывала эту часть приложения пришлось пошевелить мозгами и порыть доки.
Такой функционал нам нужен был, чтобы для карты создавать попап и передавать код созданного попапа карте, чтобы уже карта установила его в необходимое ей место.Классовые компоненты
@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) } } })
-
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) } })
-
Что-то из приложения
* В примерах значение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
из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()) } } })
-
Типизация чего-то из приложения
Чтобы оповестить
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
Комментарии (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
await
in<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?