Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Компоненти й Composition API

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

15 питань

Дані йдуть вниз через props, події - вгору через emits. Це односпрямований потік даних.

<!-- Counter.vue -->
<script setup>
const props = defineProps({ count: { type: Number, required: true } });
const emit = defineEmits(['update']);
</script>

<template>
  <button @click="emit('update', props.count + 1)">{{ props.count }}</button>
</template>
<!-- Батьківський компонент -->
<Counter :count="clicks" @update="clicks = $event" />

Головне правило: дочірній компонент не змінює props. Значення належить батькові: якщо дитина його змінить, батько при наступному рендері перезапише зміну, а Vue виведе попередження. Дитина просить зміну подією, а батько вирішує.

v-model на компоненті - скорочення для пари «prop + подія»:

<SearchInput v-model="query" />
<!-- те саме, що :modelValue="query" @update:modelValue="query = $event" -->

З Vue 3.4 у дочірньому компоненті це зручно оформлюється через defineModel().

Коли props і emits незручні: якщо дані треба передати через п'ять рівнів компонентів, які самі їх не використовують. Тоді - provide/inject або стор (Pinia).

Докладніше в документації: Props: односпрямований потік даних

  • v-if - умовний рендер: якщо умова хибна, елемента немає в DOM взагалі. При зміні умови елемент (чи компонент) створюється заново або знищується разом зі своїм станом, обробниками й дочірніми компонентами.
  • v-show - елемент рендериться завжди, а умова лише перемикає CSS display: none.
<Modal v-if="isOpen" />          <!-- немає в DOM, поки закрито -->
<div v-show="isExpanded">...</div> <!-- є завжди, лише прихований -->

Як обирати:

  • v-show - коли елемент перемикають часто (вкладки, розгортання блоків): перемкнути CSS дешевше, ніж щоразу створювати й знищувати.
  • v-if - коли умова змінюється рідко або елемент важкий і часто не потрібен узагалі: не витрачати час на початковий рендер того, що не покажуть.

Наслідки, про які питають:

  • З v-if стан дочірнього компонента (введений текст, прокрутка) скидається при кожному приховуванні. З v-show - зберігається.
  • v-if має v-else і v-else-if, v-show - ні. І v-show не працює на <template>.
  • Не ставте v-if і v-for на один елемент: у Vue 3 v-if виконується першим і не бачить змінної циклу. Фільтр - через computed або <template v-for> з v-if усередині.

Докладніше в документації: v-if проти v-show

Щоб використати компонент у шаблоні, Vue має знати, що це за тег. Є два способи його «зареєструвати».

Глобальна реєстрація - компонент доступний у шаблоні будь-якого компонента застосунку:

import { createApp } from 'vue';
import BaseButton from './components/BaseButton.vue';

const app = createApp(App);
app.component('BaseButton', BaseButton);
app.mount('#app');

Локальна реєстрація - компонент доступний лише там, де його імпортували. У <script setup> для цього досить імпорту:

<script setup>
import BaseButton from './BaseButton.vue';
</script>

<template>
  <BaseButton>Зберегти</BaseButton>
</template>

Чому документація Vue радить локальну реєстрацію:

  • tree shaking: глобально зареєстрований компонент потрапляє в збірку, навіть якщо ніде не використовується. Локальний - лише туди, де його імпортовано, і разом з тією частиною коду, що його потребує (важливо для розділення коду за сторінками);
  • явні залежності: з імпорту видно, звідки компонент. З глобальною реєстрацією в шаблоні з'являється тег, походження якого треба шукати - у великому проєкті це ускладнює підтримку й рефакторинг;
  • TypeScript і редактор краще розуміють імпортовані компоненти (підказки props, перехід до визначення).

Коли глобальна реєстрація доречна:

  • справді базові компоненти, що використовуються майже скрізь (кнопки, іконки) у невеликому застосунку;
  • компоненти бібліотеки UI, яку підключають як плагін (app.use(PrimeVue));
  • компоненти, які мають бути доступні в шаблонах, зібраних у браузері з HTML сторінки (див. Vue «острівцями» на Blade-сторінках).

Найменування:

  • у SFC - PascalCase: <BaseButton />. Так тег легко відрізнити від HTML-елементів;
  • шаблони в HTML-сторінці (не в .vue-файлах) нечутливі до регістру - там тільки kebab-case: <base-button></base-button>, і без самозакривних тегів.

Автоімпорт (плагін unplugin-vue-components) - компромісний варіант: у шаблоні компонент без імпорту, але в збірку він потрапляє як локальний.

