Управление состоянием (state) с помощью Composition API

Spread the love

Оригинальная статья: Filip Rakowski — State Management with Composition API

Некоторое время назад я написал статью о шаблонах управления состоянием в Vue. В течение последних нескольких месяцев я интенсивно работал с Composition API при создании новой версии Vue Storefront, и я подумал, что было бы неплохо поделиться некоторыми своими знаниями. Тема, которую я нашел действительно интересной, — это то, как использование Composition API изменило способ управления состоянием моего приложения — как локального, так и глобального. Если вам интересно, насколько он отличается от нынешних основных подходов, приготовьте себе кофе, потому что сегодня я поделюсь с вами, как использовать Composition API в качестве замены известных шаблонов управления состояниями и библиотек 🙂

Расширенные шаблоны управления состоянием? Vuex? Забудь об этом!

Первое, что я заметил при использовании Composition API (и других API-интерфейсов Vue 3), это то, что он упрощает многие вещи.

Когда они представили Vue.observable в Vue 2.6, то это новое API значительно упростило создание общего состояния приложения. До версии 2.6 никто не мог представить себе реальное приложение Vue без такой библиотеки управления состояниями, как Vuex. Оказалось, что простая функция, которая возвращает реактивные объекты, может быть отличной альтернативой сложному инструменту, который мы считали жизненно важным очень долгое время.

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

Чтобы показать вам, как просто управлять состоянием с помощью Composition API, я буду использовать только 2 хука для всех примеров в этой статье:

  • ref — создает реактивный объект из примитива или объекта
const isLoading = ref(true)
  • computed — создает реактивный объект, который синхронизируется с другими реактивными свойствами. Так же, как computed в Options API
const product = ref(product)
const productName = computed(() => product.name)

Теперь посмотрим, что я смогу сделать только с этим 🙂

Создаем свой собственный Vuex

Прежде чем раскрыть весь потенциал ref и computed, давайте начнем с чего-то «простого» и посмотрим, насколько хорошо эти 2 свойства могут воспроизводить функциональность Vuex.

Простое хранилище Vuex содержит 4 элемента:

  • state
  • mutations
  • actions
  • getters

state — самая простая часть, для воспроизведения. Нам просто нужно создать реактивное свойство с ref

const state = ref({ post: {} })

Теперь давайте создадим mutation (мутацию), чтобы мы могли контролировать изменения состояния. Вместо использования строки, как мы делаем в Vuex, я напишу функцию. Таким образом, мы можем извлечь выгоду из автозаполнения и tree shaking (в отличие от традиционной мутации Vuex)

function setPost(post) { 
  state.value.post = post
}

Мы можем создавать action (действия) точно таким же образом — просто помните, что они могут быть асинхронными:

async function loadPost(id) {
  const post = await fetchPost(id)
  setPost(post)
}

Для getters нам нужно отследить изменения определенных свойств объекта состояния state. Функция computed будет идеальной для этого!

    const getPost = computed(() => state.value.post)

Вместо того, чтобы делать объект состояния state общедоступным, мы экспортируем метод get для доступа к нему (getPost) и action для извлечения новых записей (loadPost), которое использует мутацию (setPost) для обновления состояния. Таким образом, мы не разрешаем прямую мутацию объекта состояния и можем контролировать, как и когда он изменяется.

Вуаля! Мы создали наше собственное Vuex-подобное управление состоянием всего с двумя функциями из Composition API! , Вся наша логика управления состоянием будет выглядеть так:

const state = ref({ post: {} })

function setPost(post) { 
  state.value.post = post
}

async function loadPost(id) {
  const post = await fetchPost(id)
  setPost(post)
}

const getPost = computed(() => state.value.post)

export {
  loadPost
  getPost
} 

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

Например, вы можете сохранить историю состояний с помощью функции watch (которая работает точно так же, как свойство watch из Options API):

const history = []
history.push(state) // push initial state

watch(state, (newState, oldState) => {
  history.push(newState)
})

