Трехуровневая организация Vue приложений

статья

Рассмотрена трехуровневая организация состояния Vue приложений с использованием библиотек useQuery, Pinia, VueUse

Содержание

Вступление

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

  • Интроспекция
  • Удобство отладки
  • Ясность
  • Высокая связность
  • Слабое зацепление

С точки зрения этих характеристик, управление состоянием — одна из ключевых вещей, которые необходимо сделать правильно. Со временем, внесение изменений в функционал веб-приложения или добавление новых функций становятся сложными. Баги копятся, и исправление одной ошибки нередко порождает несколько новых. К такому исходу нередко приводит неоптимальная организация управления состоянием.

В этой статье я опишу решение для управления состоянием Vue приложения с использованием трех библиотек: Tanstack vue-query, Pinia, VueUse.

Три уровня состояния приложения

В большинстве веб-приложений,с которыми мне приходилось работать, можно выделить три уровня:

  • UI компонент
  • Модули, реализующие связные кусочки предметной области (далее буду называть их предметные модули)
  • Сервер

UI компонент

Представим себе чекбокс в веб-форме. Реализуем этот чекбокс в виде компонента. Такой компонент будет иметь внутреннее состояние:

const checked = defineModel('checked');

Управление таким состоянием — это область ответственности конкретного компонента. Оно может быть реализовано либо напрямую в теле функции setup либо, опосредованно, в виде composable функций. Цикл жизни такого состояния заключен между инициализацией (setup) и размонтированием (onUnmounted) конкретного компонента.

Предметные модули

Представим себе корзину покупок в онлайн-магазине. В корзине могут лежать товары, которые составляют общую стоимость корзины. Корзина может быть выведена на экран в интерфейсе пользователя в разных местах приложения с использованием разных UI компонентов. Корзину могут использовать другие модули приложения для своих целей (например, роутер может принимать решение о редиректе в зависимости от состояния корзины).

Большинство из описанных требований к корзине делают необходимым ее реализацию в виде предметного модуля, обладающего высокой связностью и имеющего цикл жизни, не связанный ни с одним конкретным компонентом. Например:

const useShoppingCart = defineStore('shoppingCart', () => {
  const goods = ref([])
  const total = computed(() => goods.value.reduce((acc, good) => acc + good.price.amount, 0))

  return {
    goods,
    total,
  }
})

Сервер

Представим себе список последних покупок пользователя, номер его телефона, остаток на счете и т.д. Управление таким состоянием, строго говоря — зона ответственности сервера. Сервер предоставляет API для чтения и изменения этого состояния. Физическая отдаленность клиента и сервера влечет за собой заметную задержку между стартом и завершением операций чтения и записи. Такая особенность усложняет задачу реализации быстрого и удобного веб-приложения.

Эта проблема родила такие решения как кеширование результатов запросов на стороне клиента, оптимистический апдейт. В рамках реактивной архитектуры Vue приложения, эту задачу решает библиотека @tanstack/vue-query. Это очень мощная библиотека и я рассмотрю лишь малую долю ее возможностей. Например:

const { data } = useQuery({
  queryKey: ['payments'],
  queryFn: async () => {
    // fetch payments form server
    // ...
    return payments
  },
})

Таким образом, useQuery реализует реактивный объект с результатами серверного запроса. Реактивный объект хорошо вписывается в архитектуру Vue. Кроме того, useQuery позволяет гибко настраивать кэширование и делать оптимистический апдейт.

Пример имплементации трех уровней

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

Auth Store

Pinia использует название store для того, что мы выше назвали предметным модулем. Вот реализация модуля аутентификации пользователя в виде pinia стора:

src/stores/auth.ts

import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { createEventHook } from '@vueuse/core'

export const useAuthStore = defineStore('auth', () => {
  const phone = ref('')

  const loggedInHook = createEventHook()
  const loggedOutHook = createEventHook()

  const logIn = async (phoneVal: string) => {
    await new Promise((done) => setTimeout(done, 1000))
    phone.value = phoneVal
    await loggedInHook.trigger()
  }

  const isLoggedIn = computed(() => Boolean(phone.value))

  const { data: userInfo } = useQuery({
    queryKey: ['user-data'],
    enabled: () => !!phone.value,
    queryFn: async () => {
      await new Promise((done) => setTimeout(done, 1000))
      return {
        name: 'Artem',
        phone: phone.value,
      }
    },
  })

  const queryClient = useQueryClient()
  const logOut = async () => {
    phone.value = ''
    queryClient.removeQueries({
      queryKey: ['user-data'],
    })
    await loggedOutHook.trigger()
  }

  return {
    logIn,
    logOut,
    isLoggedIn,
    userInfo,
    addEventListener,
    onLoggedIn: loggedInHook.on,
    onLoggedOut: loggedOutHook.on,
  }
})

Серверным состоянием здесь управляет useQuery. И сверху добавляется слой состояния и логики модуля аутентификации.

