Миграция на Vue 2.7

от автора

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

История Composition API во Vue

React

Как ни странно, но данному нововведению мы обязаны React-у, а точнее представленной в 2018 году концепции react-хуков.

Создатель vue, Эван Ю о реакт хуках

Создатель vue, Эван Ю о реакт хуках

Я никогда не трогал 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-class-component

bootstrap-vue

Из этого следует что миграция на 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 есть ряд существенных недостатков, о которых писал сам Эван Ю:

  1. Он не достигает своей основной цели (лучшая поддержка TypeScript)

  2. Усложняет внутреннюю реализацию

  3. Не приносит улучшений в логическую композицию

От себя ещё добавлю: компоненты — это не классы. Это довольно субъективно, но мне кажется что мы обманываем себя когда называем компонент классом, потому что большинство правил и паттерннов ООП просто не получится применить к ним.

Типизация

Также для того чтобы добиться типизации приходится писать очень много лишнего кода, вот пример хранилища с использованием 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,   } // ... ]

Скорее всего у вас будет что-то подобное

При этом забавно, но если написать вместо cellRenderercellRendererFramework, то вы получите 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.

Чуть больше про сам процесс миграции можете прочитать в этой статье.


ссылка на оригинал статьи https://habr.com/ru/articles/738032/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *