Здравствуйте! В данной статье я бы хотел поделиться своим опытом обновления проекта, написанного на 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 хуках роутера, которые не могут быть асинхронными, соответственно там загружать данные не получится.

Я выкрутился из этого следующим образом: до создания основного инстанса 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.

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

Комментарии (3)


  1. V_asily
    28.05.2023 12:13

    Порядка 4-5 лет назад делался выбор для одного толстого проекта , vue или react. Был выбран Vue изза более низкого уровня вхождения. И это было огромной ошибкой. Постоянные проблемы с зависимостями после каких либо обновлений, то тестирование перстает работать, то еще что то перестает делать что нужно. В итоге когда вышел Vue 3 оказалось что проект вообще невозможно проапгрейдить на него, проще переписать. Причем на кажждом таком этапе оказывалось, что если бы это было на react-е, то проблем вообще не было бы. В итоге, рано или поздно, но этот толстый проект все равно придется переписать и переписать уже на react-e.


    1. DarkoEN
      28.05.2023 12:13
      +2

      Больше похоже на проблему проекта, а не vue


  1. FluffyArt
    28.05.2023 12:13
    +1

    Vue 2.7 слегка упрощает переход на vue3, но по факту если у вас есть компоненты "низкого уровня" при разработке ui компонентов, то столкнуться можно с очень большим количеством различий. То есть с 2.7 на 3.3 все равно надо будет переписывать частями, но хотя бы новый функционал уже можно сразу писать на композишенах, но и все равно надо держать в уме, что это далеко не 3.3