Перейти к содержанию

Композаблы

Совет

Этот раздел предполагает базовые знания об API Composition. Если вы изучали Vue только с Options API, вы можете переключить стиль API на Composition API (с помощью переключателя в верхней части левой боковой панели) и перечитать главы Основы реактивности и Хуки жизненного цикла.

Что такое «Composable»?

В контексте приложений Vue «composable» («композабл») это функция, использующая Composition API для инкапсуляции и повторного использования логики с отслеживанием состояния.

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

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

Пример трекера мыши

Если бы мы реализовали функцию отслеживания мыши с помощью Composition API непосредственно внутри компонента, это выглядело бы следующим образом:

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

const x = ref(0)
const y = ref(0)

function update(event) {
  x.value = event.pageX
  y.value = event.pageY
}

onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>

<template>Позиция курсора мыши: {{ x }}, {{ y }}</template>

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

js
// mouse.js
import { ref, onMounted, onUnmounted } from 'vue'

// По соглашению, имена композаблов начинаются с "use"
export function useMouse() {
  // состояние, инкапсулированное и управляемое композаблом
  const x = ref(0)
  const y = ref(0)

  // композабл может обновлять свое управляемое состояние с течением времени.
  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  // компонент может также подключиться к жизненному хуку
  // компонента-владельца для установки и снятия побочных эффектов.
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  // отображаем управляемое состояние в качестве возвращаемого значения
  return { x, y }
}

Вот как его можно использовать в компонентах:

vue
<script setup>
import { useMouse } from './mouse.js'

const { x, y } = useMouse()
</script>

<template>Позиция курсора мыши: {{ x }}, {{ y }}</template>
Позиция курсора мыши: 0, 0

Попробовать в Песочнице

Как мы видим, основная логика остается идентичной — всё, что нам нужно было сделать, это перенести её во внешнюю функцию и вернуть состояние, которое должно быть открыто. Как и в компоненте, в композаблах можно использовать весь набор функций Composition API. Та же функциональность useMouse() теперь может быть использована в любом компоненте.

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

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

js
// event.js
import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, callback) {
  // Если вы хотите, вы также можете сделать так,
  // чтобы он поддерживал строки селектора в качестве цели
  onMounted(() => target.addEventListener(event, callback))
  onUnmounted(() => target.removeEventListener(event, callback))
}

И теперь наша функция useMouse() может быть упрощена до:

js
// mouse.js
import { ref } from 'vue'
import { useEventListener } from './event'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  useEventListener(window, 'mousemove', (event) => {
    x.value = event.pageX
    y.value = event.pageY
  })

  return { x, y }
}

Примечание

Каждый экземпляр компонента, вызывающий useMouse(), будет создавать свои собственные копии состояния x и y, чтобы они не мешали друг другу. Если вы хотите управлять общим состоянием компонентов, прочитайте главу Управление состоянием.

Пример асинхронного состояния

Функция useMouse() не принимает никаких аргументов, поэтому давайте рассмотрим другой пример, в котором они используются. При выполнении асинхронной выборки данных нам часто приходится обрабатывать различные состояния: загрузку, успех и ошибку:

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

const data = ref(null)
const error = ref(null)

fetch('...')
  .then((res) => res.json())
  .then((json) => (data.value = json))
  .catch((err) => (error.value = err))
</script>

<template>
  <div v-if="error">Упс! Произошла ошибка: {{ error.message }}</div>
  <div v-else-if="data">
    Данные загружены:
    <pre>{{ data }}</pre>
  </div>
  <div v-else>Загрузка...</div>
</template>

Было бы утомительно повторять этот шаблон в каждом компоненте, которому нужно получить данные. Давайте извлечем его в виде композабла:

js
// fetch.js
import { ref } from 'vue'

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

  fetch(url)
    .then((res) => res.json())
    .then((json) => (data.value = json))
    .catch((err) => (error.value = err))

  return { data, error }
}

Теперь в нашем компоненте мы можем просто сделать это:

vue
<script setup>
import { useFetch } from './fetch.js'

const { data, error } = useFetch('...')
</script>

Принятие реактивного состояния

