Перейти к содержимому
Поиск
Разработка

VueUse: удобный интерфейс на Vue — 9 живых примеров и мастерская

Девять связанных интерфейсов на настоящем VueUse: сохранение черновика, поиск с отменой, история, размеры, темы и запросы. Мастерская, исходники и запуск Windows/Linux.

Александр Некрасов
VueUse: удобный интерфейс на Vue — 9 живых примеров и мастерская — обложка

VueUse: маленькие функции, из которых получается удобный интерфейс

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

VueUse помогает связать обычные возможности браузера с реактивностью Vue: хранить черновик, отслеживать размер блока, откладывать поиск, управлять историей, замечать скрытую вкладку и работать с запросами. Composable возвращает состояние и действия, которые можно включить в собственную форму или готовую библиотеку компонентов. Пользователь видит привычные поля и кнопки; техническая связка остаётся внутри приложения.

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

Где это пригодится: поддержка, CRM, редактор и личный кабинет

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

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

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

VueUse, Bootstrap и библиотеки компонентов: разные обязанности

VueUse не рисует готовый календарь или таблицу. Его задача — реактивная логика вокруг состояния, событий и браузерных API. Bootstrap в нашей мастерской отвечает за базовую сетку, формы и кнопки; авторские стили задают спокойную бирюзовую тему. Vue связывает данные и шаблон. Эти части можно менять независимо, пока остаётся понятным контракт между ними.

Если нужны готовые таблицы и формы, посмотрите мастерскую PrimeVue. Если важны составные доступные элементы с собственным оформлением, полезен разбор Reka UI. VueUse дополняет оба подхода: сохраняет выбранную настройку, измеряет контейнер или следит за видимостью, не навязывая внешний вид.

Для сложного серверного состояния, общего кеша и фонового обновления нескольких экранов уместно отдельно изучить TanStack Query во Vue. useFetch удобен для управляемой загрузки; он не превращает небольшой composable в полноценную стратегию кеширования всего приложения.

Версии, лицензия и воспроизводимая установка

Проект закрепляет @vueuse/core 15.0.0, Vue 3.5.45, Bootstrap 5.3.9 и Vite 8.3.8. При подготовке статьи npm latest для VueUse указывал на 15.0.0. Сам пакет требует Node не ниже 22 и совместим с Vue ветки 3.5; для нашего полного инструментария выбран Node 24 LTS не ниже 24.15. Версии зависимостей записаны без диапазонов, а транзитивные пакеты закреплены lock-файлом.

VueUse распространяется по MIT. Авторские исходники мастерской также доступны по MIT, а тексты лицензий production-зависимостей лежат в THIRD-PARTY-LICENSES.txt. Для повторения используйте npm ci, чтобы взять согласованные версии из package-lock.json. Команда npm update решает другую задачу: после неё изменившийся набор нужно проверять заново.

Сверять API полезно одновременно с установленной версией: примеры из старой статьи могут отличаться от текущего поведения. Официальные материалы: начало работы с VueUse и лицензия версии 15.0.0.

Фрагмент файла package.json

{
  "name": "aen-vueuse-workshop",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "license": "MIT",
  "engines": {
    "node": ">=24.15"
  },
  "scripts": {
    "build": "vite build && node tools/finish-build.mjs",
    "test": "node --test tests/*.test.mjs",
    "start": "node tools/serve.mjs"
  },
  "dependencies": {
    "@vueuse/core": "15.0.0",
    "vue": "3.5.45",
    "bootstrap": "5.3.9"
  },
  "devDependencies": {
    "vite": "8.3.8",
    "@vitejs/plugin-vue": "6.0.11",
    "jsdom": "31.0.1"
  }
}

Мини-мастерская: девять экранов одного рабочего места

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

Двенадцать заявок вымышлены. Они действительно загружаются по HTTP из JSON на этом же сайте. Сервер раздаёт статические файлы, а поиск фильтрует полученный список в браузере. Параметр q виден в запросе для изучения отмены, но сервер не выполняет поиск. Ползунок задержки добавляет ожидание на клиенте перед fetch; он не изображает измеренную скорость сети.

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

Запуск в Windows: готовая страница и изменение исходников

