Лекции
May 8, 2020

SPA-приложение на Vue.JS #5

Привет! В прошлом видео мы научились получать данные асинхронно с помощью Axios, а сегодня мы эти данные будем сохранять во Vuex.

Что такое Vuex?

Если говорить в общем - это state-manager, модуль для управления состоянием приложения. Пример из нативного javascript: есть такая часть объекта window как localStorage, в нём можно сохранять любые пользовательские данные, которые в любой момент оттуда можно получить. Использование localStorage позволяет хранить данные (а точнее, их часть) на клиенте, избегая постоянных обращений к серверу. Vuex несколько шире простого хранилища данных: мы имеем определённые методы для всех объектов, в которых хранятся данные и можем при изменении этих данных поменять состояние нашего приложения с помощью мутаций. Vuex позволяет отделить управление состоянием приложения от самих его компонентов.

Схема работы Vuex:

Источник: https://vuex.vuejs.org/ru/

То есть, говоря более конкретно: Vuex нам нужен для того, чтобы растащить по разным модулям дерево компонентов Vue и общее состояние приложения. При этом, у нас появляется доступ ко всем данным внутри конкретного состояния из любой компоненты.

Подключение

В папке src создаём папку store. В ней создаём файл index.js со следующим содержимым:

import Vue from 'vue'
import Vuex from 'vuex'

// import your store modules here

Vue.use(Vuex)

export default new Vuex.Store({
  modules: {
    // put your store modules
  }
})

В файлике src/main.js импортируем store:

import store from '@/store'

Не забываем передать его и в элемент класса Vue (как router и vuetify).

Пишем свои store-модули

Для начала создадим общий модуль (файл common.js). Он и будет отвечать за общее состояние нашего приложения.

export default {
  state: {
    loading: false,
    error: null
  },
  mutations: {
    setLoading (state, payload) {
      state.loading = payload
    },
    setError (state, payload) {
      state.error = payload
    },
    clearError (state) {
      state.error = null
    }
  },
  actions: {
    setLoading ({ commit }, payload) {
      commit('setLoading', payload)
    },
    setError ({ commit }, payload) {
      commit('setError', payload)
    },
    clearError ({ commit }, payload) {
      commit('clearError')
    }
  },
  getters: {
    loading (state) {
      return state.loading
    },
    error (state) {
      return state.error
    }
  }
}

Разберём структуру по порядку:

state — содержит в себе состояние данного объекта store. В формате ключ: значение.

mutations — это мутации, они должны менять состояние. Мутации синхронны, поэтому получать данные асинхронным образом внутри них — не получится. Поэтому инициировать мутации следует только из actions.

actions — объект, который содержит в себе все действия, которые можно производить с данными: получать их асинхронно, обрабатывать и инициировать мутации, чтобы записать данные в состояние.

getters — нужны для получения состояния.

Импортируйте его в store, туда, где указано комментариями.

Теперь перейдём к работе с модулями. Чтобы использование Vuex было полностью оправдано — мы будем получать теперь не только общую информацию, но и топ-5 стран, которые будем выводить вот в этом меню:

Меню, с помощью которого мы будем фильтровать данные по странам.

Для начала создадим файлик countryPrototype, который будет возвращать конструктор для каждого объекта country, сохраняемого в store.

Структура:

export default class Country {
  constructor (
    id,
    name,
    cases,
    deaths,
    recoveries,
    visualData,
    visualLabels
  ) {
    this.id = id
    this.name = name
    this.cases = cases
    this.deaths = deaths
    this.recoveries = recoveries
    this.visualData = visualData
    this.visualLabels = visualLabels
  }
}

Это нужно для того, чтобы автоматизировать процесс создания нового объекта внутри нашего store и избежать ошибок, поскольку объект будет создаваться "по образу и подобию", что называется.

Теперь сам модуль:

Чтобы получить топ-5 стран: https://corona.lmao.ninja/v2/countries?sort=active

import Country from './countryPrototype'
import axios from 'axios'

export default {
  state: {
    currCountryName: null,
    countries: [],
    topCountries: []
  },
  mutations: {
    newCountry (state, payload) {
      state.countries.push(payload)
    },
    newCountryName (state, payload) {
      state.currCountryName = payload
    },
    newTopCountry (state, payload) {
      state.topCountries = payload
    }
  },
  actions: {
    newCountry ({ commit, getters }, payload) {
      commit('clearError')
      commit('setLoading', true)
      
      console.log('new country is', payload)
      axios
        .get(`https://corona.lmao.ninja/v2/historical/${payload}?lastdays=30`)
        .then((response) => {
          let data = response.data
          
          let labels = []
          let datasets = {}
          let cases = []
          let deaths = []
          let recoveries = []
          
          for (let key in data.timeline.cases) {
            labels.push(key)
            
            cases.push(data.timeline.cases[key])
            deaths.push(data.timeline.deaths[key])
            recoveries.push(data.timeline.recovered[key])
          }          
          datasets['cases'] = cases
          datasets['deaths'] = deaths
          datasets['recoveries'] = recoveries
          
          let id = getters.countryId
          
          const сountry = new Country(
            id,
            data.country,
            cases[cases.length - 1],
            deaths[deaths.length - 1],
            recoveries[recoveries.length - 1],
            datasets,
            labels
          )
          
          console.log('new state is', this.state)
          
          commit('setLoading', false)
          commit('newCountry', сountry)
        })
        .catch((error) => {
          commit('setLoading', false)
          commit('setError', error.message)
          throw error
        })
    },
    newCountryName ({ commit }, payload) {
      commit('newCountryName', payload)
    },
    updateTopCountries ({ commit, dispatch }) {
      commit('clearError')
      commit('setLoading', true)
      
      axios
        .get('https://corona.lmao.ninja/v2/countries?sort=active')
        .then((response) => {
          let data = response.data.slice(0, 5)
          data = data.map((country) => { return country.country })
          
          commit('newTopCountry', data)
          
          for (let country of data) {
            dispatch('newCountry', country)
          }
          
          commit('setLoading', false)
        })
    }
  },
  getters: {
    country (state) {
      console.log('data is', state.countries.find(country => country.name === state.currCountryName))
      return state.countries.find(country => country.name === state.currCountryName)
    },
    currentCountry (state) {
      console.log('currentCountry', state.currCountryName)
      return state.currCountryName
    },
    countryId (state) {
      return state.countries.length
    },
    topCountries (state) {
      console.log('countries:', state.countries)
      return state.topCountries
    }
  }
}

