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 вернет следующую ошибку:

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. Нам разрешено фиксировать/отправлять только объявленные мутации/действия с соответствующими полезными нагрузками, иначе мы получим ошибку.