Vue-типізація legacy Vuex Store: вирішення проблеми

Вітаю! Мене звати Микола Коваль, я Front-end Team Lead компанії SocialTech, і це моя коротка історія про те, як ми Vuex типізували. У статті я розповім, як просто й безболісно здружити типи компонентів з Vuex за допомогою кількох рядків коду. Для тих, хто добре дружить з TS — тут результат. А кому цікаво, у чому була проблема, — далі більш детально.

Задача

Наша задача полягала в переведенні старого коду проєкту на TypeScript. Це потрібно було робити ітеративно, поступово, оскільки необхідно доставляти й нові фічі. Якщо з components, helpers і services усе зрозуміло, то зі store на Vuex виникли проблеми.

Для початку треба було додати типи, аби зрозуміти, які дані ми отримуємо та як з ними працювати:

const mutations: MutationTree<ProfileState> = {
    PROFILE_ADD(state, profiles: iProfile[]) {
        // ...
    },
} 

Усе виглядало досить просто, але виникали проблеми. Кожна задача має свою гілку на Git, перед заливкою на прод усі вони зливаються в stage. Багато розробників, багато гілок системи контролю версій, і часто виникає mergehell. Наприклад, поки задача #1 дійде до проду, уже задеплоїться задача #2. А в ній розробник вирішив видалити непотрібні дані зі стора, на які спирався розробник задачі #1. Під час code review цього не помітили. У результаті це виявив мануальний тестувальник під час тестування, оскільки до мерджа фічі в stage витку все працювало. Такі випадки повинна ловити статична типізація під час збірки проєкту на CI-платформі.

Нативний варіант типізації, що надавався типами Vuex, полягав тільки в описі state. Це дозволяло мати статичну перевірку state в mutations та actions, але при цьому не було таких моментів:

  • типи даних, що передаються в mutation з компонента й store;
  • типи даних, що передаються в action з компонента чи іншого action;
  • типи геттерів;
  • типи даних зі store, що використовуються в компоненті.

Шукаючи варіанти вирішення проблеми, я знайшов декілька цікавих рішень, що їх пропонували розробники. Але більшість не підтримувалася, мала мізерну кількість завантажень або взагалі були написані як експеримент. А от що було найбільш production ready solution, то це vuex-smart-module, хоча тоді воно мало 100 завантажень на тиждень у npm. Але нам потрібно було обрати щось просте, що не гальмуватиме роботу команди, не додасть зайвої складності. До того ж вихід Vue 3 з кращою підтримкою TypeScript не за горами, і Vuex має давно спланований roadmap, де пишуть про можливу зміну api. Тому не хотілося б усе переписувати, а потім знову це проходити. До цього всього store містить більшість бізнес-логіки, а ще велику legacy кодову базу, тому чіпати їх зайвий раз не хотілося. Отож, я вирішив хакнути Vuex.

Вирішення

Запропонований нижче спосіб дозволяє вирішити наведені вище проблеми й бути впевненим, що зібраний застосунок працюватиме. Також у написанні синтаксично нічого не змінюється, використовуються стандартні типи Vuex, які не викликають когнітивного навантаження — це той самий Vuex, що й у документації.

Далі наведені кроки для повної типізації викликів commit і dispatch з усіма можливими підказками від TypeScript та їхнім зв’язком з компонентом.

Для початку опишемо файл з типами, це буде єдине джерело правди про store (types/Store.ts):

import { Profile, User } from '@/types'
import { CommonState, ProfileState } from '@/store'

export type State = {
    profile: ProfileState
    common: CommonState
}

export type MutationTypes = {
    COMMON_INIT_USER: User
    COMMON_CLEAR: undefined
    PROFILE_ADD: Profile[]
}

export type ActionTypes = {
    LOGIN: User
    LOGOUT: undefined
}

export interface Getters {
    isAuthenticated: boolean
}

export type ActionTypesResult = {
    LOGIN: void
    LOGOUT: void
}

У файлі shims-tsx.d.ts розширюємо інтерфейс Vue, додаючи свій $store й перевизначаємо нові інтерфейси для commit і dispatch:

import { Store as VuexStore, CommitOptions, DispatchOptions } from 'vuex'
import { Store } from './types/Store'

declare module 'vue/types/vue' {
    type CustomStore = Omit<VuexStore<Store.State>, 'getters' | 'commit' | 'dispatch'>

    interface Vue {
        $store: CustomStore & {
            getters: Store.GettersTypes
            commit<Key extends keyof Store.MutationTypes>(
                type: Key,
                payload: Store.MutationTypes[Key],
                options?: CommitOptions,
            ): void
            dispatch<Key extends keyof Store.ActionTypes>(
                type: Key,
                payload: Store.ActionTypes[Key],
                options?: DispatchOptions,
            ): Promise<Store.ActionTypesResult[Key]>
        }
    }
}