Распакуйте архив в отдельную папку и откройте в ней PowerShell. Для разработки нужен установленный Node 24 LTS. Команды ниже устанавливают согласованные зависимости, собирают мастерскую и выполняют тесты. После npm start откройте http://127.0.0.1:8540/. Для остановки нажмите Ctrl+C в той же консоли.

В архиве уже лежит public с готовой сборкой. Если хотите только посмотреть результат, npm start использует стандартные модули Node и не требует предварительного npm ci. При установленном PHP можно раздать готовую папку встроенным сервером PHP. Это удобный вариант для разработчика, который работает в основном с серверными PHP-проектами, но хочет изучить браузерный компонент.

Открытие index.html двойным щелчком не заменяет HTTP-сервер: module scripts и fetch должны получать файлы с корректного адреса. Если порт занят, выберите другой порт в своём tools/serve.mjs либо в команде PHP. Останавливать неизвестный процесс ради примера не нужно. Встроенный сервер привязан к loopback и предназначен для локального просмотра.

Фрагмент файла README.md

node --version
npm ci --ignore-scripts
npm run build
npm test
npm start

Фрагмент файла README.md

php -S 127.0.0.1:8540 -t public

Linux: тот же проект, отдельные зависимости и обычные команды

На Linux выполняется та же последовательность установки, сборки и тестов. Не переносите node_modules с Windows: некоторые инструменты содержат платформенные пакеты. Переносите исходники и package-lock.json, затем запускайте npm ci в целевой среде. В готовом public уже нет зависимости от операционной системы сборщика: это HTML, CSS, JavaScript и JSON.

Для изолированной проверки можно использовать Node в контейнере. В проект включён tools/run-linux-check.sh: он копирует исходники из read-only каталога, исключает node_modules и public, устанавливает зависимости заново и сохраняет результаты проверки. Это способ воспроизвести сборку, а не требование разворачивать контейнер ради статической страницы.

На VPS обычно достаточно раздавать готовый public через Nginx или Caddy. Постоянный Node-процесс для этой мастерской не нужен. Если впоследствии появится настоящее API, это будет отдельный серверный компонент со своими правами, проверкой входа и сохранением. Проверка контейнера подтверждает работу проекта на Linux, но не настройку конкретного домена, сертификата или внешнего сервера.

Фрагмент файла README.md

cd vueuse-project
node --version
npm ci --ignore-scripts
npm run build
npm test
npm start

Архитектура: состояние, правила и запросы отдельно от разметки

В src/App.vue находятся девять экранов и связь их общего состояния. src/model.mjs содержит проверку сохранённого объекта, контракт заявок, фильтр, ошибки формы и сборку текста ответа. src/composables.mjs объединяет useFetch с прикладными правилами и реализует поиск с отменой. data содержит три настоящих JSON-файла: обычный набор, пустой список и намеренно неверную структуру.

Такое разделение полезно, когда пример превращается в рабочий продукт. Формулу или проверку можно испытать без DOM, а загрузчик — с короткоживущим HTTP-сервером. Компоненту остаётся показать понятное состояние и вызвать действие. Правило «при ошибке сохранять последний успешный список» не должно случайно зависеть от того, какую кнопку нажал пользователь.

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

Фрагмент файла src/model.mjs

export function messageText(draft, ticket) {
  return `Заявка ${ticket?.id ?? "не выбрана"}${ticket ? " · " + ticket.client : ""}\nТема: ${draft.subject.trim()}\nОт: ${draft.name.trim()}\nПриоритет: ${draft.priority}\n\n${draft.message.trim()}`;
}

Экран 1. useStorage: черновик переживает перезагрузку

Измените имя или текст, затем обновите страницу. useStorage связывает реактивное значение с localStorage, а форма восстанавливает тот же учебный черновик. Это полезно для заявки, заметки, фильтра или настроек редактора. Человеку не приходится заново вводить данные после случайной перезагрузки, пока хранилище доступно и origin остаётся прежним.

Мы используем один явно названный ключ aen-vueuse-workshop:v1. В объекте лежат черновик и настройки оформления. writeDefaults: false не записывает начальные значения просто из-за открытия страницы. При редактировании создаётся новый объект, поэтому изменение видно Vue и затем сериализуется. Пользовательское сообщение об ошибке остаётся доступным, если браузер запретил хранилище или сохранённый объект повреждён.