Докладніше в документації: Реєстрація компонентів

Основні хуки Composition API:

Хук Коли спрацьовує
setup (сам <script setup>) створення компонента, до рендеру
onBeforeMount перед першим додаванням у DOM
onMounted компонент у DOM - можна працювати з елементами
onBeforeUpdate / onUpdated до й після оновлення DOM через зміну стану
onBeforeUnmount / onUnmounted перед і після видалення компонента
onActivated / onDeactivated для компонентів у <KeepAlive>
onErrorCaptured помилка в дочірньому компоненті
<script setup>
import { onMounted, onUnmounted, ref } from 'vue';

const width = ref(window.innerWidth);
const onResize = () => (width.value = window.innerWidth);

onMounted(() => window.addEventListener('resize', onResize));
onUnmounted(() => window.removeEventListener('resize', onResize));
</script>

Хуки мають викликатися синхронно в setup - не в setTimeout і не після await. Інакше Vue не знає, до якого компонента їх прив'язати.

Де завантажувати дані:

  • прямо в setup - запит починається якомога раніше, ще до монтування:
const users = ref([]);
fetch('/api/users').then((r) => r.json()).then((data) => (users.value = data));
  • onMounted - якщо для запиту потрібен DOM (розміри елемента) або код не повинен виконуватися при серверному рендері: onMounted на сервері не викликається;
  • watch з immediate: true або watchEffect - якщо дані залежать від props чи параметрів маршруту й мають перезавантажуватися при їх зміні. Найчастіший правильний варіант для сторінок.

Типові помилки:

  • доступ до DOM у setup чи onBeforeMount - елементів ще немає, template ref дорівнює null;
  • не прибрати за собою: обробники на window, таймери, підписки без onUnmounted - витоки пам'яті, особливо в SPA;
  • onUpdated для реакції на дані - для цього є watch; onUpdated спрацьовує на будь-яке оновлення компонента і легко зациклюється, якщо змінює стан;
  • гонитва запитів при швидкій зміні параметрів - потрібне скасування попереднього запиту.

Options API має відповідники (mounted, unmounted...), а також created - його роль у Composition API виконує сам setup.

Докладніше в документації: Хуки життєвого циклу

Атрибути, передані компоненту, але не оголошені як props чи emits, автоматично потрапляють на кореневий елемент компонента:

<!-- BaseButton.vue -->
<template>
  <button class="btn"><slot /></button>
</template>
<BaseButton class="large" id="save" data-test="save-button" @focus="onFocus">Зберегти</BaseButton>

Результат:

<button class="btn large" id="save" data-test="save-button">Зберегти</button>
  • class і style об'єднуються з тими, що вже є на кореневому елементі;
  • обробники подій (@focus) теж прокидаються - як слухачі на кореневому елементі;
  • оголошені props і emits не прокидаються - їх компонент обробляє сам.

inheritAttrs: false + v-bind="$attrs" - коли атрибути мають потрапити не на корінь, а на внутрішній елемент. Класичний приклад - поле введення з обгорткою:

<!-- BaseInput.vue -->
<script setup>
defineOptions({ inheritAttrs: false });
defineProps(['label']);
</script>

<template>
  <label class="field">
    {{ label }}
    <input v-bind="$attrs">
  </label>
</template>
<BaseInput label="Email" type="email" placeholder="you@example.com" required />

type, placeholder, required потрапляють на <input>, а не на <label>.

Компонент з кількома кореневими елементами не має «кореня» - атрибути нікуди не прокидаються автоматично, і Vue виводить попередження. Треба явно вказати v-bind="$attrs" на потрібному елементі.

Доступ у скрипті: useAttrs() у <script setup>. Значення $attrs не реактивне в сенсі watch - для реакції на зміну краще оголосити prop.

Пастки:

  • неоголошена подія прокидається як нативний слухач на корінь. Якщо компонент сам робить emit('click'), а click не оголошено в defineEmits, обробник батька спрацює двічі - від нативного кліку й від emit. Тому події варто оголошувати завжди;
  • class на компоненті з inheritAttrs: false теж не застосується до кореня автоматично - він входить у $attrs.

Докладніше в документації: Прокидання атрибутів

Composable - функція, яка використовує Composition API (ref, computed, watch, хуки життєвого циклу) і повертає стан та методи. Спосіб винести логіку зі стейтом із компонента й перевикористати її.

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

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

  const update = (event) => { x.value = event.pageX; y.value = event.pageY; };

  onMounted(() => window.addEventListener('mousemove', update));
  onUnmounted(() => window.removeEventListener('mousemove', update));

  return { x, y };
}
const { x, y } = useMouse();

