Здравствуйте! В данной статье я бы хотел поделиться своим опытом обновления проекта, написанного на Vue 2.6. Помимо обновления самого vue и компонентов, я на примерах покажу как мне удалось обновить другие зависимости проекта и адаптировать их для работы с Composition API, среди них: Vuex, BootstrapVue, AgGrid и VueFormGenerator.
История Composition API во Vue
React
Как ни странно, но данному нововведению мы обязаны React-у, а точнее представленной в 2018 году концепции react-хуков.

Я никогда не трогал React, лишь бегло прочитал его документацию, поэтому не могу объективно высказаться об этой возможности библиотеки, однако почти все React-разработчики заявляют что хуки позволяют в значительной мере упростить разработку и создавать переиспользуемый код; более того функциональные компоненты с использованием хуков это уже стандарт разработки на React.
Vue 3
С выходом vue 3-ей версии, разработчикам стал доступен новый подход к созданию компонентов, схожий с функциональными компонентами реакта - Composition API: метод setup и <script setup> для использования в однофайловых компонентах.
Сравнивая composition API с options API, в качестве его преимуществ обычно перечисляют:
простота и лаконичность
возможность создавать переиспользуемые куски логики (вместо миксинов)
улучшенная поддержка TypeScrit
большая производительность (по заявлениям создателей)
Стоит также учесть и критические оценки такого подхода:
Источники
В целом можно заметить что все недостатки или опасения по поводу использования composition API упираются в опыт и знания разработчика: composition API предоставляет гораздо больше инструментов для работы с реактивностью, что естественно требует некоторых усилий для понимания и осторожности, особенно если разработчик привык к options или class API.
Vue 2.7
В июле 2022 года вышел релиз vue 2.7, в котором composition API было добавлено из коробки (ранее для этого требовалась библиотека @vue/composition-api), и добавлена возможность использовать <script setup>.
И несмотря на то, что vue 2.6 уже официально не поддерживается, а поддержка vue 2.7 прекратится в декабре 2023 года, библиотеки на vue 2, судя по данным с npm до сих пор очень часто скачиваются и используются.


Из этого следует чт�� миграция на vue 3 прошла не совсем безболезненно, а некоторые библиотеки (например bootstrap-vue) до сих пор не портированы на vue 3. Также стоит учесть что vue 3 использует систему реактивности основанную на Proxy, которые не поддерживаются старыми браузерами.
Поэтому vue 2.7, на мой взгляд, это относительно безболезненный способ использовать основную фичу vue 3 - Composition API, в своих приложениях, не переписывая при этом абсолютно весь код и не переходя на другие библиотеки.
Мотивация
В нашей компании не раз поднимался вопрос о переходе на vue 3, однако основная библиотека для наших интерфейсов - bootstrap-vue всё ещё стабильно существует только для vue 2-ой версии.
BootstrapVue: @vue/compat
С версии 2.23.0 в bootstrap-vue доступен так называемый билд миграции. Я попробовал запустить пример проекта на bootstrap-vue и @vue/compat от самих разработчиков, и получил целый список предупреждений от vue.