LocalStorage не является серверной базой и не переносит данные между устройствами. Оно также не подходит для паролей и токенов. Встраивая этот приём в продукт, заранее решите срок жизни черновика, поведение при выходе из аккаунта и допустимый состав данных. Описание API находится в документации useStorage.

Фрагмент файла src/App.vue

const stored = useStorage(STORAGE_KEY, defaults(), undefined, {
  writeDefaults: false,
  serializer: {
    read: (text) => validSettings(JSON.parse(text)),
    write: (value) =>
      value == null ? "null" : JSON.stringify(validSettings(value)),
  },
  onError: (error) => {
    storageError.value = error.message;
  },
});
const settings = computed(() => stored.value ?? defaults()),
  draft = computed(() => settings.value.draft);
function updateDraft(key, value) {
  stored.value = { ...settings.value, draft: { ...draft.value, [key]: value } };
  storageError.value = "";
}
function updatePreference(key, value) {
  stored.value = {
    ...settings.value,
    preferences: { ...settings.value.preferences, [key]: value },
  };
}
function clearSaved() {
  stored.value = null;
  storageError.value = "";
  notice.value =
    "Данные этой мастерской удалены. Другие ключи localStorage не затронуты. Новые изменения будут сохранены снова.";
}

Удаление, версия схемы и повреждённое сохранение

Кнопка «Удалить данные этой мастерской» присваивает значению useStorage null. Удаляется только собственный ключ; localStorage.clear() не вызывается. После удаления форма показывает начальные значения через computed, но запись появится снова лишь после следующего изменения. Это важная разница между сбросом интерфейса и очисткой всего хранилища сайта.

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

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

Фрагмент файла src/model.mjs

export function validSettings(value) {
  if (!value || value.schema !== 1 || !value.draft || !value.preferences)
    throw Error(
      "Сохранённый черновик имеет другую структуру. Удалите только данные этой мастерской.",
    );
  for (const key of ["name", "subject", "message", "priority"])
    if (typeof value.draft[key] !== "string")
      throw Error("Повреждённый черновик: " + key);
  if (
    value.draft.name.length > 80 ||
    value.draft.subject.length > 120 ||
    value.draft.message.length > 2000 ||
    !["Обычный", "Высокий"].includes(value.draft.priority) ||
    !["auto", "light", "dark"].includes(value.preferences.mode) ||
    typeof value.preferences.compact !== "boolean"
  )
    throw Error("Сохранённые параметры вне допустимого диапазона.");
  return {
    schema: 1,
    draft: { ...value.draft },
    preferences: { ...value.preferences },
  };
}

Экран 2. Поиск с debounce: запрос после паузы в наборе

Поиск реагирует на ввод, но не запускает загрузку на каждую букву немедленно. useDebounceFn ждёт короткую паузу, затем берёт последнее значение. В мастерской задержка debounce равна 350 мс, а maxWait ограничивает длительное откладывание. Отдельный ползунок увеличивает ожидание перед fetch, чтобы поздний ответ было проще увидеть и испытать.

Введите «Маяк», быстро замените на «Контур» и дождитесь результата. Должны остаться три заявки последней компании. Под списком указан применённый запрос: человек видит, к какому тексту относятся карточки. При пустом запросе отображается полный набор, при отсутствии совпадений — понятное пустое состояние. Старые карточки во время ожидания не выдаются за новый результат.

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

Фрагмент файла src/composables.mjs

export function useTicketSearch(
  query,
  { fetchImpl, delay = ref(0), debounce = ref(350) } = {},
) {
  const tickets = useTickets({ fetchImpl, delay }),
    rows = ref([]),
    applied = ref(""),
    waiting = ref(false);
  let sequence = 0;
  const search = useDebounceFn(
    async (value, version) => {
      if (version !== sequence) return;
      const ok = await tickets.load(
        "data/tickets.json?q=" + encodeURIComponent(value),
      );
      if (version !== sequence) return;
      if (ok) {
        rows.value = searchTickets(tickets.rows.value, value);
        applied.value = value;
      }
      waiting.value = false;
    },
    debounce,
    { maxWait: 1400 },
  );
  function schedule(value) {
    const version = ++sequence;
    tickets.cancel();
    waiting.value = true;
    search(value, version);
  }
  watch(query, schedule);
  onScopeDispose(() => {
    sequence++;
  });
  return {
    rows,
    applied,
    waiting,
    busy: computed(() => waiting.value || tickets.isFetching.value),
    started: tickets.started,
    message: tickets.message,
    refresh: () => schedule(query.value),
  };
}

