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

Питання на співбесіді з Vue

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

108 питань

Кожен computed, watch і watchEffect, створений у setup компонента, Vue автоматично прив'язує до компонента і зупиняє, коли компонент знищується. Поза компонентом такої прив'язки немає - ефекти живуть, доки їх не зупинити вручну.

effectScope збирає всі ефекти, створені всередині, в одну групу, яку можна зупинити одним викликом:

import { effectScope, ref, watch, computed } from 'vue';

const scope = effectScope();

scope.run(() => {
  const query = ref('');
  const results = computed(() => search(query.value));

  watch(query, (value) => saveToHistory(value));
  watchEffect(() => console.log(results.value.length));
});

// пізніше - зупинити все разом
scope.stop();

Де це потрібно:

1. Спільний стан composable для кількох компонентів («спільний composable»): ефекти мають жити, поки є хоча б один споживач, і зупинитися, коли зник останній:

function createSharedComposable(factory) {
  let consumers = 0;
  let state, scope;

  return () => {
    consumers++;
    if (!state) {
      scope = effectScope(true);   // detached: не прив'язаний до поточного компонента
      state = scope.run(factory);
    }
    onScopeDispose(() => {
      if (--consumers === 0) {
        scope.stop();
        state = scope = undefined;
      }
    });
    return state;
  };
}

Саме так влаштований createSharedComposable у VueUse.

2. Бібліотеки й сховища. Pinia створює effectScope для кожного стора - тому $dispose() прибирає всі його спостерігачі.

3. Тести - ізолювати й гарантовано прибрати ефекти між тестами.

4. Тимчасові ефекти - наприклад, на час відкритого модального вікна чи активного режиму редагування.

onScopeDispose(fn) - реєструє очищення для поточної області: в компоненті спрацює при його знищенні, в effectScope - при stop(). Тому composable, що використовує onScopeDispose замість onUnmounted, працює і в компонентах, і поза ними.

getCurrentScope() - дізнатися, чи код виконується всередині області (наприклад, щоб composable попередив, що його викликали поза компонентом).

Без цього composable, викликаний поза setup (у сторі, в роутері, в модулі), створює спостерігачів, які ніколи не зупиняться, - витоки пам'яті в довгоживучих SPA.

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

customRef дає змогу створити ref з власною логікою відстеження й сповіщення про зміни. Фабрика отримує дві функції:

  • track() - зареєструвати залежність (викликати при читанні);
  • trigger() - сповістити залежних про зміну (викликати, коли значення справді змінилося).

Класичний приклад - ref із затримкою (debounce):

import { customRef } from 'vue';

export function useDebouncedRef(initial, delay = 300) {
  let value = initial;
  let timeout;

  return customRef((track, trigger) => ({
    get() {
      track();
      return value;
    },
    set(newValue) {
      clearTimeout(timeout);
      timeout = setTimeout(() => {
        value = newValue;
        trigger();
      }, delay);
    },
  }));
}
<script setup>
const query = useDebouncedRef('', 400);

watch(query, (value) => search(value));   // запит лише після паузи у введенні
</script>

<template>
  <input v-model="query">
</template>

Поле оновлюється в шаблоні... тільки після затримки - бо значення змінюється в set із запізненням. Для пошуку це прийнятно, а для звичайного введення - ні: користувач не бачитиме набраного тексту. Тому частіше роблять інакше: звичайний ref для поля і окремий debounced-ref (чи watch з затримкою) для запиту.

Інші застосування customRef:

  • ref, синхронізований з localStorage - читання з кешу, запис у сховище;
  • ref над зовнішнім джерелом (значення з бібліотеки, що має власні події змін) - trigger() з обробника події бібліотеки;
  • валідація чи нормалізація при записі (обрізати пробіли, обмежити діапазон);
  • лінива ініціалізація дорогого значення при першому читанні.

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

  • get має повертати однакове значення для однакового стану - Vue може викликати його багато разів;
  • не забувати track() - без нього шаблон не оновиться, хоча trigger() викликано;
  • документація окремо застерігає від get, що щоразу створює новий об'єкт: якщо такий ref передано в props, кожен рендер батька дає дочірньому компоненту «нове» значення, і той перерендерюється без потреби;
  • готові варіанти вже є у VueUse: refDebounced, useLocalStorage, refThrottled.

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

