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, і зробили костиль з перебиванням параметрів залежності.

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

Похожие статьи:
New donuts launched in two of the major fast-food chains in 2019. With the fierce competition among fast-food chains, it’s imperative for their management to introduce new products to encourage more customers into their restaurants....
У САП і НАБУ повідомили, що викрили корупційну схему у сфері оборони, через яку держава втратила понад 246 млн грн. Ідеться про...
Команда українських розробників виборола перемогу під час міжнародного хакатону NASA Space Apps Challenge 2024. Про це повідомив Роман...
Ранее в Интернете уже появлялись слухи о готовящихся смарт-часах HTC One, а теперь стало известно о том, когда они будут...
Меня зовут Александр Винницкий, я СТО и co-founder компаний Svitsoft и AdSaver. Этот материал будет полезен продуктовым...
Яндекс.Метрика