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

Composition API появился как ответ на разрастание больших компонентов

История инструмента началась в феврале 2014 года, когда один разработчик опубликовал свой проект как открытую библиотеку. Проект вырос в полноценный фреймворк, а спустя годы пережил капитальную переработку. Третья версия принесла новый способ описания компонентов, который назвали составным.

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

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

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

Функция установки становится точкой входа для составной логики

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

import { ref } from "vue";

export default {
  setup() {
    const count = ref(0);

    return {
      count,
    };
  },
  mounted() {
    console.log(this.count);
  },
};

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

Второе свойство касается момента выполнения. Внутри функции установки нет доступа к контексту экземпляра компонента, потому что он ещё не создан. Это ограничение дисциплинирует: вся работа ведётся с локальными переменными, а не с глобальным состоянием компонента.

Третье свойство касается возвращаемого значения. Функция обязана вернуть объект синхронно. Асинхронный вариант допускается только в одном случае: когда компонент находится внутри специальной обёртки, управляющей ожиданием.

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

<script setup>
import { ref, computed } from "vue";

const search = ref("");
const items = ref([]);
const filtered = computed(() =>
  items.value.filter((item) => item.title.includes(search.value)),
);
</script>

Сокращение экономит десятки строк на каждом компоненте. Вместо возврата большого объекта достаточно объявить переменные.

Реактивные примитивы различаются по назначению и требуют понимания

Два базовых инструмента составляют основу работы с состоянием. Первый создаёт реактивную ссылку на отдельное значение, второй делает реактивным целый объект.

import { ref, reactive } from "vue";

const isLoading = ref(false);
const profile = reactive({
  name: "",
  city: "",
  rating: 0,
});

isLoading.value = true;
profile.city = "Казань";

Разница в обращении заметна сразу. Ссылка требует поля значения при чтении и записи в сценарии, но разворачивается в шаблоне. Реактивный объект ведёт себя как обычный объект, зато теряет реактивность при разрушении на отдельные переменные.

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

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

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

Наблюдатели выполняют побочные действия при изменении состояния

Третья группа инструментов отвечает за реакцию на изменения. Наблюдатель следит за выбранным источником и запускает функцию при его обновлении.

import { ref, watch, watchEffect } from "vue";

const query = ref("");
const results = ref([]);

watch(query, async (value, previous) => {
  if (value.length < 3) {
    results.value = [];
    return;
  }
  results.value = await searchRemote(value);
});

watchEffect(() => {
  document.title = "Поиск: " + query.value;
});

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

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

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

let controller = null;

watch(query, async (value) => {
  if (controller) {
    controller.abort();
  }
  controller = new AbortController();
  try {
    results.value = await searchRemote(value, { signal: controller.signal });
  } catch (error) {
    if (error.name !== "AbortError") {
      throw error;
    }
  }
});

Такой код гарантирует, что в интерфейс попадёт ответ на последний запрос, а не на один из предыдущих. Без отмены ответы приходят в произвольном порядке и перезаписывают друг друга.

Составные функции выносят повторяющуюся логику из компонентов

Главная практическая ценность подхода проявляется, когда логику удаётся переиспользовать. Для этого её оформляют отдельной функцией, которая использует реактивные инструменты и возвращает результат.

Так выглядит отслеживание положения курсора, вынесенное в переиспользуемую функцию:

import { ref, onMounted, onUnmounted } from "vue";

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 };
}

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

Функция возвращает пару значений, и компонент просто подключает её:

<script setup>
import { useMouse } from "./useMouse";

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

<template>
  <p>Курсор: {{ x }}, {{ y }}</p>
</template>

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

import { ref, watch } from "vue";

export function useFetch(url, options = {}) {
  const data = ref(null);
  const error = ref(null);
  const loading = ref(false);

  async function load() {
    loading.value = true;
    error.value = null;
    try {
      const response = await fetch(url.value, options);
      if (!response.ok) {
        throw new Error("Ответ сервера: " + response.status);
      }
      data.value = await response.json();
    } catch (cause) {
      error.value = cause;
    } finally {
      loading.value = false;
    }
  }

  watch(url, load, { immediate: true });

  return { data, error, loading, reload: load };
}

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

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

Порядок объявлений и утечки памяти чаще всего становятся источником ошибок

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

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

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

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

import { ref, onUnmounted } from "vue";

export function usePolling(task, interval = 5000) {
  const active = ref(true);
  const timer = setInterval(() => {
    if (active.value) {
      task();
    }
  }, interval);

  onUnmounted(() => {
    active.value = false;
    clearInterval(timer);
  });

  return { active };
}

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

Переход на новый стиль стоит планировать, а не выполнять одним рывком

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

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

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

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

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

Пятого шага как такового нет. Часть компонентов может остаться в старом стиле навсегда, и это нормально, если они работают и не мешают. Цель перехода не в единообразии ради единообразия, а в снижении стоимости изменений.

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