Почему AbortController недостаточно: порядок результатов

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

Загрузчик увеличивает generation при старте и отмене. После await он сравнивает номер своего запуска с текущим. Поиск дополнительно хранит sequence, чтобы отложенный debounce не применил устаревшую строку. В результате поздний ответ не меняет список, даже если нижний транспорт проигнорировал abort. Такой случай отдельно проверяется тестом с двумя управляемыми ответами.

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

Фрагмент файла src/composables.mjs

function cancel() {
    generation.value++;
    request.abort();
    message.value = "Запрос отменён. Последний успешный список сохранён.";
  }

Экран 3. Копирование: полезно только с честным результатом

Третий экран собирает сообщение из выбранной заявки и общего черновика. Пользователь видит полный текст до копирования, может выделить его вручную или скачать TXT. Это пригодится в поддержке, CRM, генераторе команд и странице с реквизитами. Копирование выполняется только по нажатию; содержимое буфера автоматически не читается.

Clipboard API зависит от безопасного контекста, браузерных разрешений и политики iframe. Наличие объекта navigator.clipboard ещё не доказывает успешную запись. В установленном VueUse 15 useClipboard после отказа native API способен перейти к legacy execCommand, а copied не подтверждает фактический результат этого резервного пути. Поэтому мастерская использует небольшой адаптер: успех показывается только после завершившегося native clipboard.write.

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

Фрагмент файла src/App.vue

let nativeCopyConfirmed = false,
  copyInFlight = false;
const clipboardNavigator =
  typeof navigator.clipboard?.write === "function"
    ? {
        clipboard: {
          write: async (items) => {
            await navigator.clipboard.write(items);
            nativeCopyConfirmed = true;
          },
        },
      }
    : {};
const clipboard = useClipboard({
    navigator: clipboardNavigator,
    read: false,
    legacy: false,
  }),
  copyFeedback = ref("");
async function copyText(value) {
  if (copyInFlight) return;
  copyFeedback.value = "";
  if (!clipboard.isSupported.value) {
    copyFeedback.value =
      "Clipboard API недоступен. Выделите текст в поле или скачайте TXT.";
    return;
  }
  copyInFlight = true;
  nativeCopyConfirmed = false;
  try {
    await clipboard.copy(value);
    if (!nativeCopyConfirmed)
      throw Error("Native clipboard write was not confirmed");
    copyFeedback.value = "Текст скопирован.";
  } catch {
    copyFeedback.value =
      "Не удалось подтвердить копирование. Выделите текст вручную или скачайте TXT.";
  } finally {
    copyInFlight = false;
  }
}

Экран 4. useRefHistory: отмена и возврат применённой правки

Введите новый ответ и нажмите «Применить версию». Затем отмените изменение и верните его. useRefHistory хранит историю реактивного значения, а кнопки используют canUndo и canRedo, чтобы не предлагать недоступное действие. В мастерской история ограничена двадцатью шагами отмены и не записывается в localStorage.

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

После undo новая правка создаёт новую ветку работы, поэтому старый путь redo больше не является продолжением текущего текста. Для вложенных объектов отдельно определяйте глубину отслеживания и способ снимка; история ссылки без копирования вложенного состояния может не соответствовать ожиданиям. Здесь хранится строка, что делает пример прозрачным. Кнопка переноса отправляет текущую формулировку в общий сохранённый черновик. Подробности: useRefHistory.

Фрагмент файла src/App.vue

const answer = ref(
    "Здравствуйте! Мы проверяем детали заявки и вернёмся с ответом.",
  ),
  answerInput = ref(answer.value);