Потребители стора часто бывают заинтересованы в каких-то событиях, происходящих внутри стора. Один из способов реализовать подписку потребителя на такие события — использовать вотчеры на реактивных объектах, которыми владеет стор. Но такой подход имеет серьезные недостатки:

  • Потребителю приходится самому строить семантику на основе изменений внутренних состояний стора, т.е. вместо потребления высокоуровневых предметных событий, потребителю приходится ковыряться на низком уровне, интерпретируя изменения внутренних состояний стора.
  • Каждый потребитель (а их обычно много) вынужден реализовывать интерпретацию изменений внутренних состояний стора. Это порождает много повторяющегося кода.

Решением описанных проблем является реализация предметных событий внутри стора. Стор превращается в эвент-эмиттер. Потребители могут подписаться на интересующие высокоуровневые события. Мы сделали это с помощью createEventHook из библиотеки VueUse:

const loggedInHook = createEventHook()
const loggedOutHook = createEventHook()

return {
    onLoggedIn: loggedInHook.on,
    onLoggedOut: loggedOutHook.on,
}

AuthPage

Далее реализуем один из потребителей модуля аутентификации — UI компонент страницы аутентификации.

src/pages/AuthPage.vue

<template>
  <div>
    <h1>auth page</h1>
    <form @submit.prevent="onSubmit">
      <label>phone: <input type="text" v-model="phone" required /></label>
      <button>log in</button>
    </form>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth.ts'
import { useRouter } from 'vue-router'

const router = useRouter()

const authStore = useAuthStore()
authStore.onLoggedOut(() => {
  console.log('auth page: logged out')
})
authStore.onLoggedIn(() => {
  console.log('auth page: logged in')
})

const phone = ref('')
const onSubmit = async () => {
  await authStore.logIn(phone.value)
  await router.push('/')
}
</script>

phone — пример состояния UI компонента. Это внутреннее состояние компонента AuthPage. Оно привязано к текстовому полю для ввода телефона. Реализовав это состояние в виде реактивного объекта, внутри функции setup компонента, мы добились низкой сцепленности между слоями UI и предметного модуля.

Потребитель регистрирует обработчик события стора authStore.onLoggedOut(...). При этом, нам не требуется делать отписку при размонтировании компонента. VueUse реализовала отписку за нас.

MainPage

Еще один потребитель модуля аутентификации — UI компонент MainPage.

src/pages/MainPage.vue

<template>
  <div>
    <h1>main page</h1>
    <div v-if="authStore.userInfo">
      <div>phone {{ authStore.userInfo.phone }}</div>
      <div>Hello {{ authStore.userInfo.name }} <button @click="onLogOut">log out</button></div>
    </div>
    <div v-else>loading...</div>
  </div>
</template>

<script setup lang="ts">
import { useAuthStore } from '@/stores/auth.ts'
import { useRouter } from 'vue-router'

const router = useRouter()

const authStore = useAuthStore()
authStore.onLoggedIn(() => {
  console.log('main page: logged in')
})
authStore.onLoggedOut(() => {
  console.log('main page: logged out')
})

const onLogOut = async () => {
  await authStore.logOut()
  await router.push('/auth')
}
</script>

Роутер гарды

И последний потребитель модуля аутентификации — плагин vue router (а точнее, роутер гарды, которые этот плагин применяет).

src/router/index.ts

import { createRouter, createWebHistory } from 'vue-router'
import MainPage from '@/pages/MainPage.vue'
import { useAuthStore } from '@/stores/auth.ts'

export const createAppRouter = () => {
    const router = createRouter({
        history: createWebHistory(import.meta.env.BASE_URL),
        routes: [
            {
                path: '/',
                component: MainPage,
            },
            {
                path: '/auth',
                component: () => import('@/pages/AuthPage.vue'),
            },
        ],
    })

    const authStore = useAuthStore()

    router.beforeEach((to) => {
        if (to.path !== '/auth' && !authStore.isLoggedIn) {
            return '/auth'
        }
    })

    return router
}

Подключаем плагины в нужном порядке.

main.ts

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { VueQueryPlugin } from '@tanstack/vue-query'

import App from './App.vue'
import { createAppRouter } from './router'

const app = createApp(App)

app.use(VueQueryPlugin)
app.use(createPinia())
app.use(createAppRouter())

Получается следующая иерархия хранилищ состояния: query <- pinia <- component. Можно использовать и упрощенную схему: query <- component.

Интроспекция

В момент отладки важно иметь возможность наблюдать внутренние состояния приложения. Vue devtools предоставляет возможность наблюдать состояние UI компонентов и Pinia сторов.

Pinia dev tools

Итоги

Управление состоянием — не та задача, которую можно пустить на самотек. В этом деле нужно руководствоваться рядом договоренностей и пользоваться профессиональными инструментами. В нашем примере мы разделили состояние приложения на три отдельных слоя: серверные данные, предметные модули, компоненты. Мы достигли высокой наблюдаемости состояния приложения с помощью vue devtools. Переложили сложную задачу управления кешем на специальный инструмент (vue-query). Реализовали высоко-связный предметный модуль с внутреннем состоянием, логикой и высокоуровневыми событиями (authStore). Изолировали локальное состояние UI компонента, избежав смешивания его с состоянием предметного модуля, достигнув таким образом низкого зацепления слоев приложения.

Смотри также