Потім буде доступний автокомпліт і перевірка даних у сторі з компонента, а також помилка, що $store вже задекларовано.

Якщо ви використовуєте об’єктну нотацію, то тут інакший вигляд:

dispatch<Key extends keyof Store.ActionTypes>(
    payloadWithType: { type: Key } & Store.ActionTypes[Key],
    options?: DispatchOptions
): Promise<Store.ActionTypesResult[Key]>

Але в цьому випадку для типу ActionTypes потрібно залишати порожній об’єкт, оскільки змерджити об’єкт з undefined немає можливості.

export type ActionTypes = {
    LOGIN: iUser
    LOGOUT: {}
}

Не можна використовувати обидві нотації — слід обрати одну, оскільки не буде точного визначення типу payload, і типізація працюватиме некоректно.

Щоб заміна інтерфейсу Vue працювала правильно, потрібно видалити з node_modules/vuex/types/vue.d.ts опис $store в інтерфейсі Vue. Просто перевизначити конструкцію $store: Store<any> не можна, оскільки тип any повністю вбиває типізацію.

Також потрібно з node_modules/vuex/types/index.d.ts видалити декларації сommit і dispatch, оскільки в них використовується тип any й нічого типізувати не вдасться. Щоб це реалізувати, можна форкнути Vuex, а можна запустити простий скрипт на postinstall хук.

Після цього потрібно описати нові інтерфейси, оскільки в store action не матимуть сигнатур сommit і dispatch.

//file shims-tsx.d.ts
declare module 'vuex/types/index' {
    interface Commit {
        <Key extends keyof Store.MutationTypes>(
            type: Key,
            payload: Store.MutationTypes[Key],
            options?: CommitOptions,
        ): void
    }

    interface Dispatch {
        <Key extends keyof Store.ActionTypes>(
            type: Key,
            payload: Store.ActionTypes[Key],
            options?: DispatchOptions,
        ): Promise<Store.ActionTypesResult[Key]>
    }
}

Далі в сторі також будуть типізовані commit та dispatch, але щоб це працювало, не можна використовувати деструктуризацію параметрів, як наприклад ({ commit }) (глюк TypeScript). Нижче результат:

Якщо ви хочете лаконічний вигляд і ладні вирішити це більш складним кодом, є такий варіант. При цьому вам не потрібно передавати undefined, коли не потрібно передавати дані під час виклику commit або dispatch.

Що в результаті

Отримуємо зручні виклики store в компоненті з усіма можливими підказками.

У компоненті при виклику commit або dispatch ви бачите доступні виклики й дані, що потрібно передати.

Невирішеними залишилися:

  • результат виклику dispatch потрібно декларувати вручну this.$store.dispatch('LOGIN').then((data: Store.ActionTypesResult['LOGIN']) => {}), вирішили домовленістю, що типи для даних з actions брати зі Store.ActionTypesResult;
  • в actions незадекларовані змінні getters і rootGetters — для цього так само треба декларувати type Getter в декларації, зайвий код, але легко вирішується (довільний приклад, у репозиторії такого не наведено):
const actions: ActionTree<CommonState, Store.State> = {
    FOO(context): Store.ActionTypesResult[FOO] {
        const { rootGetters }: { rootGetters: Store.Getters } = context

        return rootGetters.bar
    },
}
  • не було типізовано Vuex mapState й подібні хелпери в об’єктному стилі компонента, тому їх доведеться замінити автозаміною.

Додали обмежень у тому, що можемо використовувати тільки одну Vuex-нотацію виклику dispatch і commit, і зробили костиль з перебиванням параметрів залежності.

Якщо цей матеріал був вам корисним або залишилися питання, напишіть у коментарях — я обов’язково відповім.

Похожие статьи:
Ссылки, на которые лучше таки нажать (по мнению автора), отмечены знаком (!) Что-то вроде новостей (!) Собственно новость месяца: Pattern Matching...
Ссылки, на которые лучше таки нажать (по мнению автора), отмечены знаком (!) 8-9 апреля в Киеве пройдет конференция ScalaUA. Список...
В этом году в международном хакатоне NASA SpaceApps Challenge 15 310 участников из 61 страны разрабатывали 1178 проектов. Украина приняла...
«Не існує поганої архітектури. Є та, що задовольняє чи не задовольняє вимоги клієнта». Ця фраза з книги Software Architecture...
Google анонсує запуск нового рішення для входу в облікові записи — використання ключів доступу. Це ще один крок...
Яндекс.Метрика