const history = useRefHistory(answer, { capacity: 20 });
watch(answer, (value) => (answerInput.value = value));
function applyAnswer() {
  if (answerInput.value.trim()) {
    answer.value = answerInput.value.trim();
  }
}
function answerToDraft() {
  updateDraft("message", answer.value);
  notice.value = "Ответ из истории перенесён в сохранённый черновик.";
}

Экран 5. Размер контейнера важнее ширины окна

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

Меняйте ползунок ширины. В подписи отображаются реальные пиксели, полученные от observer. При ширине от 460 px карточка показывает подробную раскладку, ниже — компактную. На узком телефоне весь диапазон ползунка может оставаться компактным: это правильный результат измерения, а не сломанный переключатель. Для сравнения двух режимов откройте мастерскую в широком окне.

Для чисто визуальной перестановки элементов часто достаточно CSS container queries. JavaScript-наблюдение полезно, когда от размера зависит расчёт, конфигурация диаграммы или другой программный выбор. Не меняйте размеры наблюдаемого блока бесконечно внутри callback: обратная связь способна породить лишние измерения. Сохраняйте вычисления короткими и не подменяйте реальный размер заранее выбранным числом. API: useResizeObserver.

Фрагмент файла src/App.vue

const resizeTarget = ref(null),
  blockPercent = ref(100),
  measuredWidth = ref(0);
const resize = useResizeObserver(resizeTarget, (entries) => {
  measuredWidth.value = Math.round(entries[0]?.contentRect.width ?? 0);
});
const expandedCard = computed(() => measuredWidth.value >= 460);

Экран 6. Видимость блока и пауза скрытой вкладки

Внутри прокручиваемого контейнера есть сводка. Нажмите «Показать сводку», затем вернитесь в начало списка: useElementVisibility меняет состояние по пересечению с заданной областью. Здесь используется scrollTarget — ссылка на реальный контейнер прокрутки. Индикатор сообщает появление блока, но не обещает точный процент видимости или внимание человека.

При первом появлении сводки в видимой вкладке запускается отдельный настоящий GET. До этого счётчик равен нулю, после загрузки карточка показывает двенадцать заявок. Повторная прокрутка не отправляет запрос заново; при отказе доступна явная кнопка повторения. Так наблюдение за элементом связано с полезным действием, а не только с изменением надписи.

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

Так можно экономить необязательные обновления виджета или откладывать работу вне экрана. Но видимость документа нельзя считать надёжным присутствием пользователя, а браузерный таймер — серверным планировщиком. Для обязательных фоновых задач нужен другой механизм. Три секунды выбраны для заметной демонстрации маленького JSON; частоту реального API определяют нагрузка и нужная свежесть. Документация: useElementVisibility и useDocumentVisibility.

Фрагмент файла src/App.vue

const visibilityRoot = ref(null),
  visibilityTarget = ref(null);
const elementVisible = useElementVisibility(visibilityTarget, {
  scrollTarget: visibilityRoot,
});
const documentVisibility = useDocumentVisibility(),
  autoRefresh = ref(false),
  refreshTicks = ref(0);
const lazyApi = useTickets(),
  lazyAttempted = ref(false);
watch(
  [elementVisible, documentVisibility, active],
  ([visible, documentState, section]) => {
    if (
      visible &&
      documentState === "visible" &&
      section === 5 &&
      !lazyAttempted.value
    ) {
      lazyAttempted.value = true;
      lazyApi.load("data/tickets.json");
    }
  },
);
const poll = useIntervalFn(
  () => {
    refreshTicks.value++;
    api.load("data/tickets.json");
  },
  3000,
  { immediate: false },
);
watch(
  [documentVisibility, autoRefresh, active],
  ([visible, enabled, section]) => {
    if (visible === "visible" && enabled && section === 5) poll.resume();
    else poll.pause();
  },
  { immediate: true },
);
function scrollPreview(bottom) {
  visibilityRoot.value?.scrollTo({
    top: bottom ? visibilityRoot.value.scrollHeight : 0,
    behavior: reducedMotion.value === "reduce" ? "instant" : "smooth",
  });
}

Экран 7. useFetch: загрузка, пустота, ошибка и повтор