reactive() повертає проксі над оригінальним об'єктом, а не сам об'єкт:

const raw = { a: 1 };
const state = reactive(raw);

state === raw;            // false
toRaw(state) === raw;     // true
reactive(raw) === state;  // true - для одного оригіналу Vue повертає той самий проксі

Зміни через проксі змінюють оригінал (і запускають оновлення), а зміни оригіналу напряму - не запускають нічого.

Де це вилазить:

  • порівняння й пошук: items.value.indexOf(obj) чи Set.has(obj) з оригінальним об'єктом, коли в масиві лежать проксі (або навпаки), дають «не знайдено». Vue перехоплює indexOf/includes у реактивних масивах, щоб пошук сирого об'єкта працював, але в Map, Set і власному коді порівняння за посиланням легко помилитися;
  • передача в сторонні бібліотеки: бібліотека отримує проксі. Бібліотеки, що порівнюють об'єкти за посиланням, змінюють їх інтенсивно чи покладаються на приватні поля (#field), можуть працювати неправильно або повільно;
  • класи з приватними полями: метод, викликаний через проксі, отримує this = проксі, а в проксі немає #field - TypeError;
  • structuredClone(state) падає на проксі - потрібно structuredClone(toRaw(state)).

toRaw(proxy) - отримати оригінал. Корисно для:

  • передачі даних у сторонні бібліотеки, postMessage, IndexedDB, structuredClone;
  • тимчасового читання/запису без відстеження й оновлень (масові зміни, після яких одне оновлення).

markRaw(obj) - позначити об'єкт як ніколи не реактивний: Vue не обгортатиме його в проксі навіть усередині реактивного стану.

const map = markRaw(new mapboxgl.Map({ container: 'map' }));
state.map = map;   // лишається звичайним об'єктом

Застосовують для екземплярів сторонніх бібліотек (карти, редактори, графіки), складних класів, великих незмінних структур.

shallowRef / shallowReactive - реактивність лише на верхньому рівні: заміна значення відстежується, зміни всередині - ні. Добре для великих даних, що замінюються цілком.

Застереження: toRaw і markRaw - інструменти для конкретних проблем. Змішування сирих і реактивних версій того самого об'єкта в коді - джерело помилок «чому не оновилося». Документація Vue радить тримати стабільну межу: або завжди проксі, або завжди сирий.

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

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

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

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

У <script setup> будь-яка змінна з назвою vЩось стає директивою v-щось:

<script setup>
const vFocus = {
  mounted: (el) => el.focus(),
};
</script>

<template>
  <input v-focus>
</template>