СОВЕТ: Мы можем достичь того же результата, не выполняя первоначальный push, используя функцию watchEffect, которая работает как watch из Options API с immediate флагом true.

В Vue Storefront мы использовали то же решение (но с reactive вместо ref) для управления нашим состоянием пользовательского интерфейса:

import { reactive, computed } from '@vue/composition-api';

const state = reactive({
  isCartSidebarOpen: false,
  isLoginModalOpen: false
});

const isCartSidebarOpen = computed(() => state.isCartSidebarOpen);
const toggleCartSidebar = () => {
  state.isCartSidebarOpen = !state.isCartSidebarOpen;
};

const isLoginModalOpen = computed(() => state.isLoginModalOpen);
const toggleLoginModal = () => {
  state.isLoginModalOpen = !state.isLoginModalOpen;
};

const uiState = {
  isCartSidebarOpen,
  isLoginModalOpen,
  toggleCartSidebar,
  toggleLoginModal
};

export default uiState;

Ладно. Итак, мы знаем, что мы можем делать то же самое, что Vuex делает с Composition API, но на самом деле это также позволяет делать вещи, которые раньше были невозможны (или, по крайней мере, они требовали гораздо больше работы).

Сохраняем состояние локальным

Одна из лучших особенностей Composition API заключается в том, что он позволяет писать логику Vue вне компонентов Vue. Благодаря этому вы можете нарезать код на многократно используемые, независимые и автономные части и скрыть их бизнес-логику за красивым API.

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

export default function useProduct() {
  const loading = ref(false)
  const products = ref([])

  async function search (params) {
    loading.value = true
    products.value = await fetchProduct(params)
    loading.value = false
  }
  return {
    loading: computed(() => loading.value)
    products: computed(() => products.value)
    search
  }
}

Вы заметили, что вместо прямого возврата products и loading мы возвращаем вычисленные свойства? Таким образом, мы можем быть уверены, что никто не будет изменять эти объекты за пределами useProduct.

СОВЕТ: Вы можете добиться того же эффекта с помощью свойства readonly из Composition API

Теперь, когда мы хотим получить products в компоненте, мы можем использовать нашу функцию useProduct следующим образом:

<template>
  <div>
    <span v-if="loading">Loading product</span>
    <span v-else>Loaded {{ product.name }}</span>
  </div>
</template>

<script>
import { useProduct } from './useProduct'

export default {
  setup (props, context) {
    const id = context.root.$route.params.id
    const { products, loading, search } = useProduct()

    search({ id })

    return {
      product: computed(() => products.value[0]),
      loading
    }
  }
}
</script>

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

Вернемся к нашей функции useProduct. Каково ее состояние? Очевидно, products и loading, но вы заметили что-то действительно классное в них?

Состояние useProduct тесно связано с управляющей ей бизнес-логикой, и состояние является локальным только для этой функции. Его нет в приложении, даже как пустой объект, пока он не понадобится. Это также изменчиво только изнутри.

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

const globalState = ref({
  products: {}, // легко забыть при удаление useProduct
  categories: {},
  user: {},
  // ... другие свойства глобального состояния
})

Крутая вещь с функциями Composition, когда дело доходит до управления состоянием, состоит в том, что он позволяет нам легко выбирать, должно ли состояние быть глобальным или локальным. Эта свобода уменьшает некоторую сложность и зависимость от объекта корневого хранилища по сравнению с такими решениями, как Vuex.

Этот подход также устраняет некоторые проблемы, с которыми мы обычно сталкиваемся с модулями Vuex. Если бы у нас было централизованное состояние для posts, это, вероятно, был бы объект или массив. Всякий раз, когда мы выполняем метод поиска, мы используем объект state.posts или добавляем вложенное свойство с идентификатором записи. Это может затруднить доступ к определенным post, так как нам всегда нужно знать их идентификатор (что может быть проблематично, тогда как у нас есть возможность получить их на основе других свойств, таких как sku, name или slug). Мы также не знаем, когда определенное свойство больше не требуется и может быть удалено, чтобы освободить часть памяти.

