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 витку все працювало. Такі випадки повинна ловити статична типізація під час збірки проєкту на
Нативний варіант типізації, що надавався типами 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, і зробили костиль з перебиванням параметрів залежності.
Якщо цей матеріал був вам корисним або залишилися питання, напишіть у коментарях — я обов’язково відповім.