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

Senior: питання на співбесіді з теми «Компоненти й Composition API»

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

5 питань

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 (на екземпляр застосунку);
  • порядок має значення: плагін, що використовує роутер, підключається після роутера;
  • глобальні компоненти й директиви з плагіна потрапляють у збірку повністю - для великих бібліотек краще імпорт окремих компонентів.

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