Vuex и TypeScript
В этой статье мы поделимся своим опытом расширения типов store. Мы продемонстрируем это на примере простого store.
State
Определение хранилища начинается с определения состояния.
state.ts
:
export const state = { counter: 0, } export type State = typeof state
Нам нужно экспортировать тип состояния, потому что он будет использоваться в определениях геттеров, мутаций и действий. Перейдем к мутациям.
Мутации
Как указано в документации Vuex:
Это распространенный шаблон использования констант для типов мутаций в различных реализациях Flux.
Итак, все возможные названия мутаций будут храниться в MutationTypes
перечислении.
mutation-types.ts
:
export enum MutationTypes { SET_COUNTER = 'SET_COUNTER', }
Теперь, когда мы определили имена мутаций, мы можем объявить контракт для каждой мутации (ее фактический тип). Мутация — это простая функция, которая принимает состояние в качестве первого аргумента и полезную нагрузку в качестве второго и в конечном итоге изменяет первое.
State
type приходит в действие, он используется как тип первого аргумента. Второй аргумент специфичен для конкретной мутации. Мы уже знаем, что у нас есть SET_COUNTER
мутация, поэтому давайте объявим для нее типы.
mutations.ts
:
import { MutationTypes } from './mutation-types' import { State } from './state' export type Mutations<S = State> = { [MutationTypes.SET_COUNTER](state: S, payload: number): void }
Пришло время реализовать это.
import { MutationTree } from 'vuex' import { MutationTypes } from './mutation-types' import { State } from './state' export type Mutations<S = State> = { [MutationTypes.SET_COUNTER](state: S, payload: number): void } export const mutations: MutationTree<State> & Mutations = { [MutationTypes.SET_COUNTER](state, payload: number) { state.counter = payload }, }
Переменная mutations
отвечает за хранение всех реализованных мутаций и в конечном итоге будет использоваться для создания хранилища.
MutationTree<State> & Mutations
пересечение типов гарантирует корректную реализацию контракта. Если это не так, TypeScript вернет следующую ошибку:
Type '{ SET_COUNTER(state: { counter: number; }, payload: number): void; }' is not assignable to type 'MutationTree<{ counter: number; }> & Mutations<{ counter: number; }>'. Property '[MutationTypes.RESET_COUNTER]' is missing in type '{ SET_COUNTER(state: { counter: number; }, payload: number): void; }' but required in type 'Mutations<{ counter: number; }>'
Несколько слов о MutationTree
типе. MutationTree
— это универсальный тип, который поставляется вместе с vuex
пакетом. Из названия понятно, что он помогает объявить тип дерева мутаций.
vuex/types/index.d.ts
:
export interface MutationTree<S> { [key: string]: Mutation<S>; }
Но он недостаточно специфичен для наших нужд, поскольку предполагает, что имя мутации может быть любым string
, но в нашем случае мы знаем, что имя мутации может быть только typeof MutationTypes
. Мы оставили этот тип только для совместимости с Store
опциями.
Actions
Для такого простого хранилища нет необходимости в действиях, но чтобы проиллюстрировать ввод действий, давайте представим, что мы можем откуда-то получить счетчик.
Точно так же, как мы храним имена мутаций, мы храним имена действий.
action-types.ts
:
export enum ActionTypes { GET_COUTNER = 'GET_COUTNER', }
actions.ts
:
import { ActionTypes } from './action-types' export const actions = { [ActionTypes.GET_COUTNER]({ commit }) { return new Promise((resolve) => { setTimeout(() => { const data = 256 commit(MutationTypes.SET_COUNTER, data) resolve(data) }, 500) }) }, }
У нас есть простое GET_COUNTER
действие, которое возвращает Promise
, которое разрешается за 500 мс. Он фиксирует ранее определенную мутацию ( SET_COUNTER
). Кажется, все в порядке, но commit
позволяет совершать любые мутации, что неприемлемо, поскольку мы знаем, что можем совершать только определенные мутации. Давайте исправим это.
import { ActionTree, ActionContext } from 'vuex' import { State } from './state' import { Mutations } from './mutations' import { ActionTypes } from './action-types' import { MutationTypes } from './mutation-types' type AugmentedActionContext = { commit<K extends keyof Mutations>( key: K, payload: Parameters<Mutations[K]>[1] ): ReturnType<Mutations[K]> } & Omit<ActionContext<State, State>, 'commit'> export interface Actions { [ActionTypes.GET_COUTNER]( { commit }: AugmentedActionContext, payload: number ): Promise<number> } export const actions: ActionTree<State, State> & Actions = { [ActionTypes.GET_COUTNER]({ commit }) { return new Promise((resolve) => { setTimeout(() => { const data = 256 commit(MutationTypes.SET_COUNTER, data) resolve(data) }, 500) }) }, }
Точно так же, как мы объявляем контракт на мутации, мы объявляем контракт на действия ( Actions
). Мы также должны расширить ActionContext
тип, поставляемый с vuex
пакетом, поскольку он предполагает, что мы можем совершить любую мутацию. AugmentedActionContext
выполнить задание, ограничивает фиксацию только объявленных мутаций (он также проверяет тип полезной нагрузки).
Набранные commit
внутри действия:
Неправильно реализованное действие:
Геттеры
Геттеры также могут быть статически типизированы. Геттер похож на мутацию и, по сути, представляет собой функцию, которая получает состояние в качестве первого аргумента. Объявление геттеров мало чем отличается от объявления мутаций.
getters.ts
:
import { GetterTree } from 'vuex' import { State } from './state' export type Getters = { doubledCounter(state: State): number } export const getters: GetterTree<State, State> & Getters = { doubledCounter: (state) => { return state.counter * 2 }, }
Глобальный $store
тип
Основные модули хранилища определены, и теперь мы можем его построить. Процесс создания хранилища в Vuex@v4.0.0-beta.1
немного отличается от Vuex@3.x
. Тип Store
должен быть объявлен для безопасного доступа к определенному хранилищу в компонентах.
Обратите внимание, что типы Vuex по умолчанию: getters
и commit
следует dispatch
заменить типами, которые мы определили ранее. Причина этой замены заключается в том, что типы хранилищ Vuex по умолчанию слишком общие. Просто посмотрите на типы геттеров по умолчанию:
export declare class Store<S> { // ... readonly getters: any; // ... }
Вне всякого сомнения, эти типы не подходят в том случае, если вы хотите безопасно работать с типизированным магазином.
store.ts
:
import { createStore, Store as VuexStore, CommitOptions, DispatchOptions, } from 'vuex' import { State, state } from './state' import { Getters, getters } from './getters' import { Mutations, mutations } from './mutations' import { Actions, actions } from './actions' export const store = createStore({ state, getters, mutations, actions, }) export type Store = Omit< VuexStore<State>, 'getters' | 'commit' | 'dispatch' > & { commit<K extends keyof Mutations, P extends Parameters<Mutations[K]>[1]>( key: K, payload: P, options?: CommitOptions ): ReturnType<Mutations[K]> } & { dispatch<K extends keyof Actions>( key: K, payload: Parameters<Actions[K]>[1], options?: DispatchOptions ): ReturnType<Actions[K]> } & { getters: { [K in keyof Getters]: ReturnType<Getters[K]> } }
Мы на финише. Осталось только расширить глобальные типы Vue.
types/index.d.ts
:
import { Store } from '../store' declare module '@vue/runtime-core' { interface ComponentCustomProperties { $store: Store } }
Мы готовы насладиться полностью типизированным доступом к магазину.
Использование в компонентах
Теперь, когда наше хранилище правильно объявлено и статически типизировано, мы можем использовать его в наших компонентах. Мы рассмотрим использование хранилища в компонентах, определенных с помощью синтаксиса Options API и Composition API, поскольку Vue.js 3.0 поддерживает оба.
API опций
<template> <section> <h2>Options API Component</h2> <p>Counter: {{ counter }}, doubled counter: {{ counter }}</p> <input v-model.number="counter" type="text" /> <button type="button" @click="resetCounter">Reset counter</button> </section> </template> <script lang="ts"> import { defineComponent } from 'vue' import { MutationTypes } from '../store/mutation-types' import { ActionTypes } from '../store/action-types' export default defineComponent({ name: 'OptionsAPIComponent', computed: { counter: { get() { return this.$store.state.counter }, set(value: number) { this.$store.commit(MutationTypes.SET_COUNTER, value) }, }, doubledCounter() { return this.$store.getters.doubledCounter } }, methods: { resetCounter() { this.$store.commit(MutationTypes.SET_COUNTER, 0) }, async getCounter() { const result = await this.$store.dispatch(ActionTypes.GET_COUTNER, 256) }, }, }) </script>
state
:
getters
:
commit
:
dispatch
:
API композиции
Чтобы использовать хранилище в компоненте, определенном с помощью Composition API, мы должны получить к нему доступ через useStore
перехватчик, который просто возвращает наше хранилище:
export function useStore() { return store as Store } <script lang="ts"> import { defineComponent, computed, h } from 'vue' import { useStore } from '../store' import { MutationTypes } from '../store/mutation-types' import { ActionTypes } from '../store/action-types' export default defineComponent({ name: 'CompositionAPIComponent', setup(props, context) { const store = useStore() const counter = computed(() => store.state.counter) const doubledCounter = computed(() => store.getters.doubledCounter) function resetCounter() { store.commit(MutationTypes.SET_COUNTER, 0) } async function getCounter() { const result = await store.dispatch(ActionTypes.GET_COUTNER, 256) } return () => h('section', undefined, [ h('h2', undefined, 'Composition API Component'), h('p', undefined, counter.value.toString()), h('button', { type: 'button', onClick: resetCounter }, 'Reset coutner'), ]) }, }) </script>
state
:
getters
:
commit
:
dispatch
:
Заключение
Результатом наших усилий является полностью статически типизированный store. Нам разрешено фиксировать/отправлять только объявленные мутации/действия с соответствующими полезными нагрузками, иначе мы получим ошибку.