Функция useFetch() принимает на вход статическую строку URL — таким образом, она выполняет выборку только один раз и на этом завершается. Что, если мы хотим, чтобы она выполняла повторную выборку при каждом изменении URL? Чтобы добиться этого, нам нужно передать реактивное состояние в составную функцию и позволить составной функции создавать наблюдатели, которые выполняют действия с использованием переданного состояния.

Например, useFetch() должна иметь возможность принимать ссылку:

js
const url = ref('/initial-url')

const { data, error } = useFetch(url)

// это должно вызвать повторную выборку
url.value = '/new-url'

Или функцию-геттер:

js
// повторная выборка при изменении props.id
const { data, error } = useFetch(() => `/posts/${props.id}`)

Мы можем отрефакторить нашу существующую реализацию с помощью API watchEffect() и toValue():

js
// fetch.js
import { ref, watchEffect, toValue } from 'vue'

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

  const fetchData = () => {
    // сбрасываем состояние перед получением
    data.value = null
    error.value = null

    fetch(toValue(url))
      .then((res) => res.json())
      .then((json) => (data.value = json))
      .catch((err) => (error.value = err))
  }

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

  return { data, error }
}

toValue() — это API, добавленный в версии 3.3. Он предназначен для нормализации ссылок или геттеров в значения. Если аргумент является ссылкой, то возвращается значение ссылки; если аргумент является функцией, он вызовет функцию и вернет её возвращаемое значение. В противном случае он возвращает аргумент как есть. Он работает аналогично unref(), но с особым подходом к функциям.

Обратите внимание, что toValue(url) вызывается внутри обратного вызова watchEffect. Это гарантирует, что все реактивные зависимости, к которым обращаются во время нормализации toValue(), будут отслежены наблюдателем.

Эта версия useFetch() теперь принимает статические строки URL, ссылки и геттеры, что делает её гораздо более гибкой. Эффект watch будет запущен немедленно и будет отслеживать все зависимости, к которым обращались во время toValue(url). Если зависимости не отслеживаются (например, url уже является строкой), эффект выполняется только один раз; в противном случае он будет запускаться заново при каждом изменении отслеживаемой зависимости.

Вот обновлённая версия useFetch(), с искусственной задержкой и рандомизированной ошибкой для демонстрационных целей.

Соглашения и лучшие практики

Именование

Принято называть композаблы именами в «верблюжьем» регистре (camelCase), которые начинаются с use.

Входные аргументы

Композабл может принимать ссылки или геттеры в качестве аргументов, даже если он не полагается на них для реактивности. Если вы пишете составную функцию, которая может быть использована другими разработчиками, неплохо бы обработать случай, когда входными аргументами являются ссылки или геттеры, а не необработанные значения. Для этого пригодится функция toValue():

js
import { toValue } from 'vue'

function useFeature(maybeRefOrGetter) {
  // Если maybeRefOrGetter является ссылкой или геттером,
  // будет возвращено его нормализованное значение.
  // В противном случае она возвращается как есть.
  const value = toValue(maybeRefOrGetter)
}

Если ваша функция создает реактивные эффекты, когда на вход подается ссылка или геттер, убедитесь, что вы либо явно следите за ссылкой/геттером с помощью watch(), либо вызываете toValue() внутри watchEffect(), чтобы она была правильно отслежена.

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

Возвращаемые значения

Вы, наверное, заметили, что в композаблах мы используем исключительно ref(), а не reactive(). Рекомендуется, чтобы композаблы всегда возвращали простой, нереактивный объект, содержащий несколько ссылок. Это позволяет разрушать его на составляющие, сохраняя реакционную способность:

js
// x и y - ссылки
const { x, y } = useMouse()

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

Если вы предпочитаете использовать возвращаемое состояние из коимпозитных функций в качестве свойств объекта, вы можете обернуть возвращаемый объект с помощью reactive(), чтобы ссылки были развёрнуты. Например:

js
const mouse = reactive(useMouse())
// mouse.x связан с оригинальной ссылкой
console.log(mouse.x)
template
Позиция курсора мыши: {{ mouse.x }}, {{ mouse.y }}

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

