Здравствуйте! В данной статье я бы хотел поделиться своим опытом обновления проекта, написанного на 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 хуках роутера, которые не могут быть асинхронными, соответственно там загружать данные не получится.
Я выкрутился из этого следующим образом: до создания основного инстанса Vue с корневым компонентом приложения создал пустой инстанс с одной лишь pinia.
// Всё это внутри асинхронной функции
const pinia = createPinia()
// Фиктивный инстанс vue для инициализации хранилища
const piniaLoadApp = new Vue({ pinia })
await useUserStore().fetchCurrentUser()
new Vue({
pinia,
router,
i18n,
render: (h) => h(App)
}).$mount('#app')
piniaLoadApp.$destroy()
Соответственно после этого мы получим доступ ко всем сторам.
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>
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.
Чуть больше про сам процесс миграции можете прочитать в этой статье.
Автор: Влад