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

Composition API и локальный composable

Оглавление серии «Vue 3: кабинет сотрудников «Команда»»

В App накопились правила поиска, сортировки и добавления. Они относятся к одной предметной задаче — работе с локальной командой — и могут быть выделены в composable useTeam. Результат урока заключается в повторном использовании реактивной логики без переноса шаблона. Карточки и панели сохраняют свои обязанности, а App становится местом соединения интерфейса с состоянием.

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

Функция создаёт коллекцию

Создадим полный src/composables/useTeam.js. Все рабочие ref объявлены внутри функции. Возвращаем отдельно исходные значения, вычисляемую коллекцию и операции. Благодаря этому вызывающий компонент может читать их без копирования алгоритмов.

import { computed, ref } from 'vue'
import { seedMembers } from '../data/members.js'

export function useTeam() {
  const members = ref(seedMembers.map(member => ({ ...member })))
  const query = ref('')
  const onlyAvailable = ref(false)
  const descending = ref(false)
  const visibleMembers = computed(() => members.value.filter(member =>
    `${member.name} ${member.role}`.toLocaleLowerCase('ru')
      .includes(query.value.trim().toLocaleLowerCase('ru'))
    && (!onlyAvailable.value || member.available)
  ).sort((a, b) => (descending.value ? -1 : 1) *
    (a.name.localeCompare(b.name, 'ru') || a.id.localeCompare(b.id))))
  function toggle(id) {
    const member = members.value.find(item => item.id === id)
    if (member) member.available = !member.available
  }
  function create(draft) {
    members.value.push({ id: crypto.randomUUID(), ...draft, available: true, version: 1 })
  }
  return { members, query, onlyAvailable, descending, visibleMembers, toggle, create }
}

Руководство Vue по composables описывает такой способ организации логики. Для кабинета преимущество проявляется в точном месте изменения: правило отбора можно исправить в useTeam, не затрагивая разметку панели. Функция остаётся небольшой и предметной. Она не должна становиться общим контейнером любой логики приложения только потому, что название допускает импорт из многих файлов.

Полная замена src/App.vue использует новый модуль. Файлы MemberCard, MemberForm и TeamPanel сохраняются из предыдущего состояния.

<script setup>
import { useTeam } from './composables/useTeam.js'
import MemberCard from './components/MemberCard.vue'
import MemberForm from './components/MemberForm.vue'
import TeamPanel from './components/TeamPanel.vue'
const { members, query, onlyAvailable, descending, visibleMembers, toggle, create } = useTeam()
</script>
<template>
  <main>
    <h1>Команда</h1>
    <TeamPanel title="Новый участник">
      <MemberForm @create="create" />
      <template #hint>Добавление изменяет локальную коллекцию.</template>
    </TeamPanel>
    <TeamPanel title="Сотрудники">
      <label>Поиск <input v-model="query" type="search"></label>
      <label><input v-model="onlyAvailable" type="checkbox"> Только доступные</label>
      <label><input v-model="descending" type="checkbox"> Обратный порядок</label>
      <p>Показано {{ visibleMembers.length }} из {{ members.length }}</p>
      <p v-if="!visibleMembers.length">Подходящих участников нет.</p>
      <MemberCard v-for="member in visibleMembers" :key="member.id"
        :member="member" @toggle="toggle" />
      <template #hint>Всего участников: {{ members.length }}.</template>
    </TeamPanel>
  </main>
</template>

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

Возвращённые ref сохраняют связь

Деструктуризация результата useTeam не превращает ref в обычные значения. members и query по-прежнему являются контейнерами. Это отличие существенно: если функция вернула бы reactive-объект и мы вынули из него отдельное примитивное поле обычной деструктуризацией, связь могла бы потеряться. Наш договор возвращает объект с самостоятельными ref и computed, что удобно для script setup и распаковки в шаблоне.

Не пишите const query = useTeam().query.value, ожидая дальнейших обновлений. Такая запись читает текущее значение и присваивает обычную строку. Для изменения настройки нужен сам ref либо другой явно объявленный механизм. Полезный ошибочный случай этого шага — потерять value в одном месте JavaScript или слишком рано извлечь его в другом. Реактивность не является свойством любого похожего имени переменной; она зависит от того, какую ссылку вы сохранили.

Операции toggle и create замыкаются над ref конкретного вызова. Если два компонента независимо вызовут useTeam, они получат две коллекции с одинаковыми исходными людьми. Добавленный Максим появится только в той коллекции, где вызван create. Это не случайная поломка модулей, а прямое следствие объявления members внутри функции. Локальное состояние полезно для независимых экземпляров, например двух черновиков сравнения, но не выполняет задачу общего кабинета автоматически.

Поэтому на этом шаге useTeam вызывается один раз у App. Когда появятся маршруты, мы сначала увидим проблему независимых источников и затем введём отдельное общее состояние. Не переносите ref на уровень модуля заранее без обсуждения жизненного цикла: в серверном рендеринге такой singleton может смешать данные разных запросов. Наша будущая общая коллекция будет иметь явно ограниченную среду браузерного учебного приложения.

Правило, эффект и место подключения

Вычисляемый список остаётся чистой логикой. Он не читает DOM и не обращается к localStorage. Такой composable может использоваться в компоненте без специального ожидания монтирования. Но это не означает, что любые внешние ресурсы следует бездумно подключать в его теле. Если функция добавит обработчик окна или watcher с побочным эффектом, у него появятся требования к очистке и среде выполнения. Следующий урок рассмотрит именно такую зависимость для настроек поиска.

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

Разберите изменение файла без запуска: найдите все места, где App раньше объявлял members, и убедитесь, что теперь остаётся один импорт и один вызов useTeam. Далее проследите, куда направляется create из MemberForm. Если случайно оставить старый локальный ref рядом с новым, шаблон и обработчик могут работать с разными коллекциями. Такая ошибка создаёт впечатление «Vue не обновляется», хотя причина заключается в двух источниках. Следующий шаг добавит watcher для сохранения фильтра, сохранив один ясный источник каждого значения.

Зависимости функции и исходный набор

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

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

Для будущего сопровождения полезно сформулировать инвариант: visibleMembers не содержит объектов, которых нет в members, а toggle не создаёт новых участников. Тогда изменение сортировки не должно менять число записей, а изменение доступности не должно менять id. Эти предметные правила помогают оценить рефакторинг независимо от конкретного числа строк исходника.

Для самостоятельного воспроизведения результата «Выделить независимое состояние списка в функцию» выберите lesson-08 в архиве исходников. Статус выполнения этого состояния: not_run.