У нас нет ни одной из этих проблем в нашей функции useProduct. Каждый раз, когда она вызывается, она создает свое собственное локальное состояние с products и loading, поэтому его можно легко использовать несколько раз. Например, мы могли бы использовать его дважды — сначала для получения определенного продукта, а затем связанных продуктов из той же категории:

async setup (props, context) {
  const id = context.root.$route.params.id
  const { products, search } = useProduct()
  const { products: relatedProducts, search: relatedSearch } = useProduct()

  await search({ id }) // получение основного продукта
  await relatedSearch({ categoryId: products.value[0].catId }) // получение других продуктов из той же категории

  return {
    product: computed(() => products.value[0]),
    relatedProducts
  }
}

Совместное использование состояния между композиционными функциями

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

В Vue Storefront у нас есть свойство useCart, которое (на удивление) отвечает за взаимодействие с корзиной покупок. Вы ожидаете от такой функциональности всегда ссылаться на один и тот же объект корзины, независимо от того, где он вызывается.

Для этого напишем следующий код в SomeComponent.vue:

<template>
  <div>
    Items in cart<b>{{ cart.items.length }}</b>
  </div>
</template>

<script>
export default {
  setup () {
    const { cart } = useCart()
    return { cart }
  }
</script>

Затем будем добавлять товар в корзину в OtherComponent.vue следующим образом:

<template>
  <div>
    <button @click="addToCart(product)">Add to cart</button>
  </div>
</template>

<script>
export default {
  setup () {
    const { products, search } = useProduct()
    const { cart, addToCart } = useCart()

    search({ id: '123' })

    return { 
      cart, 
      addToCart.
      product: computed(() products.value[0])
     }
  }
</script>

Мы хотим, чтобы обе cart ссылались на один и тот же объект, поэтому, когда мы нажимаем кнопку «Add to cart» в OtherComponent, результат сразу становится видимым в SomeComponent.

Мы можем бы добиться такого поведения двумя способами — один из них — использовать объект корзины cart из внешнего хранилища, аналогичный тому, который я показал в начале статьи.

// store.js
const state = ref({ cart: {} })
const setCart = (cart) => { state.value.cart = cart }

export { setCart }
// useCart.js
import { setCart, cart } from './store.js'

export function useCart () {
  // use setCart and cart here
}

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

  1. Наш метод useCart имеет внешнюю зависимость от store.js, что делает его менее автономным и пригодным для повторного использования.
  2. Свойство cart в объекте состояния будет существовать независимо от функции useCart, поэтому его легко пропустить при удалении / изменении этой функции.

Итак, как мы можем улучшить наше решение и решить эти проблемы?

Все, что нам нужно сделать, это немного улучшить шаблон, используемый в useProduct:

export default function useProduct() {
  const loading = ref(false)
  const products = ref([])

  async function search (params) {
    loading.value = true
    products.value = await fetchProduct(params)
    loading.value = false
  }
  return {
    loading: computed(() => loading.value)
    products: computed(() => products.value)
    search
  }
}

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

const cart = ref({})

function useCart () {
  // super complicated cart logic
  return {
    cart: computed(() => cart.value)
  }
}

И вуаля — так вы создаете общее состояние с помощью Composition API! Это решение чрезвычайно просто, как и все предыдущие примеры. Надеюсь, на данный момент вы полюбили Composition API так же сильно, как и я.

Заключение

Composition API — это не только новый, революционный способ обмена повторно используемым кодом между компонентами, но и отличная альтернатива популярным библиотекам управления состоянием, таким как Vuex. Этот новый API может не только упростить ваш код, но и улучшить архитектуру вашего проекта, внеся свой вклад в его модульность. Было бы интересно посмотреть, как Composition API вместе с такими инструментами, как Pinia, повлияет на то, как мы будем управлять нашим состоянием в будущем.

Была ли вам полезна эта статья?
[3 / 3.7]

Spread the love

Добавить комментарий