Повний набір хуків (усі необов'язкові):

const vClickOutside = {
  mounted(el, binding) {
    el._onClick = (event) => {
      if (!el.contains(event.target)) binding.value(event);
    };
    document.addEventListener('click', el._onClick);
  },
  unmounted(el) {
    document.removeEventListener('click', el._onClick);
  },
};
<div v-click-outside="closeMenu">...</div>

Хуки: created, beforeMount, mounted, beforeUpdate, updated, beforeUnmount, unmounted. Аргумент binding містить value, oldValue, arg (v-tooltip:top → 'top') і modifiers (v-tooltip.delay → { delay: true }).

Скорочена форма - функція замість об'єкта: викликається в mounted і updated.

Глобальна реєстрація - app.directive('focus', vFocus).

Коли директива - правильний вибір, а коли ні:

  • так: логіка прив'язана до конкретного DOM-елемента й не має власного шаблону чи складного стану;
  • ні - краще компонент: є розмітка (підказка з вмістом, випадне меню);
  • ні - краще composable: логіка зі станом, яку зручно використати в setup (useClickOutside(target, handler), useIntersectionObserver). Composable краще типізується й тестується;
  • ні - краще вбудовані директиви: документація радить декларативні v-bind, v-show там, де можливо, - вони ефективніші й дружні до SSR.

Пастки:

  • на компонентах директиви не рекомендовані: застосовуються до кореневого елемента, а на компоненті з кількома коренями ігноруються з попередженням (на відміну від атрибутів, їх не передати через $attrs);
  • аргументи хуків - лише для читання, крім el. Спільні дані між хуками - через el.dataset чи WeakMap, а не довільні властивості на елементі (як el._onClick у прикладі - працює, але це компроміс);
  • прибирання в unmounted обов'язкове для глобальних слухачів і спостерігачів;
  • SSR: хуки директив на сервері не викликаються (крім getSSRProps для атрибутів).

Докладніше в документації: Власні директиви

За замовчуванням Vue екранує дані: інтерполяція {{ }} і прив'язки атрибутів вставляють значення як текст, а не як HTML.

<p>{{ comment.body }}</p>               <!-- <script> покажеться як текст -->
<a :title="comment.author">...</a>      <!-- значення атрибута екрановано -->

Де захист закінчується:

1. v-html вставляє рядок як HTML:

<div v-html="comment.body"></div>   <!-- XSS, якщо body від користувача -->

Для контенту від користувачів - лише після санітизації (DOMPurify на клієнті чи HTML Purifier на сервері), а краще - зберігати Markdown і рендерити безпечним рендерером з білим списком тегів.

2. URL у атрибутах: екранування не захищає від схеми javascript::

<a :href="user.website">Сайт</a>   <!-- javascript:alert(1) виконається при кліку -->

Перевіряйте протокол (http:/https:) перед виведенням посилань з даних користувача.

3. :style з даних користувача - можливі атаки через CSS (підміна інтерфейсу, витік через url()).

4. Шаблон з даних користувача. Найнебезпечніший випадок - компіляція рядка, що містить ввід користувача, як шаблону Vue:

createApp({ template: `<div>${userInput}</div>` });   // виконання довільних виразів

Шаблон Vue - це код. Будь-що, що компілюється як шаблон, має повністю контролюватися розробником.

5. Серверний рендер + Vue в браузері (Blade, Laravel). Якщо Vue монтується на розмітку, яку сервер згенерував з даними користувача (in-DOM шаблон), то {{ }} у даних користувача Vue виконає як вираз. Користувач пише коментар {{ constructor.constructor('alert(1)')() }} - Blade екранує HTML, але не фігурні дужки, і Vue їх обчислить. Захист: не монтувати Vue на області з користувацьким вмістом, позначати такі ділянки v-pre, передавати дані через props/JSON, а не через розмітку.

Вирази шаблонів у «пісочниці»: у шаблоні доступний лише обмежений набір глобальних об'єктів (Math, Date тощо), а не window. Але це не межа безпеки - через ланцюжок конструкторів можна дістатися до Function, тому вимога «шаблони лише від розробника» лишається.

Що ще: Content Security Policy знижує наслідки XSS. Повна збірка Vue з компілятором шаблонів у браузері потребує unsafe-eval; збірка лише з runtime (шаблони скомпільовані Vite) - ні.

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

Три етапи роботи Vue:

  1. компіляція - шаблон перетворюється на рендер-функцію (зазвичай під час збирання, через Vite і @vitejs/plugin-vue);
  2. монтування - рендер-функція повертає дерево віртуальних вузлів (VNode), з якого створюється DOM. Під час виконання реактивна система запам'ятовує, які дані читалися;
  3. оновлення (patch) - змінилися дані - рендер-функція виконується знову, нове віртуальне дерево порівнюється зі старим, і в DOM застосовуються лише відмінності.

Чому шаблон - це не просто «зручний синтаксис». Компілятор бачить структуру шаблону цілком і додає підказки, яких немає в рукописній рендер-функції. Vue називає це «віртуальний DOM з підказками компілятора»:

  • піднімання статичного (static hoisting): вузли без динамічних прив'язок створюються один раз і використовуються в усіх рендерах. Великі статичні фрагменти можуть бути перетворені на рядок HTML;
  • прапорці оновлення (patch flags): на кожен динамічний вузол компілятор записує, що саме в ньому може змінитися - лише текст, лише class, лише конкретні props. При оновленні Vue перевіряє тільки це;
  • блоки й «сплощення дерева» (tree flattening): кожен блок зберігає плоский список своїх динамічних нащадків. При оновленні Vue проходить лише цей список, а не все дерево. Межі блоків - структурні директиви (v-if, v-for), де форма дерева може змінитися.

Результат: вартість оновлення залежить від кількості динамічних частин, а не від розміру шаблону.

Можна подивитися самому: Vue Template Explorer показує, у що компілюється шаблон, з підсвіченими прапорцями.

Дві збірки Vue:

  • runtime-only - без компілятора (менша й без eval). Підходить, коли всі шаблони - .vue-файли, скомпільовані Vite;
  • повна збірка (vue/dist/vue.esm-bundler.js з компілятором) - потрібна, якщо шаблон компілюється в браузері: опція template у компоненті чи in-DOM шаблон (Vue монтується на розмітку, згенеровану Blade). Вона більша й потребує unsafe-eval у CSP.

Практичні висновки:

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

Докладніше в документації: Механізм рендерингу

Vue можна використовувати не лише як SPA, а й «острівцями» на серверних сторінках Laravel: змонтувати компонент на елемент Blade-шаблону. Тут є кілька пасток.

1. Конфлікт фігурних дужок. Blade і Vue обидва використовують {{ }}. Blade обробить їх першим і спробує виконати PHP:

<div id="app">
    @{{ message }}          {{-- @ - Blade лишить {{ message }} для Vue --}}

    @verbatim
        <p>{{ user.name }}</p>   {{-- цілий блок без обробки Blade --}}
    @endverbatim
</div>

2. Шаблони в HTML (in-DOM) розбирає браузер, а не компілятор Vue:

  • регістр не враховується: <BlogPost :postTitle="t"> браузер перетворить на <blogpost :posttitle="t">. У HTML-шаблонах - лише kebab-case: <blog-post :post-title="t">;
  • самозакривних тегів немає: <my-component /> браузер прочитає як відкритий тег, і наступні елементи стануть його дітьми. Потрібно <my-component></my-component>;
  • обмеження вкладеності: у <table>, <ul>, <select> браузер викидає «чужі» теги. Компонент-рядок таблиці - через <tr is="vue:table-row">.

У .vue-файлах цих обмежень немає - їх компілює Vite.

3. Потрібна повна збірка Vue з компілятором шаблонів, якщо шаблон береться з DOM сторінки: псевдонім vue → vue/dist/vue.esm-bundler.js. Збірка більша і вимагає unsafe-eval у Content Security Policy.

4. Безпека - найважливіше. Якщо в області, на яку змонтовано Vue, є дані користувача, виведені Blade, Vue скомпілює їх як частину шаблону. Коментар із текстом {{ ... }} стане виразом Vue - Blade екранує HTML, але не фігурні дужки. Захист:

  • не монтувати Vue на області з користувацьким вмістом;
  • позначати такі ділянки v-pre (Vue не компілюватиме вміст);
  • передавати дані в компоненти через props з JSON, а не вбудовувати в розмітку.

5. Передача даних з Laravel у компонент:

<user-profile :user='@json($user)'></user-profile>
{{-- або --}}
<script>window.App = @js(['user' => $user->only('id', 'name')]);</script>

@json/Js::from безпечно екранують значення. Передавайте лише потрібні поля - усе, що в JSON, видно в коді сторінки.

Альтернативи «острівцям»: Inertia (повноцінні Vue-сторінки з маршрутизацією Laravel) або Livewire з Alpine, якщо інтерактивність помірна.

Докладніше в документації: Особливості in-DOM шаблонів

Плагін Pinia - функція, що викликається для кожного стору при його створенні й може додавати властивості, обгортати дії, підписуватися на зміни.

const pinia = createPinia();

pinia.use(({ store, options }) => {
  // додати властивість усім сторам
  store.createdAt = new Date();

  // логувати всі дії
  store.$onAction(({ name, args, after, onError }) => {
    const start = performance.now();
    after(() => console.debug(`${store.$id}.${name}`, performance.now() - start));
    onError((error) => reportError(error, { store: store.$id, action: name }));
  });
});

Плагін отримує store, pinia, app і options - опції, передані в defineStore. Через власні опції стор може «вмикати» поведінку плагіна.

Збереження стану між перезавантаженнями - мінімальний плагін:

pinia.use(({ store, options }) => {
  if (!options.persist) return;

  const key = `pinia:${store.$id}`;
  const saved = localStorage.getItem(key);
  if (saved) store.$patch(JSON.parse(saved));

  store.$subscribe((_, state) => {
    localStorage.setItem(key, JSON.stringify(state));
  }, { detached: true });
});

export const useSettingsStore = defineStore('settings', {
  state: () => ({ theme: 'light', perPage: 20 }),
  persist: true,
});

На практиці частіше беруть готовий pinia-plugin-persistedstate - він уміє вибирати поля, сховище і серіалізацію.

Що враховувати при збереженні:

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

TypeScript: власні властивості й опції плагіна оголошуються розширенням модулів PiniaCustomProperties і DefineStoreOptionsBase.

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

useStore() усередині setup знаходить активний екземпляр Pinia через inject. Поза компонентом (у guard роутера, інтерсепторі HTTP-клієнта, звичайному модулі) інжекту немає, і Pinia покладається на «активний» екземпляр, встановлений app.use(pinia).

Типова помилка - виклик на рівні модуля:

// api.js
const auth = useAuthStore();   // виконується при імпорті - ДО app.use(pinia)
// Error: getActivePinia() was called but there was no active Pinia

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

router.beforeEach((to) => {
  const auth = useAuthStore();   // на момент навігації Pinia вже встановлена
  if (to.meta.requiresAuth && !auth.isLoggedIn) return '/login';
});

http.interceptors.request.use((config) => {
  const auth = useAuthStore();
  if (auth.token) config.headers.Authorization = `Bearer ${auth.token}`;
  return config;
});

Чому це критично в SSR. На сервері Node.js обслуговує багато запитів одним процесом. Якщо стор створюється на рівні модуля чи покладається на глобальний «активний» екземпляр, стан одного користувача може потрапити в запит іншого: кошик, профіль, токен.

Правила для SSR:

  • новий екземпляр Pinia (і роутера, і застосунку) на кожен запит - у фабричній функції createApp();
  • поза setup передавати pinia явно:
router.beforeEach((to) => {
  const auth = useAuthStore(pinia);   // саме той екземпляр, що належить цьому запиту
});
  • жодного стану на рівні модуля (let currentUser в імпортованому файлі) - він спільний для всіх запитів процесу.

Ще одна пастка - стори, що використовують один одного. Виклик useOtherStore() у тілі setup store - нормально, але циклічне читання стану двох сторів одне з одного під час створення призводить до помилок. Доступ до іншого стору краще робити в гетерах і діях, а не на верхньому рівні.

Тестування: у юніт-тестах сторів перед кожним тестом - setActivePinia(createPinia()), щоб тести не ділили стан.

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

При серверному рендерингу сервер заповнює стори (завантажує дані), рендерить HTML і має передати цей стан у браузер. Інакше на клієнті стори стартують порожніми, Vue перемалює сторінку іншими даними, і гідрація зламається.

Схема:

  1. на сервері після рендеру взяти весь стан: pinia.state.value;
  2. серіалізувати з екрануванням і вставити в HTML;
  3. на клієнті до першого useStore() відновити його.
// сервер
import { uneval } from 'devalue';

const html = await renderToString(app);
const state = uneval(pinia.state.value);
// <script>window.__pinia = ${state}</script>
// клієнт - перед монтуванням і до будь-якого useStore()
const pinia = createPinia();
app.use(pinia);
if (window.__pinia) {
  pinia.state.value = window.__pinia;
}

Чому JSON.stringify - діра. Стан майже завжди містить дані, які вводили користувачі (ім'я, коментар, назва товару). Рядок </script><script>stealCookies()</script> у полі name, вставлений через JSON.stringify у <script>, закриє тег і виконає чужий код. Документація Pinia прямо наголошує: стан треба екранувати, наприклад бібліотекою devalue (її використовує Nuxt), яка ще й зберігає Date, Map, Set.

Що не повинно гідруватися. У setup store можуть бути значення, що мають братися лише з браузера (наприклад, useLocalStorage). Їх позначають skipHydrate(), щоб серверне значення не затерло клієнтське.

Інші правила SSR зі сторами:

  • новий екземпляр Pinia на кожен запит - інакше стан одного користувача потрапить у відповідь іншому;
  • поза setup (guard роутера, serverPrefetch) передавати екземпляр явно: useStore(pinia);
  • не класти в стан секрети: усе, що в сторі на сервері, буде у HTML сторінки;
  • браузерні API (window, localStorage) у сторах - лише в діях, що викликаються в браузері, чи з перевіркою середовища.

У Nuxt усе це зроблено модулем @pinia/nuxt - вручну налаштовувати не треба.

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

Питання з реальних технічних співбесід - 108 питань у 9 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 36 Middle 37 Senior 35

Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії