Vue.js Composables. Лучшие практики

После выхода Composition API разработчики приложений на Vue получили в свое распоряжение композиционные функции — еще один способ разбить код на небольшие модули для лучшей читаемости и повторного использования. Так были решены проблемы, которые мы имели применяя иные подходы: Mixins (Миксины) и Renderless Components (Компоненты без представления). Но спустя время многие из нас не достаточно хорошо владеют тонкостями написания Composables. В этой статье вы найдете описание важных моментов, благодаря которым ваш код будет становится более поддерживаемым.

Содержание

Что такое Composable

Composable (Композиционная функция) — это функция, которая использует Composition API для инкапсуляции и повторного использования логики хранящей состояние.

Рассмотрим следующий простой пример:

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const isOnline = ref(navigator.onLine)

function updateStatus() {
  isOnline.value = navigator.onLine
}

onMounted(() => {
  window.addEventListener('online', updateStatus)
  window.addEventListener('offline', updateStatus)
})

onUnmounted(() => {
  window.removeEventListener('online', updateStatus)
  window.removeEventListener('offline', updateStatus)
})
</script>

<template>
  <div v-if="!isOnline" class="offline-banner">
    Нет подключения к интернету. Проверьте сеть.
  </div>
</template>

<style scoped>
.offline-banner {
  padding: 12px 16px;
  background: #fef3c7;
  color: #92400e;
  text-align: center;
}
</style>

Логика понятна, но видно, что <script setup> занят не UI, а инфраструктурой.

Компонент работает. Однако, если завтра понадобится показывать статус сети ещё где-то — в шапке, рядом с кнопкой «Сохранить», в настройках — придётся копировать те же ref, onMounted и onUnmounted.

Перенесём всё, что относится к отслеживанию сети, в отдельную функцию. По имеющемуся соглашению название composable начинают с префикса use:

import { ref, onMounted, onUnmounted } from 'vue'

export function useOnlineStatus() {
  const isOnline = ref(navigator.onLine)

  function updateStatus() {
    isOnline.value = navigator.onLine
  }

  onMounted(() => {
    window.addEventListener('online', updateStatus)
    window.addEventListener('offline', updateStatus)
  })

  onUnmounted(() => {
    window.removeEventListener('online', updateStatus)
    window.removeEventListener('offline', updateStatus)
  })

  return { isOnline }
}

Теперь компонент будет отвечать только за отображение. Читать его проще: одна строка в <script setup>, шаблон без лишнего шума:

<script setup>
import { useOnlineStatus } from '@/composables/useOnlineStatus'

const { isOnline } = useOnlineStatus()
</script>

<template>
  <div v-if="!isOnline" class="offline-banner">
    Нет подключения к интернету. Проверьте сеть.
  </div>
</template>

<style scoped>
.offline-banner {
  padding: 12px 16px;
  background: #fef3c7;
  color: #92400e;
  text-align: center;
}
</style>

Компонент делает своё дело, а composable — своё.

Composable с параметрами

Используем toValue

Рассмотрим компонент с обращением к backend:

<script setup>
import { ref, watch } from 'vue'

const postId = ref(1)
const data = ref(null)
const error = ref(null)
const loading = ref(false)

watch(postId, async (id) => {
  loading.value = true
  error.value = null
  try {
    const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
    data.value = await res.json()
  } catch (e) {
    error.value = e
    data.value = null
  } finally {
    loading.value = false
  }
}, { immediate: true })
</script>

<template>
  <div>
    <button v-for="n in 3" :key="n" @click="postId = n">Пост {{ n }}</button>

    <p v-if="loading">Загрузка...</p>
    <p v-else-if="error">{{ error.message }}</p>
    <article v-else-if="data">
      <h2>{{ data.title }}</h2>
      <p>{{ data.body }}</p>
    </article>
  </div>
</template>

Мы могли бы вынести обращение к fetch и первичную обработку результата в composable. Начнем с использования фиксированного URL:

import { ref } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)

  loading.value = true
  fetch(url)
    .then((res) => res.json())
    .then((json) => { data.value = json })
    .catch((err) => { error.value = err })
    .finally(() => { loading.value = false })

  return { data, error, loading }
}