Выберите обычный список и нажмите загрузку: появятся двенадцать заявок. Затем запросите отсутствующий файл — HTTP-сервер вернёт настоящий 404, а прежний успешный набор останется рядом с явным сообщением. Пустой корректный JSON, напротив, заменяет список пустым. Эти состояния принципиально различаются: «ничего не найдено» не равно «не удалось получить данные».

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

useFetch возвращает реактивные состояния, execute и abort. Наш composable добавляет проверку структуры и правило сохранения последнего результата. Не очищать полезные данные при временном отказе часто удобно в кабинете, но обязательно показывайте, что они могут быть устаревшими. Для критичных операций доступность кнопки должна зависеть от бизнес-правил, а не только от наличия старой карточки. Официальное описание: useFetch.

Фрагмент файла src/composables.mjs

export function useTickets({
  fetchImpl = (...args) => fetch(...args),
  delay = ref(0),
} = {}) {
  const url = ref("data/tickets.json"),
    rows = ref([]),
    message = ref(""),
    completed = ref(0),
    started = ref(0),
    generation = ref(0);
  const request = useFetch(url, {
    immediate: false,
    refetch: false,
    fetch: async (path, options) => {
      await abortableDelay(delay.value, options.signal);
      return fetchImpl(path, options);
    },
  }).json();
  function cancel() {
    generation.value++;
    request.abort();
    message.value = "Запрос отменён. Последний успешный список сохранён.";
  }
  async function load(path = "data/tickets.json") {
    const current = ++generation.value;
    url.value = path;
    started.value++;
    message.value = "";
    await request.execute();
    if (current !== generation.value) return false;
    if (request.aborted.value) {
      message.value = "Запрос отменён. Последний успешный список сохранён.";
      return false;
    }
    if (request.error.value) {
      message.value = request.statusCode.value
        ? `HTTP ${request.statusCode.value}: данные не загружены.`
        : "Не удалось получить JSON. Проверьте соединение и повторите.";
      return false;
    }
    try {
      rows.value = parseTickets(request.data.value);
      completed.value++;
      message.value = `Загружено заявок: ${rows.value.length}.`;
      return true;
    } catch (error) {
      message.value = error.message;
      return false;
    }
  }
  onScopeDispose(() => {
    generation.value++;
    request.abort();
  });
  return { ...request, rows, message, completed, started, load, cancel };
}

HTTP 200 ещё не означает правильные данные

Третий вариант ответа специально возвращает JSON неправильной структуры. Сеть сработала, статус успешный, синтаксис JSON допустим, но tickets не является массивом. Проверка parseTickets останавливает такой ответ до обновления интерфейса. Пользователь получает понятное сообщение, а последняя корректная коллекция сохраняется.

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

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

Фрагмент файла src/model.mjs

export function parseTickets(value) {
  if (
    !value ||
    value.schema !== 1 ||
    !Array.isArray(value.tickets) ||
    value.tickets.length > 200
  )
    throw Error("JSON получен, но структура списка неверна.");
  const ids = new Set();
  for (const row of value.tickets) {
    if (
      !row ||
      typeof row.id !== "string" ||
      ids.has(row.id) ||
      typeof row.client !== "string" ||
      typeof row.title !== "string" ||
      !["Новая", "В работе", "Готово"].includes(row.status) ||
      !Number.isSafeInteger(row.minutes) ||
      row.minutes < 0
    )
      throw Error("JSON получен, но одна из заявок не прошла проверку.");
    ids.add(row.id);
  }
  return value.tickets;
}

Экран 8. Тема, плотность и системные предпочтения

Выберите светлое, тёмное или системное оформление. useColorMode меняет data-bs-theme документа мастерской, а собственные CSS-переменные и Bootstrap используют это значение. В системном режиме итоговая тема следует prefers-color-scheme. Сохранённый выбор и применённая тема показываются отдельно: значение auto не означает какой-то третий цвет.

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

useMediaQuery показывает переход через ширину 720 px, а usePreferredReducedMotion помогает отказаться от плавной прокрутки при соответствующей настройке устройства. Тёмная тема и уменьшенное движение не заменяют проверку контраста, фокуса и порядка элементов. Настройки должны улучшать читаемость, а не скрывать важные действия. Официальный API: useColorMode.

Фрагмент файла src/App.vue