Чим це краще за mixins з Vue 2:

  • Зрозуміле джерело. З mixin незрозуміло, звідки в компоненті взялося this.isLoading. З composable видно: const { isLoading } = useFetch(...).
  • Немає конфліктів імен. Два mixin з полем data тихо перезаписують одне одне. Результати composable можна перейменувати при деструктуризації.
  • Параметри. Composable приймає аргументи, зокрема реактивні: useFetch(() => /api/users/${id.value}).
  • Типізація працює природно, бо це звичайні функції.

Правила: назва з use, виклик синхронно в setup (щоб хуки життєвого циклу прив'язалися до компонента), повертати ref-и, щоб деструктуризація не губила реактивність.

Готові composables для типових задач є в бібліотеці VueUse.

Докладніше в документації: Composables

Слот - місце в шаблоні компонента, куди батько вставляє свою розмітку. Компонент задає рамку, а вміст вирішує той, хто його використовує.

<!-- Card.vue -->
<div class="card">
  <header><slot name="title" /></header>
  <slot />                         <!-- слот за замовчуванням -->
</div>
<Card>
  <template #title>Замовлення №42</template>
  <p>Деталі замовлення...</p>
</Card>

Scoped slot - слот, у який компонент передає дані назад батькові. Компонент знає дані, а батько - як їх показати.

<!-- DataTable.vue -->
<tr v-for="row in rows" :key="row.id">
  <slot name="row" :row="row" :selected="selectedId === row.id" />
</tr>
<DataTable :rows="orders">
  <template #row="{ row, selected }">
    <td :class="{ 'font-bold': selected }">{{ row.number }}</td>
    <td>{{ formatMoney(row.total) }}</td>
  </template>
</DataTable>

Навіщо: таблиці, списки, випадаючі меню й інші компоненти з логікою (сортування, вибір, пагінація), але без жорстко заданого вигляду. Це той самий патерн, що render props у React.

Корисне:

  • $slots.title (чи useSlots()) дозволяє перевірити, чи передали слот, і не рендерити порожню обгортку.
  • Вміст слота компілюється в області батька: він бачить змінні батька, а дані дитини - лише ті, що передані через scoped slot.

Докладніше в документації: Scoped slots

Батько отримує доступ до дочірнього компонента через template ref:

<script setup>
import { useTemplateRef } from 'vue';
import VideoPlayer from './VideoPlayer.vue';

const player = useTemplateRef('player');   // Vue 3.5+; раніше - const player = ref(null)

function startOver() {
  player.value?.seek(0);
  player.value?.play();
}
</script>

<template>
  <VideoPlayer ref="player" />
  <button @click="startOver">Спочатку</button>
</template>

Але компоненти з <script setup> закриті за замовчуванням: через ref батько не бачить нічого - ні змінних, ні функцій дитини. Те, що можна викликати ззовні, дитина оголошує явно:

<!-- VideoPlayer.vue -->
<script setup>
import { useTemplateRef } from 'vue';

const video = useTemplateRef('video');

function play() { video.value.play(); }
function seek(seconds) { video.value.currentTime = seconds; }

defineExpose({ play, seek });
</script>

<template>
  <video ref="video" src="/intro.mp4"></video>
</template>

Це навмисне рішення: публічний інтерфейс компонента - props, events, slots і явно відкриті методи, а не будь-яка внутрішня змінна.

Коли виклик методу дитини доречний - імперативні дії, які погано виражаються через стан:

  • фокус на полі (input.focus()), прокрутка до елемента;
  • керування медіа (play, pause, seek);
  • відкриття модального вікна сторонньої бібліотеки, скидання форми.

Коли краще інакше: якщо батько змінює дані дитини - це props чи v-model. Виклик методів для синхронізації стану робить потік даних непрозорим: незрозуміло, хто й коли змінив стан.

Що варто знати:

  • ref заповнюється після монтування - у setup батька він ще null; звертатися в onMounted чи обробниках подій;
  • ref на компонент усередині v-if стає null, коли компонент прибрано, - звідси ?.;
  • ref у v-for дає масив екземплярів;
  • TypeScript: тип відкритого API береться з defineExpose - useTemplateRef<InstanceType<typeof VideoPlayer>>('player') чи автоматичне виведення в Vue 3.5 з Volar.

Докладніше в документації: defineExpose

v-model на компоненті - це скорочення для пари «prop + подія оновлення». З Vue 3.4 для цього є макрос defineModel():

<!-- QuantityInput.vue -->
<script setup>
const quantity = defineModel({ type: Number, default: 1 });
</script>

<template>
  <button type="button" @click="quantity--" :disabled="quantity <= 1">-</button>
  <span>{{ quantity }}</span>
  <button type="button" @click="quantity++">+</button>
</template>
<QuantityInput v-model="item.qty" />

defineModel() повертає ref: читання дає значення від батька, запис - надсилає подію update:modelValue, і батько оновлює свою змінну.

Що генерує компілятор (так писали до 3.4 і це варто розуміти):

const props = defineProps(['modelValue']);
const emit = defineEmits(['update:modelValue']);
// v-model="item.qty" на батьку = :modelValue="item.qty" @update:modelValue="v => item.qty = v"

Кілька v-model на одному компоненті - іменовані моделі:

<script setup>
const firstName = defineModel('firstName');
const lastName = defineModel('lastName');
</script>
<UserName v-model:first-name="user.first" v-model:last-name="user.last" />

Модифікатори (власні чи вбудовані на кшталт .trim) читаються з другого елемента й обробляються в set:

const [title, modifiers] = defineModel({
  set(value) {
    return modifiers.capitalize ? value.charAt(0).toUpperCase() + value.slice(1) : value;
  },
});

Пастки:

  • значення за замовчуванням в defineModel({ default: ... }) і відсутність v-model на батьку: дитина бачить своє значення, а батько - undefined. Стан розсинхронізований;
  • об'єкт у моделі: зміна поля model.value.name = 'x' змінює об'єкт батька напряму, без події. Для об'єктів правильніше присвоювати новий об'єкт (model.value = { ...model.value, name: 'x' }) або мати окремі моделі на поля;
  • для нативного поля всередині - простіше прокинути v-model далі: <input v-model="value">, де value = defineModel().

Докладніше в документації: v-model на компонентах

Компонент повідомляє батька про події через emit. Оголошувати події не обов'язково технічно, але дуже бажано.

<script setup>
const emit = defineEmits(['save', 'cancel']);

function onSubmit() {
  emit('save', { title: title.value });
}
</script>
<PostForm @save="createPost" @cancel="close" />

Чому оголошувати:

  • документація інтерфейсу: видно, які події компонент генерує, - як props для вхідних даних;
  • відсутність подвійних спрацювань: неоголошена подія прокидається як нативний слухач на кореневий елемент. Якщо дитина робить emit('click'), а click не оголошено, обробник батька спрацює і від нативного кліку, і від emit - двічі;
  • TypeScript перевіряє назви й типи параметрів подій.

Валідація параметрів - об'єктний синтаксис:

const emit = defineEmits({
  save: (payload) => {
    if (!payload?.title) {
      console.warn('Подія save без title');
      return false;
    }
    return true;
  },
  cancel: null,   // без перевірки
});

Невдала перевірка лише виводить попередження в режимі розробки - подія все одно відправляється. Це інструмент налагодження, а не захист.

З TypeScript - типова сигнатура, що краще за валідатори:

const emit = defineEmits<{
  save: [payload: { title: string }];
  cancel: [];
}>();

Іменування: у скрипті - camelCase (emit('itemSelected')), у шаблоні батька можна писати kebab-case (@item-selected) - Vue перетворює автоматично.

Чим події компонентів відрізняються від подій DOM:

  • не спливають - подію дитини чує лише її безпосередній батько. Для передачі через кілька рівнів - повторний emit на кожному рівні, provide/inject з функцією або стор;
  • синхронні: обробники батька виконуються в момент emit.

emit у setup поза <script setup> - другий аргумент setup(props, { emit }).

Докладніше в документації: Події компонентів

provide/inject передає значення від предка до будь-якого нащадка в його піддереві без проміжних props:

// Form.vue
provide('form', { errors, register });

// глибоко вкладений FormField.vue
const form = inject('form');

Pinia - глобальний стор: стан, геттери й дії в окремому модулі, доступні з будь-якого компонента застосунку.

provide/inject доречний, коли:

  • стан належить конкретному піддереву, а не всьому застосунку: форма і її поля, таблиця і її колонки, вкладки і їхні панелі;
  • таких піддерев може бути кілька одночасно - у кожної форми свій стан, а глобальний стор для цього незручний;
  • це бібліотека компонентів, яка не повинна залежати від стору застосунку.

Pinia доречна, коли:

  • стан справді глобальний: користувач, кошик, налаштування, кеш даних;
  • потрібні DevTools (історія змін, інспекція стану), SSR-гідратація, плагіни (збереження в localStorage);
  • з даними працюють непов'язані частини інтерфейсу.

Практичні поради:

  • Ключі provide - через Symbol з InjectionKey<T>, а не рядки: без колізій і з типізацією.
  • Передавайте через provide реактивні значення (ref, readonly(ref)) і функції для змін - так нащадки не змінюють стан предка напряму.
  • Не все треба класти в стор. Стан, що використовує один компонент, лишається в ньому. Дані з сервера часто зручніше тримати в бібліотеці запитів (TanStack Query), а не в Pinia.

Докладніше в документації: Provide / Inject

Коли список змінюється, Vue порівнює старий і новий віртуальний DOM і намагається перевикористати наявні елементи. key каже, який новий елемент відповідає якому старому.

<TodoItem v-for="todo in todos" :key="todo.id" :todo="todo" />

Без key або з індексом Vue зіставляє елементи за позицією. Поки список лише дописують у кінець, усе гаразд. Але після видалення, вставки на початок чи сортування:

  • елемент на позиції 0 тепер показує інші дані, але його внутрішній стан лишився від попереднього: введений в <input> текст, розгорнутий блок, локальний стан дочірнього компонента, фокус;
  • анімації <TransitionGroup> рухають не ті елементи;
  • Vue оновлює вміст кожного зсунутого елемента замість того, щоб просто перемістити один.
// Видалили перший елемент: індекси зсунулися,
// і стан TodoItem[0] тепер «належить» колишньому другому
todos.value.splice(0, 1);

Хороший key - стабільний і унікальний серед сусідів: id з бази, стабільний ідентифікатор. Погані: індекс (для списків, що змінюються), Math.random() (новий щоразу - Vue перестворюватиме всі елементи на кожен рендер).

Індекс допустимий, коли список статичний або елементи не мають власного стану й не змінюють порядок.

Корисний прийом: зміна key на одному компоненті примусово його перестворює - простий спосіб «скинути» компонент: <UserForm :key="userId" />.

Докладніше в документації: Збереження стану через key

Шаблони Vue компілюються в рендер-функції - функції, що повертають віртуальні вузли (VNode). Ці функції можна писати й вручну.

import { h, ref } from 'vue';

export default {
  props: { level: { type: Number, default: 2 } },
  setup(props, { slots }) {
    return () => h(`h${props.level}`, { class: 'heading' }, slots.default?.());
  },
};

h(тег або компонент, props/атрибути, діти). З плагіном @vitejs/plugin-vue-jsx те саме можна писати як JSX:

setup(props, { slots }) {
  const Tag = `h${props.level}`;
  return () => <Tag class="heading">{slots.default?.()}</Tag>;
}

Коли рендер-функції справді потрібні:

  • дуже динамічна структура, яку незручно виражати директивами: тег обирається програмно, дерево будується з конфігурації (конструктор форм за JSON-схемою, рендер Markdown-AST у компоненти);
  • компоненти-«обгортки», що маніпулюють слотами: переставити, обгорнути кожен дочірній вузол, вставити роздільники між елементами списку;
  • функціональні компоненти без стану - проста функція (props, { slots }) => h(...);
  • бібліотеки компонентів, де потрібен повний контроль над VNode.

Чому для звичайних компонентів кращі шаблони:

  • оптимізації компілятора. Компілятор шаблонів знає, які частини статичні, а які динамічні: піднімає статичні вузли, позначає динамічні прапорцями (patch flags), будує «блоки» з плоским списком динамічних вузлів. Під час оновлення Vue пропускає все статичне. Рукописна рендер-функція цих підказок не має - порівнюється все дерево;
  • читабельність для команди й дизайнерів, близькість до HTML;
  • інструменти: підсвітка, перевірка типів у шаблонах (Volar), форматування.

Пастки рендер-функцій:

  • VNode не можна використовувати двічі в одному дереві - для повторення треба створювати нові;
  • v-model, v-if, v-for у JSX - це звичайний JavaScript (тернарні оператори, map), а v-model потребує ручного modelValue + onUpdate:modelValue;
  • слоти передаються об'єктом функцій: h(Comp, null, { default: () => ..., header: () => ... }).

Практичне правило: шаблони - за замовчуванням, рендер-функції - точково там, де шаблон стає незручним.

Докладніше в документації: Рендер-функції й JSX

Помилка в рендері, хуку життєвого циклу, спостерігачі чи обробнику події одного компонента без обробки може зламати весь застосунок - користувач побачить «завмерлий» чи порожній екран.

Глобальний обробник - останній рубіж, місце для відправки в моніторинг:

const app = createApp(App);

app.config.errorHandler = (error, instance, info) => {
  // info - де сталася помилка: 'render function', 'mounted hook', 'watcher callback'...
  Sentry.captureException(error, { extra: { info } });
};

Він ловить помилки з рендеру, хуків, спостерігачів, обробників подій шаблону, setup, provide/inject - тобто з коду, який викликає Vue. Помилки з setTimeout, промісів без await і сторонніх колбеків він не бачить - для них window.onerror і unhandledrejection.

onErrorCaptured - перехоплення помилок нащадків у компоненті-предку. Основа для «межі помилок» (error boundary), як у React:

<!-- ErrorBoundary.vue -->
<script setup>
import { ref, onErrorCaptured } from 'vue';

const error = ref(null);

onErrorCaptured((err, instance, info) => {
  error.value = err;
  report(err, info);
  return false;   // не передавати помилку вище
});
</script>

<template>
  <div v-if="error" class="widget-error">
    Блок не завантажився. <button @click="error = null">Спробувати ще</button>
  </div>
  <slot v-else />
</template>
<ErrorBoundary>
  <RevenueChart />
</ErrorBoundary>

Зламаний графік показує запасний вигляд, решта дашборду працює.

Правила поширення:

  • помилка йде вгору ланцюжком батьків, викликаючи кожен onErrorCaptured;
  • return false зупиняє поширення - вище й до app.config.errorHandler вона не дійде;
  • якщо сам onErrorCaptured кидає помилку, вона теж іде до глобального обробника.

Пастки:

  • рендер запасного вигляду не повинен знову рендерити зламаний компонент - інакше нескінченний цикл помилок. Тому v-if/v-else, а не показ поверх;
  • асинхронні помилки в setup після await чи в звичайних промісах можуть не дійти до onErrorCaptured - async-код варто обробляти явно (try/catch);
  • app.config.warnHandler - окремо для попереджень (лише в режимі розробки).

Докладніше в документації: onErrorCaptured

Плагін - об'єкт з методом install(app, options) (або сама функція), який додає щось на рівні всього застосунку. Підключається через app.use():

// plugins/i18n.js
export default {
  install(app, options) {
    const translate = (key) =>
      key.split('.').reduce((obj, part) => obj?.[part], options.messages) ?? key;

    app.provide('i18n', { translate });                     // для Composition API
    app.config.globalProperties.$t = translate;             // для шаблонів і Options API
    app.directive('t', (el, binding) => (el.textContent = translate(binding.value)));
    app.component('LocaleSwitcher', LocaleSwitcher);
  },
};

// main.js
app.use(i18n, { messages: uk });

Що плагін може зареєструвати:

  • глобальні компоненти (app.component) і директиви (app.directive);
  • значення для inject (app.provide) - сервіси, конфігурацію, клієнт API;
  • глобальні властивості (app.config.globalProperties) - доступні в шаблонах як $t, $route;
  • глобальні обробники (app.config.errorHandler), міксини (застарілий підхід).

Так влаштовані Vue Router (app.use(router)), Pinia, бібліотеки UI, i18n.

Як споживачам отримати доступ у <script setup>: не через globalProperties (вони доступні лише в шаблоні й this Options API), а через inject - найкраще обгорнути в composable:

export const I18nKey = Symbol('i18n');
export const useI18n = () => inject(I18nKey);

Символ як ключ - замість рядка, щоб плагіни не перезаписали значення одне одного. З TypeScript - InjectionKey<T> для типізації.

Типізація globalProperties - доповнення модуля:

declare module 'vue' {
  interface ComponentCustomProperties {
    $t: (key: string) => string;
  }
}

Що варто враховувати:

  • повторне app.use() того самого плагіна Vue ігнорує;
  • SSR: застосунок створюється на кожен запит - плагін не повинен тримати стан на рівні модуля, інакше дані одного користувача «протечуть» до іншого. Стан - усередині install (на екземпляр застосунку);
  • порядок має значення: плагін, що використовує роутер, підключається після роутера;
  • глобальні компоненти й директиви з плагіна потрапляють у збірку повністю - для великих бібліотек краще імпорт окремих компонентів.

Докладніше в документації: Плагіни