Можно выполнять побочные действия (например, добавление слушателей событий DOM или получение данных) в составных элементах, но обратите внимание на следующие правила:

  • Если вы работаете над приложением, использующим рендеринг на стороне сервера (SSR), убедитесь, что побочные эффекты, специфичные для DOM, выполняются в хуках жизненного цикла после монтирования, например onMounted(). Эти хуки вызываются только в браузере, поэтому вы можете быть уверены, что код в них имеет доступ к DOM.

  • Не забудьте убрать побочные эффекты в onUnmounted(). Например, если компонент устанавливает слушатель событий DOM, он должен удалить этот слушатель в onUnmounted(), как мы видели в примере useMouse(). Хорошей идеей может быть использование композабла, которая автоматически делает это за вас, как пример useEventListener().

Ограничения на использование

Составляющие должны вызываться только в <script setup> или в хуке setup(). В этих контекстах их также следует вызывать синхронно. В некоторых случаях вы также можете вызывать их в хуках жизненного цикла, например onMounted().

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

  1. В нем можно зарегистрировать хуки жизненного цикла.

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

Совет

<script setup> — единственное место, где вы можете вызывать составные части после использования await. Компилятор автоматически восстанавливает активный контекст экземпляра после выполнения операции async.

Извлечение составных частей для организации кода

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

vue
<script setup>
import { useFeatureA } from './featureA.js'
import { useFeatureB } from './featureB.js'
import { useFeatureC } from './featureC.js'

const { foo, bar } = useFeatureA()
const { baz } = useFeatureB(foo)
const { qux } = useFeatureC(baz)
</script>

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

Использование составных элементов в Options API

Если вы используете Options API, композаблы должны быть вызваны внутри setup(), а возвращаемые привязки должны быть возвращены из setup(), чтобы они были доступны this и шаблону:

js
import { useMouse } from './mouse.js'
import { useFetch } from './fetch.js'

export default {
  setup() {
    const { x, y } = useMouse()
    const { data, error } = useFetch('...')
    return { x, y, data, error }
  },
  mounted() {
    // Открытые свойства setup() могут быть доступны через `this`.
    console.log(this.x)
  }
  // ...другие параметры
}

Сравнение с другими методами

Примеси

Пользователи, пришедшие из Vue 2, могут быть знакомы с опцией mixins, которая также позволяет нам извлекать логику компонентов в многократно используемые блоки. У примесей есть три основных недостатка:

  1. Нечистый источник свойств: При использовании большого количества примесей становится неясно, какое свойство экземпляра инжектируется какой примесью, что затрудняет отслеживание реализации и понимание поведения компонента. Именно поэтому мы рекомендуем использовать шаблон «refs + деструктуризация» для композаблов: это делает источник свойств понятным для потребляющих компонентов.

  2. Коллизии пространств имён: несколько примесей от разных авторов могут регистрировать одни и те же ключи свойств, что приводит к коллизии пространств имён. С помощью составных переменных можно переименовывать деструктурированные переменные, если в них есть конфликтующие ключи из разных составных переменных.

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

По вышеуказанным причинам мы больше не рекомендуем использовать примеси во Vue 3. Эта функция сохраняется только для миграции и удобства.

Компоненты без отрисовки

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

Основное преимущество составных компонентов перед компонентами без отрисовки заключается в том, что составные компоненты не несут дополнительных накладных расходов на экземпляр компонента. При использовании во всём приложении количество дополнительных экземпляров компонентов, создаваемых шаблоном компонентов без отрисовки, может стать заметным повышением производительности.

Рекомендуется использовать составные компоненты при повторном использовании чистой логики и компоненты при повторном использовании как логики, так и визуальной компоновки.

Хуки React

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

Дальнейшее чтение

  • Реактивность в деталях: чтобы получить представление о том, как работает система реактивности Vue.
  • Управление состоянием: для моделей управления состоянием, разделяемым несколькими компонентами.
  • Тестирование композаблов: советы по модульному тестированию композаблов.
  • VueUse: постоянно растущая коллекция композаблов Vue. Исходный код также является отличным учебным ресурсом.
Композаблы