При подключении useFetch задаем нужный URL:

import { useFetch } from '@/composables/useFetch'

const { data, error, loading } = useFetch(
  'https://jsonplaceholder.typicode.com/posts/1'
)

Компонент стал короче, но postId с кнопками пока не работает — URL зашит. То есть не используется реактивность на полную мощь.

Нужно иметь возможность принимать как строку, так и ref или getter, чтобы перезапускать запрос при изменении значения. Для этих целей прекрасно послужит toValue (доступен во Vue с версии 3.3) — он приводит любой из этих вариантов к обычному значению и даёт watchEffect отследить зависимости:

import { ref, watchEffect, toValue } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)

  const fetchData = () => {
    loading.value = true
    data.value = null
    error.value = null

    fetch(toValue(url)) // ref, getter или строка
      .then((res) => res.json())
      .then((json) => { data.value = json })
      .catch((err) => { error.value = err })
      .finally(() => { loading.value = false })
  }

  watchEffect(() => {
    fetchData()
  })

  return { data, error, loading }
}

Код компонента — простой:

<script setup>
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetch'

const postId = ref(1)
const { data, error, loading } = useFetch(
  () => `https://jsonplaceholder.typicode.com/posts/${postId.value}`
)
</script>

<template>
  <div>
    <button v-for="n in 3" :key="n" @click="postId = n">Пост {{ n }}</button>

    <p v-if="loading">Загрузка...</p>
    <p v-else-if="error">{{ error.message }}</p>
    <article v-else-if="data">
      <h2>{{ data.title }}</h2>
      <p>{{ data.body }}</p>
    </article>
  </div>
</template>

Теперь наш Composable более универсален. Мы передаем в него getter (() => /posts/${postId.value}). Можем передавать и ref и простую строку. Все благодаря toValue.

Используем ref и unref

В одной из своих статей Майкл Тиссен (Michael Thiessen) показывает еще один прием обработки параметра composable на лету. Такой, чтобы можно было работать как с примитивом, так и с реактивной переменной (ref).

Мы могли бы написать composable, так, чтобы он работал следующим образом:

const count = ref(2)
const { increment } = useCount(count)
increment() // count.value === 3
const { count, increment } = useCount(2)
increment() // count.value === 3

Код самого useCount может выглядеть так:

import { ref } from 'vue'

export function useCount(input) {
  const count = ref(input)   // 2 → новый ref; ref → тот же ref

  function increment() {
    count.value++
  }

  return { count, increment }
}

Это работает благодаря тому, что если мы передадим в ref реактивную переменную, то получем на выходе ссылку на нее же:

// Создадим новый ref
const myRef = ref(0);

// Получим тот же ref
assert(myRef === ref(myRef));

Когда же нам нужен ref, а не toValue? Он нужен, если composable **владеет состоянием** и должен работать с реактивным значением внутри себя. Если же composable только **читает** реактивное состояние (разово или внутри watchEffect), то здесь подойдет toValue.

В своей статье Майкл Тиссен также говорит по unref.

// Когда нам нужно использовать простое значение в composable
export default useMyComposable(input) {
  const rawValue = unref(input);
}

После выхода Vue 3.3 unref не так полезен, так как делает то же, что и toValue, но только для ref и примитиваgetter не вызовет. Для чтения старого кода и cтатей полезно знать, что это предшественник toValue.

Использование опций

У composable могут быть обязательные аргументы и еще несколько необязательных настроек. Передавать их длинным списком неудобно — непонятен порядок и смысл каждого значения. Лучше использовать паттерн из первой статьи серии Vue Mastery, когда опциональные параметры собирают в объект options.

// Длинный список аргументов. Что значит каждое число?
useCount(0, 5, 100)
// Объект — настройки читаются сами
useCount(0, { step: 5, max: 100 })

Выгоды такого подхода:

1. Не нужно помнить порядок — в объекте он не важен.

2. Код лучше читается — «step: 5» понятнее, чем голая пятёрка третьим аргументом.

3. Легко расширять — новая опция не ломает существующие вызовы.

Обязательные аргументы идут отдельно, а options — последним параметром с дефолтом {}.

Расширим useCount из примера выше:

export function useCount(input, options = {}) {
  const { step = 1, max = Infinity } = options

  const count = ref(input)

  function increment() {
    count.value = Math.min(count.value + step, max)
  }

  return { count, increment }
}

Деструктуризация сразу задаёт значения по-умолчанию — вызывающий код передаёт только то, что нужно изменить.

const { count, increment } = useCount(0, { step: 5, max: 100 })

increment()
increment()
// count.value === 10, так как шаг 5

Без options наш composable ведёт себя как раньше — step равен 1, а ограничения по значению нет.

Тот же приём используется в библиотеке в VueUse:

useTitle('Заголовок', { titleTemplate: '%s | Мой сайт' })
useRefHistory(count, { deep: true, capacity: 15 })

Динамический return

Иногда composable по-умолчанию отдаёт одно значение, а по запросу — объект с методами. Паттерн из третьей статьи Vue Mastery можно озвучить так: возвращаем простое, когда хватает простого; отдаем подробности, когда нужен контроль.

Например useInterval:

// Достаточно счётчика
const counter = useInterval(1000)

// Нужны pause и resume
const { counter, pause, resume } = useInterval(1000, { controls: true })

Здесь меньше шума в простых случаях. Нет лишней деструктуризации.

    Реализация выглядит так:

    import { ref, onMounted, onUnmounted } from 'vue'
    
    export function useInterval(ms, options = {}) {
      const { controls = false } = options
      const counter = ref(0)
      let id
    
      const pause = () => clearInterval(id)
      const resume = () => {
        pause()
        id = setInterval(() => counter.value++, ms)
      }
    
      onMounted(resume)
      onUnmounted(pause)
    
      return controls ? { counter, pause, resume } : counter
    }

    Опция controls меняет только форму return — логика внутри одна.

    Использование:

    <script setup>
    import { useInterval } from '@/composables/useInterval'
    
    const ticks = useInterval(1000)
    
    const { counter, pause, resume } = useInterval(500, { controls: true })
    </script>
    
    <template>
      <p>Тиков: {{ ticks }}</p>
      <p>Счёт: {{ counter }}</p>
      <button @click="pause">Пауза</button>
      <button @click="resume">Продолжить</button>
    </template>

    Во VueUse так устроены useInterval, useNow, useTimeout. Ищите опцию controls.

    Однако, стоит заметить, что это не дает какой-то большой выгоды в читаемости кода. Простой случай выглядел бы так:

    const { ticks } = useInterval(1000)

    вместо

    const ticks = useInterval(1000)

    Не слишком большая разница, не так ли?

    Рекомендации по написанию Composable

    В документации Vue расказывается о нескольких правилах которые стоит соблюдать.

    Именование

    Имя для composable должно начинаться с «use» и быть в формате camelCase.

    Входящие величины

    Если Ваш composable требует какой-то входящий параметр, то желательно иметь поддержку передачи в него ref и getter, даже если в реальности реактивность не используется. Предпочтительнее использовать toValue вместо рассчета на передачу примитива. Как это делается описывалось выше.

    Возврат значений

    Рекомендуется всегда возвращать простой объект с набором ref, а не реактивный (результат вызова reactive).

    Побочные эффекты

    В composable побочные эффекты — это нормально. Но стоит помнить о следующих нюансах:

    • onMounted() — здесь создаем обработчики событий, таймеры, работаем с DOM.
    • onUnmounted() — здесь всё это очищаем.
    • При использовании SSR (Server-Side Rendering) нельзя обращаться к window, document и DOM вне onMounted(), потому что на сервере их не существует.

    Ограничения

    Composables должны вызываться только в секции <script setup> или в хуке setup()

    Также <script setup> — то место, где мы можем вызвать composable после использования await. В хуке setup() так делать нельзя.

    Если у вас еще используется Options API, то composables должны быть вызваны в setup() , а нужные данные из них должны быть в return этого хука.

    Польза от Composable

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

    Composables организуют код понятнее чем другие, более ранние подходы во Vue — примести (mixins) и компоненты без отображения (renderless components).

    Полезные ссылки

    0 0 голоса
    Рейтинг статьи
    guest
    0 комментариев
    Старые
    Новые Популярные