Это одна из причин почему я не стал использовать bootstrap-vue в compat режиме, другая же причина: migration build нужен всё же для миграции проекта, то есть постепенной замены его модулей и переписывания на vue 3, но не для разработки новых продуктов на нём.
Спустя пол года полноценного релиза bootstrap-vue для vue 3 так и нет, а разрабатывать и поддерживать проекты надо.
Class API
Для поддержки typescript во vue 2 команда разработчиков vue создала библиотеку vue-class-component. А с использованием библиотеки vue-property-decorator, можно в декораторном стиле объявлять пропсы, рефы и эмиты и типизировать их.
Однако у Class API есть ряд существенных недостатков, о которых писал сам Эван Ю:
Он не достигает своей основной цели (лучшая поддержка TypeScript)
Усложняет внутреннюю реализацию
Не приносит улучшений в логическую композицию
От себя ещё добавлю: компоненты - это не классы. Это довольно субъективно, но мне кажется что мы обманываем себя когда называем компонент классом, потому что большин��тво правил и паттерннов ООП просто не получится применить к ним.
Типизация
Также для того чтобы добиться типизации приходится писать очень много лишнего кода, вот пример хранилища с использованием VuexSmartModule.
toasts_store.ts
// ... class ToastState { count = 0 toasts: Toast[] = [] } class ToastGetters extends Getters<ToastState> { get toasts() { return this.state.toasts } get count() { return this.state.count } } class ToastMutations extends Mutations<ToastState> { pushToast(toast: Toast) { this.state.toasts.push(toast) } spliceToast(id: number) { this.state.toasts = this.state.toasts.filter(p => p.id !== id) } incCount() { this.state.count++ } } class ToastActions extends Actions< ToastState, ToastGetters, ToastMutations, ToastActions > { async pushToast(toast: Toast) { toast.id = this.state.count this.mutations.incCount() this.mutations.pushToast(toast) } async delToast(id: number) { this.mutations.spliceToast(id) } } export const toast = new Module({ state: ToastState, getters: ToastGetters, mutations: ToastMutations, actions: ToastActions }) export const toastMapper = createMapper(toast)
Страшновато не правда ли? Чуть дальше покажу как это выглядит на pinia.
Сложность реализации
Во vue-class-component есть ещё одна ловушка, this на этапе создания класса это не тот же this, что и в компоненте, всё потому что наш класс сначала из класса преобразуется в объект понятный для vue и только после этого создаётся компонент, можете проверить это следующим образом:
<script lang="ts"> import { Vue, Component } from 'vue-property-decorator' @Component({}) export default class Test extends Vue { self: Test | null = null constructor() { super() this.self = this console.log('constructor:', this.self == this) } created() { console.log('created:', this == this.self) } } </script>
Получите следующую картину:constructor: true created: false
Это относится и к функциям в объектах, объявленным в классе, вы просто не имеете доступ к актуальному состоянию объекта (this в данном случае всё ещё тот объект полученный из класса).
// ... obj = { f: () => { // this != контекст компонента } } // ...
Поэтому в таких случаях приходилось создавать отдельный метод в классе и передавать уже его, даже если требовалось просто изменить какое-то одно поле в объекте, или что-то сэмитить.
// ... private schema: FormSchema<SelectData> = { fields: [ { // ... onChanged: this.onSelected }, { // ... onChanged: this.onSelected } ] } // ... private async onSelected() { // Какая-то логика } // ...
Однако до vue 2.7 внутри объектов можно было обращаться к пропсам и сторам (если смаппить их в methods).
<script lang="ts"> import { Vue, Component, Prop } from 'vue-property-decorator' const Mappers = Vue.extend({ methods: { ...mobileWidthMapper.mapGetters(['isMobile']) } }) @Component({}) export default class Test extends Mappers { @Prop({ required: true }) a!: number @Prop({ required: true }) b!: string obj = { a: this.a, b: this.b c: this.isMobile() } created() { console.log(this.obj) // получим корректные значения } } </script>
Логическая композиция
До появления composition API единственным способом создать переиспользуемую логику компонентов были миксины, у них есть существенные недостатки:
Пересечение пространств имён
Сложность типизации
Непрозрачность требований (например если миксин требует наличия определённых пропсов или методов у компонента)
Сложность понимания и отладки: когда вы обращаетесь ко всем методам и объектам через this, может быть затруднительно понять откуда он взялся: из самого объекта, из стора или из какого-то миксина.
Только в одном случае миксины могут дать некоторое преимущество: если требуется создать переиспользуемые пропсы/эмиты, вот тут решение с composable будет слегка сложнее, далее я покажу эту проблему на примере библиотеки vue-form-generator.
Шаги для миграции
Обновление vue
В первую очередь я обновил сам vue до версии 2.7, и сразу у меня перестала работать часть компонентов, в которых использовались пропсы при объявлении внутренних объектов, как в примере выше. Я решил сразу переписать такие компоненты на composition API, но можно и попробовать забирать нужные пропсы в хуках жизненного цикла.
Vuex / pinia
На очереди хранилище, в целом тут не возникло особых сложностей, я подключил pinia и постепенно переписал все модули vuex. Если вы тоже использовали VuexSmartModule и не хотите переписывать все компоненты, то pinia сторы тоже можно смаппить следующим образом:
const Mappers = Vue.extend({ computed: { // Либо mapState/mapActions из pinia ...mapStores(useUserStore /*, и другие*/) } }) @Component export default class SomeComponent extends Mappers { // ... }
Для сравнения покажу вышеописанный стор для тостов, теперь уже на pinia.
toasts_store.ts
... export const useToastStore = defineStore('toast', () => { const count = ref(0), toasts = ref<ToastInner[]>([]) function pushToast(toast: Toast) { toasts.value.push({ ...toast, id: count.value++ }) } function delToast(id: number) { toasts.value = toasts.value.filter((p) => p.id !== id) } return { count, toasts, pushToast, delToast } })
Единственной проблемой стала необходимость доступа к сторам в роутере, а точнее в нашем проекте для запуска приложения использовался следующий кусок кода:
store.dispatch('user/fetchCurrentUser').then(() => { new Vue({ router, store, // прочие плагины render: h => h(App) }).$mount('#app') })
То есть до начала работы приложения требовалось получить данные пользователя для использования их в роутере.
Возможно правильнее было бы вынести загрузку пользователя в middleware роутера, но оказалось что у нас много где данные хранилищ используются ещё и в redirect хуках роутера, которые не могут быть асинхронными, соответственно там загружать данные не получится.
В функцию подключения хранилищ можно опционально передать инстанс сам��й pinia, как раз для таких случаев:
// Всё это внутри асинхронной функции const pinia = createPinia() await useUserStore(pinia).fetchCurrentUser() new Vue({ pinia, router, i18n, render: (h) => h(App) }).$mount('#app')
Соответственно после этого мы получим доступ ко всем сторам.
BootstrapVue
В целом работа с данной библиотекой не изменилась, а чтобы получить доступ к объектам $bvToast и $bvModal в <script setup> я создал простые composable
// ... export function useBVModal() { return getCurrentInstance()?.proxy.$bvModal } export function useBVToast() { return getCurrentInstance()?.proxy.$bvToast }
AgGrid
AgGrid это очень мощная и одна из самых популярных библиотек для работы с таблицами, в наших проектах она широко используется. После её обновления я столкнулся с некоторыми deprecated ворнингами. Однако, если следовать тому что в них написано, то можно всё корректно обновить без особых изменений, в основном это просто другие названия для полей в объектах настроек.
Но я обнаружил одну проблему, скорее всего баг самой библиотеки (на момент написания статьи она все ещё не устранена, но issue я уже отправил). Если вы в column-defs объявляете колонку с собственным рендерером (компонентом), например так:
import CustomRenderer from '.../CustomRenderer.vue' // ... const colDefs: ColDef[] = [ // ... { // ... cellRenderer: CustomRenderer, } // ... ]
Скорее всего у вас будет что-то подобное

При этом забавно, но если написать вместо cellRenderer - cellRendererFramework, то вы получите deprecated ворнинг от библиотеки, но всё будет работать.
Можно было бы попробовать передать в cellRenderer строку вместо компонента, но это тоже не получится, так как в <scirpt setup> импортированные компоненты не регистрируются. Для этого я создал ещё один <script>, где уже в options API зарегистрировал нужный компонент.
<script lang="ts"> import CustomRenderer from '.../CustomRenderer.vue' import { defineComponent } from 'vue' export default defineComponent({ // eslint-disable-next-line vue/no-unused-components components: { CustomRenderer } }) </script> <script setup lang="ts"> // ... const colDefs: ColDef[] = [ // ... { // ... cellRenderer: 'CustomRenderer', } // ... ] </script>
Также зарегистрировать компонент можно в более императивном стиле, добавив нужный компонент по ключу-имени к соответствующему полю инстанса текущего компонента
getCurrentInstance()?.proxy.$options?.components
Это можно обернуть в некоторый composable который принимает объект компонента и возвращает его зарегистрированное имя:
Hidden text
export function useRegistration( c: DefineComponent | VueConstructor ): string | undefined { const components = getCurrentInstance()?.proxy.$options?.components if (components) { const name = c.name ?? (c as any).__name components[name] = c return name } }
VueFormGenerator
VueFormGenerator - небольшая библиотека для генерации форм с помощью JSON схем, она больше не поддерживается разработчиками и в целом мне не очень нравится, но выкинуть из проектов её пока что не получилось, так как на ней у нас сделаны все формы.
Для её адаптации к composition API пришлось сделать громоздкий composable на основе их миксина abstractField, а для переиспользования пропсов и эмитов я сделал объекты, передаваемые в defineProps и defineEmits. Кода там много, поэтому просто оставлю ссылку на gist.
Простейшее кастомное поле (обычный лэйбл) с использованием данного composable:
<template> <span> {{ value }} </span> </template> <script setup lang="ts"> import { useField } from ".../use-field"; import { FieldPropsObject, FieldEmitsObject, FieldExpose } from ".../types"; const props = defineProps(FieldPropsObject); const emit = defineEmits(FieldEmitsObject); const { clearValidationErrors, validate, value } = useField( props, emit ); defineExpose<FieldExpose>({ validate, clearValidationErrors }); </script>
Чего мы добились
Несмотря на все сложности, которые пришлось преодолеть, на данный момент моё мнение однозначно: оно того стоило, мы получили гораздо более компактный, понятный, более поддерживаемый код, при этом не потеряв типизацию.
Особым приятным бонусом стала возможность использовать библиотеку VueUse, с помощью которой например получилось заменить стор для размера окна и миксин для отслеживания изменения его размера, всего одной функцией useWindowSize.
Чуть больше про сам процесс миграции можете прочитать в этой статье.
