ЯдроКодаподготовка к экзаменам
Учебная платформа

Загружаем материалы

Подготавливаем материалы и навигацию по разделу.

Модули во Vue 3: ES modules, SFC и composable с собственным состоянием

Автор: · Обновлено

Во Vue слово «модуль» может обозначать обычный файл JavaScript, компонент SFC или выделенную часть функциональности. Эти понятия связаны, но различаются способом выполнения, владением состоянием и ролью в интерфейсе.

Среда и минимальная структура

Основной пример — полный App.vue для Vue 3 с Composition API и поддержкой TypeScript. Его можно вставить в официальный Vue SFC Playground или заменить им src/App.vue в отдельном учебном проекте. Для локального запуска используйте официальный npm create vue@latest, выберите TypeScript, установите зависимости через npm install и запустите npm run dev. На дату проверки руководство Vue указывает Node.js ^22.18.0 || >=24.12.0; требования генератора следует сверять при создании проекта.

Здесь типы лишь уточняют идентификатор и список выбранных занятий; основная логика остаётся JavaScript. SFC объединяет шаблон, логику и стили одного компонента. Браузер не исполняет исходный .vue как JavaScript самостоятельно: настроенный инструмент компилирует его. Получившийся компонент можно импортировать, потому что результат компиляции участвует в системе ES modules.

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

Полный пример: выбор занятий

<script setup lang="ts">
import { computed, ref } from 'vue';

const lessons = [
  { id: 1, title: 'Модули', minutes: 20 },
  { id: 2, title: 'DOM', minutes: 25 },
];
const selected = ref<number[]>([]);
const total = computed(() => lessons
  .filter((lesson) => selected.value.includes(lesson.id))
  .reduce((sum, lesson) => sum + lesson.minutes, 0));

function toggle(id: number) {
  selected.value = selected.value.includes(id)
    ? selected.value.filter((value) => value !== id)
    : [...selected.value, id];
}
</script>

<template>
  <main class="plan">
    <h1>План занятий</h1>
    <ul class="lesson-list">
      <li v-for="lesson in lessons" :key="lesson.id">
        <button type="button"
          :aria-pressed="selected.includes(lesson.id)"
          @click="toggle(lesson.id)">
          {{ lesson.title }} — {{ lesson.minutes }} мин
        </button>
      </li>
    </ul>
    <p role="status">Выбрано: {{ selected.length }}. Всего: {{ total }} мин.</p>
  </main>
</template>

<style scoped>
.plan { max-width: 32rem; margin: 2rem auto; padding: 1rem; }
.lesson-list { display: grid; gap: 0.75rem; }
</style>

Сначала отображается Выбрано: 0. Всего: 0 мин.. Выбор «Модули» даёт один урок и двадцать минут, затем выбор DOM — два урока и сорок пять минут. Повторное нажатие первой кнопки оставляет один урок и двадцать пять минут. Проверяйте также клавиатурную активацию: используются настоящие кнопки, а состояние выбора передаётся через aria-pressed.

В коде значение ref читается через .value, а в шаблоне верхнеуровневая привязка разворачивается автоматически. Итог вычисляется через computed, поэтому не требуется вручную синхронизировать отдельный счётчик минут. Вычисление остаётся чистым: не отправляет запросы, не изменяет выбор и не записывает данные в хранилище. Исходный список в этом уроке статичен; для загружаемого списка следует отдельно спроектировать реактивный вход.

ES module и экземпляр компонента

Обычный верхнеуровневый код модуля относится к экземпляру этого модуля в среде выполнения. Код внутри script setup компилируется в настройку, выполняемую для каждого экземпляра компонента. Поэтому два экземпляра нашего плана получают независимые ref выбора. Импорт самого компонента два раза и создание двух экземпляров — не одно и то же действие.

Если вынести const selected = ref(...) на верхний уровень обычного импортируемого файла, все потребители могут получить общее состояние. Иногда оно нужно, но это должно быть явным решением. Особенно осторожно относитесь к серверному рендерингу: общий модуль процесса не должен случайно хранить персональный выбор разных запросов. Фабрика внутри правильно выбранного жизненного цикла обеспечивает другую границу владения.

script setup не требует вручную экспортировать каждую переменную для шаблона: компилятор знает его привязки. Макросы вроде defineProps и defineEmits также относятся к компиляции SFC, а не являются обычными глобальными функциями браузера. Не переносите их в произвольный .js файл в надежде, что импорт Vue сделает их доступными везде.

Практика: вынести переиспользуемое состояние

Создайте файл useLessonSelection.ts со следующим содержимым. Это отдельный модуль для того же Vue-проекта; его не нужно вставлять в консоль браузера. Функция возвращает доступное для чтения состояние и явное действие изменения. Входом служит фиксированный список занятий: реактивную замену этого списка данный контракт не обещает.

import { computed, readonly, ref } from 'vue';

type Lesson = { id: number; minutes: number };

export function useLessonSelection(lessons: readonly Lesson[]) {
  const selected = ref<number[]>([]);
  const total = computed(() => lessons
    .filter((lesson) => selected.value.includes(lesson.id))
    .reduce((sum, lesson) => sum + lesson.minutes, 0));
  function toggle(id: number) {
    if (!lessons.some((lesson) => lesson.id === id)) return;
    selected.value = selected.value.includes(id)
      ? selected.value.filter((value) => value !== id)
      : [...selected.value, id];
  }
  return { selected: readonly(selected), total, toggle };
}

В App.vue замените импорт реактивных функций импортом useLessonSelection из соседнего файла. Сохраните массив lessons, а объявления selected, total и toggle замените строкой const { selected, total, toggle } = useLessonSelection(lessons). Шаблон и ожидаемое поведение останутся прежними. Проверка важна: извлечение кода должно сохранить контракт, а не только уменьшить длину компонента.

Если назвать компонент с планом LessonPlan.vue и разместить два его экземпляра в родителе, выбор должен быть независимым. Если по требованиям нужен общий план, создайте состояние один раз в явном владельце и передайте его вниз. Для визуальной карточки используйте props как вход и событие как запрос изменения; не меняйте поля родительского состояния скрыто внутри дочерней карточки.

Жизненный цикл и практические ошибки

Composable может управлять подпиской, таймером или наблюдением. Тогда он обязан согласовать создание и очистку ресурса с владельцем, например через хуки компонента. Браузерный доступ к window или DOM нельзя выполнять безусловно на верхнем уровне модуля, который также импортируется сервером. Монтирование и серверное выполнение имеют разные доступные API.

Не путайте живую привязку ES module с реактивностью Vue. Изменение обычной экспортированной переменной не автоматически запускает обновление шаблона. Vue должен отследить реактивную зависимость. И наоборот, destructuring объекта из composable безопасен для связи, если возвращаются ref; извлечение простого числового поля из reactive-объекта может создать лишь снимок значения.

Частые вопросы

Почему браузер не понимает импорт файла .vue?

Исходный SFC требует компилятора и настроенного инструмента разработки. Проверьте, что открываете адрес dev-сервера, а не файл напрямую. Ошибка MIME или синтаксиса часто означает, что сервер отдаёт исходник без нужного преобразования.

Нужно ли всегда создавать composable?

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

Чем scoped CSS отличается от отдельного модуля?

scoped ограничивает применение правил с помощью преобразования селекторов компонентов. Это не система импорта JavaScript и не изоляция уровня Shadow DOM. Общие стили приложения и локальные стили компонента всё равно требуют осмысленного распределения.

Источники