const modeChoice = computed({
  get: () => settings.value.preferences.mode,
  set: (value) => updatePreference("mode", value),
});
const colorMode = useColorMode({
  storageRef: modeChoice,
  attribute: "data-bs-theme",
});
const wideScreen = useMediaQuery("(min-width: 720px)"),
  reducedMotion = usePreferredReducedMotion();

Экран 9. Мини-кабинет: собираем приёмы в рабочую операцию

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

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

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

Фрагмент файла src/App.vue

function addLocal() {
  attempted.value = true;
  if (!ticket.value || Object.values(errors.value).some(Boolean)) {
    notice.value = "Проверьте поля формы и выберите доступную заявку.";
    return;
  }
  localQueue.value.unshift({
    id: "OUT-" + String(localQueue.value.length + 1).padStart(3, "0"),
    ticket: ticket.value.id,
    text: outbound.value,
  });
  notice.value =
    "Ответ добавлен в локальную очередь. Сеть и почта не использовались.";
}

Фрагмент файла src/model.mjs

export function draftErrors(draft) {
  return {
    name: draft.name.trim().length < 2 ? "Введите имя: хотя бы 2 символа." : "",
    subject:
      draft.subject.trim().length < 5
        ? "Добавьте тему: хотя бы 5 символов."
        : "",
    message:
      draft.message.trim().length < 10
        ? "Опишите вопрос: хотя бы 10 символов."
        : "",
  };
}

Свои composables: возвращайте понятный контракт

useTickets возвращает список, сообщение, счётчики, состояние загрузки и действия load/cancel. Компоненту не нужно знать, как создаётся AbortController внутри библиотеки. Однако он знает прикладной смысл: когда данные приняты, что осталось после ошибки и как повторить операцию. Такой контракт удобен для другого экрана или самостоятельного теста.

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

Обратите внимание на refs во вложенном объекте. В JavaScript доступ к ним выполняется через .value. В шаблоне верхнеуровневые refs разворачиваются Vue, однако для вложенных объектов нашего контракта в мастерской явно используются api.rows.value и api.isFetching.value. Следите за этим при переносе фрагмента: потерянная реактивность часто выглядит как случайно переставшая обновляться кнопка.

Жизненный цикл, SSR и освобождение подписок

В обычном Vue-компоненте composables создаются в setup и связаны с его effect scope. При размонтировании VueUse освобождает управляемые подписки, а наш загрузчик дополнительно отменяет запрос и меняет поколение. Таймер при уходе из соответствующего раздела ставится на паузу явно: сам компонент мастерской продолжает существовать, даже когда один экран скрыт условием.

Если вы создаёте composable вне компонента, используйте контролируемую область жизни и вызывайте stop для собственного effectScope. Не считайте, что любой таймер, созданный сторонним кодом, исчезнет автоматически. Особенно внимательно проверяйте редакторы, которые многократно открываются в модальном окне, и панели, где список виджетов часто меняется.

Эта мастерская собирается как браузерное SPA. Её прямой доступ к navigator в адаптере clipboard нельзя без проверки перенести в серверный рендеринг. Для SSR обращайтесь к браузерным объектам после mount или используйте явные guards и согласованное начальное состояние. Иначе сервер не найдёт window, а тема или сохранённый черновик могут дать различающуюся разметку при гидратации.

Фрагмент файла src/composables.mjs

onScopeDispose(() => {
    generation.value++;
    request.abort();
  });

Исходники, экспорт и понятные ограничения

В мастерской доступен просмотр реальных файлов проекта: App.vue, модель, composables, тесты, стили, данные и инструкции. Кнопка скачивания выдаёт выбранный файл; общий ZIP содержит также lock-файл и готовую сборку. Vue, VueUse и Bootstrap уже включены в локальные assets, поэтому открытая страница не зависит от сторонней CDN.

Экспорт черновика и очереди формирует файл в браузере через Blob. Он не загружает данные на неизвестный сервис. После скачивания object URL освобождается. Для своего продукта отдельно определяйте, какие поля можно экспортировать, нужен ли понятный формат даты и как человек импортирует результат обратно. Наличие кнопки JSON само по себе не создаёт полноценный резервный механизм.

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

Фрагмент файла src/App.vue