Компоненты + store

Получение данных: this.$store.getters.key, где key — ключик, в который Вы записали нужный Вам объект.

Запись данных в store (а точнее — вызов действия, который эту запись будет производить): this.$store.dispatch('action', arguments), где action — имя вашего действия, а arguments — аргументы, которые будут в него переданы.

В компоненте App.vue надо добавить вычисляемое свойство:

  computed: {
    countries () {
      return this.$store.getters.topCountries
    }
  }

Вычисляемые свойства нужны для того, чтобы результат их работы кэшировался и обновлялся автоматически только в том случае, если в цепочке есть изменения.

Меняем содержимое нашего <v-list>, который открывается по нажатию на три bullet-points:

<v-list-item v-for="country in countries" :key="country" @click="() => {}" link :to="'/country/' + country">
  <v-list-item-title @click="updateCountry(country)">{{ country }}</v-list-item-title>
</v-list-item>

countries здесь — то самое вычисляемое свойство, не забудьте указать атрибут link и :to. Метод updateCountry() очень прост:

  methods: {
    updateCountry (country) {
      this.$store.dispatch('newCountryName', country)
    }
  }

И не забудьте, что ровно в тот момент, когда наша компонента App.vue будет смонтирована — надо обновить список топ-5 стран:

  mounted () {
    this.$store.dispatch('updateTopCountries')
  }

Теперь Country.vue. По логике она такая же, как и Total.vue, но только карточки и графики теперь — вычисляемые свойства:

  computed: {
    countryDataCards () {
      let countryData = this.$store.getters.country
      let cards = []
      
      cards.push({ title: 'total cases', bgColor: 'primary lighten-2', amount: countryData.cases, amountNew: 0, icon: 'mdi-alert-box' })
      cards.push({ title: 'deaths', bgColor: 'red accent-2', amount: countryData.deaths, amountNew: 0, icon: 'mdi-emoticon-dead' })
      cards.push({ title: 'recoveries', bgColor: 'teal lighten-1', amount: countryData.recoveries, amountNew: 0, icon: 'mdi-hospital-box' })
      
      return cards
    },
    countryDataVisuals () {
      let countryData = this.$store.getters.country
      let visuals = []
      
      visuals.push({
        id: 1,
        chartData: {
          labels: countryData.visualLabels,
          datasets: [{
            label: 'Total cases',
            backgroundColor: '#6aaaff',
            data: countryData.visualData.cases
          }]
        },
        options: { responsive: true, maintainAspectRatio: false }
      })
      
      visuals.push({
        id: 2,
        chartData: {
          labels: countryData.visualLabels,
          datasets: [{
            label: 'Deaths',
            backgroundColor: '#ff5252',
            data: countryData.visualData.deaths
          }]
        },
        options: { responsive: true, maintainAspectRatio: false }
      })
      
      visuals.push({
        id: 3,
        chartData: {
          labels: countryData.visualLabels,
          datasets: [{
            label: 'Recoveries',
            backgroundColor: '#26a69a',
            data: countryData.visualData.recoveries
          }]
        },
        options: { responsive: true, maintainAspectRatio: false }
      })
      
      return visuals
    }
  }

Соответственно, вместо карточек (cards) и графиков (visuals) используйте теперь имена вычисляемых свойств (countryDataCards и countryDataVisuals).

Не забудьте и про передачу пропса queryName из параметров роутера

export default {
  name: 'Country',
  props: ['queryName'],
  <...>

Сам роутер:

import Vue from 'vue'
import Router from 'vue-router'
import Total from '@/components/Total'
import Countries from '@/components/Countries'
import Country from '@/components/Country'
import store from '@/store'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/',
      name: 'Total',
      component: Total
    },
    {
      path: '/countries',
      name: 'Countries',
      component: Countries
    },
    {
      path: '/country/:queryName',
      name: 'Country',
      component: Country,
      props: true,
      beforeEnter (to, from, next) {
        let queryName = to.params.queryName
        store.dispatch('newCountryName', queryName)
        next()
      }
    }
  ]
})

Исходники на гитхабе, я на всякий случай, обновил.

Заключение

На этом сегодня всё. В следующем видео мы разберём CI/CD и напишем простой NGINX-конфиг, чтобы обеспечить автоматизированный деплой нашего приложения после пуша в ветку мастер.