function download(name, value, type = "text/plain;charset=utf-8") {
  const url = URL.createObjectURL(new Blob([value], { type })),
    a = document.createElement("a");
  a.href = url;
  a.download = name;
  document.body.append(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
  notice.value = "Подготовлен файл: " + name;
}

Linux/VPS: публикация готового public через Nginx

Соберите проект от обычного пользователя, выполните тесты и подготовьте отдельный каталог сайта. Для Debian или Ubuntu пример в docs/linux-vps.md создаёт /srv/vueuse-demo/public с правом записи пользователя deploy и доступом веб-сервера к чтению. Переносится содержимое public, а не весь каталог разработки. После изменения собственного виртуального хоста выполните nginx -t и только затем reload.

Конфигурация ниже выдаёт явный 404 для отсутствующего файла. Это важно: возврат index.html на любой URL может замаскировать пропавший JSON ответом HTTP 200 и привести к ошибке разбора. Имена JS/CSS содержат хеш, поэтому assets можно кешировать надолго. Для index.html и данных нужен актуальный ответ или revalidation, иначе после обновления останутся несовместимые версии.

Пример показывает HTTP-конфигурацию, а публичному домену нужен HTTPS: настройте DNS и принятый у вас механизм сертификатов Nginx/Caddy. Clipboard на публичном HTTP может быть недоступен. Размещая мастерскую в подпапке, открывайте конечный index.html либо URL с завершающим slash, чтобы относительные data и assets разрешались правильно. Для обновления сохраняйте предыдущий release и переключайте собственный web-root после проверки файлов.

Фрагмент файла docs/nginx.conf

server {
    listen 80;
    server_name vueuse.example.test;
    root /srv/vueuse-demo/public;
    index index.html;
    add_header X-Content-Type-Options nosniff always;
    location / { try_files $uri $uri/ =404; }
    location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; }
    location ~ \.(html|json)$ { add_header Cache-Control "no-cache"; }
}

Фрагмент файла docs/linux-vps.md

sudo install -d -m 0755 -o "$USER" -g www-data /srv/vueuse-demo/public
cp -R public/. /srv/vueuse-demo/public/
sudo nginx -t
sudo systemctl reload nginx

Что проверено тестами и что проверяется отдельно в браузере

На Windows и Linux прошли десять тестов проекта. Девять проверяют модель, контракт, фильтрацию, отмену, последнюю версию поиска и HTTP-поведение. Для сетевого теста запускается собственный временный сервер на loopback: useFetch получает настоящий 200, неправильную структуру и 404, затем восстанавливает обычный набор. Сервер закрывается после проверки.

Десятый тест импортирует собранный JavaScript в JSDOM и проходит все девять экранов. Проверяются изменение и удаление собственного storage-ключа, сохранность чужого ключа, история, смена темы, отказ копирования, ошибки формы, пустой список и восстановление. Clipboard, геометрические observers и media queries там управляются адаптерами; они проверяют связь состояний, но не доказывают поведение конкретного телефона.

Поэтому браузерная приёмка остаётся отдельным этапом: узкая ширина, крупные кнопки, отсутствие общей горизонтальной прокрутки, реальные размеры карточки, видимость при прокрутке и честный результат clipboard. После размещения также нужны проверки публичных JS/CSS/JSON и скачивания архива. Успешная сборка в контейнере не означает автоматически исправный production-адрес или настроенный внешний VPS.

Фрагмент файла README.md

npm run build
npm test

Размер сборки и следующий шаг для своего приложения

JavaScript всей мастерской занимает 120959 байт, gzip — 44232 байт. CSS с Bootstrap занимает 235712 байт, gzip — 32485 байт. Это измерение конкретных собранных файлов девяти экранов, а не размер одной функции VueUse. Данные с хешами находятся в public/build-info.json; при изменении состава приложения результат может отличаться.

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

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

Официальные материалы и самостоятельные задания

Начните с изменения вымышленного набора: добавьте заявку, проверьте уникальность ID и убедитесь, что она появляется в поиске и выборе кабинета. Затем добавьте поле в черновик вместе с проверкой структуры и отображением в готовом тексте. Наконец, замените статический URL собственным учебным endpoint и воспроизведите отмену, пустоту и ошибку контракта.

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

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