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

Питання на співбесіді: Модулі й збирання

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

10 питань

ES-модуль - файл JavaScript з власною областю видимості, який явно експортує те, що дає назовні, й імпортує те, що йому потрібно.

// money.js
export function formatPrice(amount) {
  return `${amount.toFixed(2)} грн`;
}

// app.js
import { formatPrice } from './money.js';
console.log(formatPrice(100));
<script type="module" src="/app.js"></script>

Як було до модулів: усі <script> на сторінці ділили одну глобальну область видимості. Функція formatPrice з одного файлу ставала глобальною і могла перезаписати однойменну з іншого. Порядок підключення скриптів доводилося вибудовувати вручну, а залежності між файлами ніде не були записані.

Чим модулі відрізняються:

  • власна область видимості: змінні верхнього рівня модуля не потрапляють у window;
  • явні залежності: з import видно, звідки що береться, - це читає і людина, і збирач;
  • строгий режим завжди ('use strict' не потрібен);
  • виконуються один раз: скільки б модулів не імпортували money.js, він завантажиться й виконається лише раз, а всі отримають той самий екземпляр;
  • відкладене виконання: <script type="module"> поводиться як defer - виконується після розбору HTML і не блокує його;
  • top-level await дозволений;
  • this на верхньому рівні - undefined, а не window;
  • завантаження з іншого домену підпорядковується CORS.

Імпорти статичні: import - лише на верхньому рівні модуля, шлях - рядок-константа. Завдяки цьому залежності відомі ще до виконання коду - звідси можливість tree shaking. Для завантаження «за потреби» є динамічний import().

Модулі в Laravel-проєкті: resources/js/app.js - точка входу ES-модуля. Vite збирає граф імпортів, а директива @vite в Blade підключає результат як <script type="module">. Пакети з npm імпортуються за назвою (import axios from 'axios') - браузер такого шляху не розуміє, тому розв'язує його збирач (або import map без збирача).

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

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

// validation.js
export function isEmail(value) { /* ... */ }
export const MAX_LENGTH = 255;

// інший файл
import { isEmail, MAX_LENGTH } from './validation.js';
import { isEmail as checkEmail } from './validation.js';   // перейменування
import * as validation from './validation.js';              // усе як об'єкт-простір імен

Експорт за замовчуванням - один на модуль, імпортується без дужок і під будь-якою назвою:

// Modal.vue, api.js
export default function createApi(baseUrl) { /* ... */ }

import createApi from './api.js';
import makeClient from './api.js';   // теж працює - та сама функція

Модуль може мати й те, й інше: import axios, { AxiosError } from 'axios'.

Аргументи на користь іменованих експортів:

  • однакові назви всюди: не буває ситуації, коли в одному файлі функцію імпортували як createApi, в іншому як apiFactory, - пошук і рефакторинг простіші;
  • помилка в назві помітна одразу: import { isEmial } - збирач чи редактор скаже, що такого експорту немає. З експортом за замовчуванням будь-яка назва «правильна»;
  • автоімпорт у редакторі працює точніше;
  • tree shaking простіше розібратися з окремими експортами, ніж з одним великим об'єктом за замовчуванням.

Через це багато команд мають правило лінтера «лише іменовані експорти» (import/no-default-export).

Коли експорт за замовчуванням природний:

  • файл = одна сутність: компонент Vue (.vue-файли експортують компонент за замовчуванням), сторінка в Next.js, конфіг (vite.config.js - export default defineConfig(...));
  • вимога інструмента, що шукає саме default.

Реекспорт - для «бочок» (index.js, що збирає експорти папки):

export { isEmail, MAX_LENGTH } from './validation.js';
export { default as Modal } from './Modal.vue';
export * from './formatters.js';

Великі «бочки» мають ціну: імпорт однієї функції з index.js змушує збирач обробити всі реекспортовані модулі, а в режимі розробки - ще й завантажити їх.

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

Браузер уміє виконувати ES-модулі, тож чому не віддати йому файли як є? Тому що код, який зручно писати, і код, який швидко завантажується, - різні.

Що робить збирач під час розробки (Vite npm run dev):

  • розв'язує імпорти пакетів: import axios from 'axios' браузер не розуміє - Vite перетворює на шлях до файлу в node_modules;
  • перетворює те, що браузер не вміє: TypeScript, .vue-файли, JSX, Tailwind і PostCSS;
  • гаряча заміна модулів (HMR): змінили компонент - оновився лише він, без перезавантаження сторінки й зі збереженням стану;
  • віддає модулі браузеру майже без обробки, тож сервер стартує за мілісекунди незалежно від розміру проєкту - у цьому головна перевага Vite над старими збирачами.

Що робить збирач для продакшену (npm run build):

  • об'єднує модулі в невелику кількість файлів - сотні дрібних запитів повільніші за кілька великих;
  • мініфікує: прибирає пробіли й коментарі, скорочує імена змінних;
  • tree shaking: викидає код, який ніде не імпортується;
  • розділення коду: спільні залежності - в окремий файл, сторінки й важкі компоненти - в окремі частини, що завантажуються за потреби;
  • хешовані імена файлів (app-3f9a1c.js): вміст змінився - змінилася назва. Тоді файли можна кешувати в браузері «назавжди», а після деплою користувачі гарантовано отримають нову версію;
  • маніфест - відповідність вихідних файлів і зібраних. Саме з нього директива @vite('resources/js/app.js') у Laravel дізнається, який файл підключити.

У Laravel-проєкті:

  • npm run dev - сервер розробки, @vite підключає файли з нього (адресу записано у public/hot);
  • npm run build - збірка в public/build, @vite бере шляхи з public/build/manifest.json;
  • помилка «Unable to locate file in Vite manifest» означає, що збірку не виконано або файл не вказано в input конфігурації laravel-vite-plugin.

Альтернативи: webpack (старіший, гнучкіший, повільніший), esbuild і Rollup (на яких побудовано Vite), Rolldown (новий швидкий збирач для майбутніх версій Vite). Можна й зовсім без збирача - import maps і нативні модулі, - для невеликих проєктів без TypeScript чи .vue-файлів.

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

Статичний import завантажує модуль одразу разом з тим, хто його імпортує. Динамічний import(path) - це функція-вираз, що завантажує модуль на вимогу і повертає Promise з об'єктом модуля:

button.addEventListener('click', async () => {
  const { default: Chart } = await import('chart.js/auto');
  new Chart(canvas, config);
});

Бібліотека графіків (сотні кілобайтів) завантажиться, лише коли користувач натисне кнопку.

Розділення коду (code splitting). Збирач бачить import() і виносить модуль з його залежностями в окремий файл. Головний бандл стає меншим - сторінка швидше стає інтерактивною.

Типові застосування:

  • маршрути SPA - кожна сторінка своїм файлом:
const routes = [
  { path: '/reports', component: () => import('./pages/Reports.vue') },
];
  • важкі віджети - редактори тексту, карти, графіки, PDF-переглядачі;
  • рідко потрібні функції - експорт у Excel, модальні вікна налаштувань;
  • локалізація - завантажити лише потрібну мову: await import(`./locales/${lang}.json`);
  • поліфіли лише для браузерів, яким вони потрібні.

Особливості:

  • шаблонний шлях (`./locales/${lang}.js`) збирач обробляє, лише якщо частину шляху відомо: він створить окремий файл для кожного відповідного модуля в папці. Повністю динамічний шлях (import(userInput)) зібрати неможливо. У Vite для таких випадків - import.meta.glob;
  • експорт за замовчуванням приходить як властивість default;
  • помилки мережі: динамічний імпорт може не вдатися (офлайн, або після деплою старий файл зник з сервера). Потрібна обробка: повідомлення, повторна спроба, перезавантаження сторінки. Vite у такому разі генерує подію vite:preloadError;
  • кешування: модуль завантажується один раз; повторні import() повертають той самий екземпляр.

Попереднє завантаження. Щоб користувач не чекав на мережу після кліку, частини можна завантажити заздалегідь - при наведенні на посилання чи в простої: <link rel="modulepreload">. Vite сам додає попереднє завантаження залежностей для частин, що імпортуються динамічно.

Не варто дробити надто сильно: десятки дрібних частин - це десятки запитів і затримка «водоспадом», коли одна частина імпортує іншу. Розділяють за межами використання (маршрут, функція), а не кожен компонент окремо.

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

CommonJS - система модулів, з якою Node.js жив до ES-модулів:

// CommonJS
const path = require('node:path');
module.exports = { formatPrice };

// ES-модулі
import path from 'node:path';
export { formatPrice };

Ключові відмінності:

CommonJS ES-модулі
завантаження синхронне, під час виконання асинхронне, граф будується до виконання
require/import звичайна функція, будь-де в коді, шлях може бути змінною лише на верхньому рівні, шлях - константа (крім import())
що імпортується копія значення module.exports на момент виклику живе посилання на експортовану змінну
this на верхньому рівні module.exports undefined
__dirname, __filename є немає: import.meta.dirname, import.meta.filename
top-level await ні так
розширення в шляху можна опустити обов'язкове для відносних шляхів (./utils.js)

Як Node.js вирішує, що це за модуль:

  • .mjs - завжди ES-модуль, .cjs - завжди CommonJS;
  • .js - залежно від поля "type" у найближчому package.json: "module" - ES-модулі, "commonjs" чи відсутність поля - CommonJS.

Laravel-проєкти мають "type": "module" у package.json, тож vite.config.js і скрипти пишуться як ES-модулі.

Взаємодія:

  • ES-модуль може імпортувати CommonJS: module.exports стає експортом за замовчуванням;
  • CommonJS імпортує ES-модуль через require() - з Node.js 22.12 це працює без прапорців, якщо модуль не використовує top-level await. Раніше лишався тільки асинхронний await import();
  • пакети з подвійною збіркою містять обидва варіанти й вибирають потрібний через поле exports у package.json.

Навіщо ES-модулі, якщо CommonJS працював:

  • один стандарт для браузера і сервера;
  • статичний аналіз: збирачі й редактори знають залежності без виконання коду - tree shaking, точний автоімпорт, перевірка неіснуючих експортів;
  • асинхронне завантаження підходить для браузера і дає змогу top-level await.

Типові помилки при переході: require is not defined in ES module scope, __dirname is not defined, Cannot use import statement outside a module - усі означають, що файл інтерпретується не в тій системі модулів, ніж написаний.

Докладніше в документації: Node.js: ES-модулі

Tree shaking - видалення з продакшен-збірки коду, який експортується, але ніде не імпортується. Назва - від образу «струсити дерево залежностей, щоб сухе листя впало».

// utils.js
export function formatPrice() { /* ... */ }
export function parseCsv() { /* 20 КБ коду */ }

// app.js
import { formatPrice } from './utils.js';

У зібраному файлі parseCsv не буде.

Чому це можливо лише з ES-модулями: статичні import/export відомі до виконання - збирач будує граф і бачить, що використовується. require() можна викликати динамічно, з обчисленим шляхом, а module.exports змінювати як завгодно - для CommonJS надійно визначити невикористаний код неможливо.

Чому невикористаний код лишається:

1. Побічні ефекти модуля. Якщо модуль при імпорті щось робить (змінює глобальні об'єкти, реєструє компоненти, додає стилі), збирач не може його викинути, навіть якщо експорти не використовуються:

// side-effects.js
window.analytics = createTracker();   // виконується при імпорті
export const unused = 1;

Пакет може оголосити, що його модулі побічних ефектів не мають: "sideEffects": false у package.json (або список файлів з ефектами - наприклад, CSS).

2. Імпорт «всього». import _ from 'lodash' і виклик _.debounce - збирач не знає, які методи об'єкта будуть потрібні. Краще import { debounce } from 'lodash-es' - пакет у форматі ES-модулів з окремими експортами.

3. Пакет у форматі CommonJS. Більшість збирачів обробляють його як єдине ціле.

4. Класи й об'єкти. Метод класу, який ніхто не викликає, не видаляється: збирач видаляє експорти, а не невикористані частини об'єктів.

5. Виклики на верхньому рівні, результат яких не використовується, але які можуть мати ефект: export const config = createConfig(). Позначка /* @__PURE__ */ перед викликом каже збирачу, що виклик чистий і його можна видалити, якщо результат не потрібен.

Як перевірити, що потрапило в бандл: rollup-plugin-visualizer для Vite показує розмір кожного модуля у збірці. Часто виявляється, що одна іконка тягне всю бібліотеку іконок або ціла локалізація моментів дат потрапила через один імпорт.

Tree shaking працює лише в продакшен-збірці. У режимі розробки Vite віддає модулі як є - розмір там не показовий.

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

Vite читає файли .env і робить змінні доступними в коді через import.meta.env:

# .env Laravel-проєкту
APP_NAME="Laravel Ukraine"
VITE_APP_NAME="${APP_NAME}"
VITE_PUSHER_APP_KEY=abc123
STRIPE_SECRET=sk_live_...
import.meta.env.VITE_APP_NAME;        // 'Laravel Ukraine'
import.meta.env.VITE_PUSHER_APP_KEY;  // 'abc123'
import.meta.env.STRIPE_SECRET;        // undefined - без префікса не потрапляє

Правило префікса: у клієнтський код потрапляють лише змінні з префіксом VITE_. Це захист від випадкового витоку: .env Laravel містить паролі бази й ключі API, і без фільтра вони опинилися б у JavaScript, який завантажує кожен відвідувач.

Вбудовані змінні: import.meta.env.MODE (development/production), DEV, PROD (булеві), BASE_URL, SSR.

Як це працює: під час збирання Vite підставляє значення прямо в код як рядкові константи. Наслідки:

  • змінна з префіксом VITE_ - публічна. Будь-хто відкриє зібраний файл і побачить значення. Туди можна класти лише те, що й так публічне: публічний ключ Pusher/Reverb, ідентифікатор аналітики, URL API;
  • значення фіксуються на момент збирання. Змінити .env на сервері після npm run build - нічого не змінить у вже зібраних файлах. Потрібна нова збірка. Якщо один образ має працювати в різних середовищах, конфігурацію передають з бекенду під час виконання (наприклад, через <meta>-теги чи JSON у Blade);
  • код у гілках if (import.meta.env.DEV) видаляється з продакшен-збірки.

Режими й файли: .env.local (не в Git), .env.production для vite build, .env.staging для vite build --mode staging. Пріоритет - від конкретнішого до загального.

Чому секрет у фронтенді - не секрет взагалі. Мініфікація, обфускація, «приховані» змінні - нічого не захищає від того, хто відкриє DevTools. Якщо фронтенду потрібно звернутися до API з секретним ключем (оплата, OpenAI, SMS), запит іде через власний бекенд: браузер звертається до Laravel, Laravel - до стороннього API з ключем із серверного .env.

TypeScript: типи власних змінних оголошують в env.d.ts (interface ImportMetaEnv { readonly VITE_APP_NAME: string }) - тоді редактор підказує назви й ловить друкарські помилки.

Докладніше в документації: Vite: змінні оточення й режими

Поле exports у package.json визначає, які файли пакета можна імпортувати і який файл віддати залежно від умов (ES-модулі чи CommonJS, браузер чи Node.js, типи TypeScript).

Старий підхід - поле main (одна точка входу) плюс можливість імпортувати будь-який внутрішній файл: import x from 'lib/dist/internal/helpers.js'. Користувачі пакета покладалися на внутрішню структуру, і будь-яке перейменування файлу ламало їхній код.

З exports:

{
  "name": "@acme/ui",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./button": {
      "types": "./dist/button.d.ts",
      "import": "./dist/button.js"
    },
    "./styles.css": "./dist/styles.css",
    "./package.json": "./package.json"
  }
}
  • import '@acme/ui' → dist/index.js, а require('@acme/ui') → dist/index.cjs;
  • import '@acme/ui/button' - дозволена «підточка»;
  • import '@acme/ui/dist/internal.js' → помилка ERR_PACKAGE_PATH_NOT_EXPORTED. Внутрішні файли інкапсульовані.

Умови перевіряються в порядку, в якому записані в об'єкті, - перша відповідна перемагає:

  • types - для TypeScript, завжди першою;
  • import / require - залежно від способу імпорту;
  • browser, node, deno, worker - середовище (збирачі передають browser);
  • development / production - режим (підтримують збирачі);
  • default - запасний варіант, завжди останнім.

Шаблони: "./icons/*": "./dist/icons/*.js" - для пакетів з сотнями файлів.

Поле imports - дзеркальне: внутрішні псевдоніми для коду самого пакета, що починаються з #:

"imports": { "#utils/*": "./src/utils/*.js" }
import { slugify } from '#utils/strings';

Працює без налаштувань збирача - на відміну від псевдонімів @/ у Vite чи TypeScript.

Підводні камені:

  • додавання exports до існуючого пакета - ламаюча зміна: усі глибокі імпорти, які раніше працювали, перестануть. Тому це роблять у мажорній версії;
  • подвійний пакет (dual package hazard): якщо застосунок завантажить і ESM-, і CJS-версію пакета, у пам'яті буде два екземпляри - два різні класи, два синглтони, instanceof між ними не працює;
  • TypeScript читає exports лише з moduleResolution: "node16"/"nodenext"/"bundler". Зі старим node він бачить лише types/main, і помилки типів з'являються лише у споживачів пакета.

Перевірка перед публікацією: інструмент @arethetypeswrong/cli і publint знаходять невідповідності між exports, файлами й типами.

Докладніше в документації: Node.js: точки входу пакетів

Після збирання код у браузері не схожий на написаний: TypeScript перетворено на JavaScript, .vue-файли - на функції рендеру, усе мініфіковано в один рядок з іменами a, b, c. Помилка в продакшені виглядає як TypeError at app-3f9a.js:1:48213.

Source map - файл (app-3f9a.js.map), що зіставляє кожну позицію зібраного коду з файлом, рядком і колонкою вихідного коду, а також з оригінальними іменами змінних. DevTools автоматично його використовують: налагоджувач показує ваш TypeScript, точки зупинки ставляться у вихідних файлах, стеки помилок вказують на справжні рядки.

Зібраний файл посилається на карту коментарем наприкінці:

//# sourceMappingURL=app-3f9a.js.map

Увімкнення у Vite:

// vite.config.js
export default defineConfig({
  build: {
    sourcemap: true,      // файли .map + коментар
    // sourcemap: 'hidden' - файли .map без коментаря в коді
  },
});

У режимі розробки карти генеруються завжди.

Чи публікувати на продакшені - аргументи:

Проти публічних карт:

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

За точні стеки помилок:

  • без карт звіти про помилки з продакшену майже марні.

Компроміс, який використовують найчастіше:

  1. генерувати карти як 'hidden' - файли є, але браузери про них не знають;
  2. завантажувати карти в систему моніторингу помилок (Sentry, Bugsnag, Flare) під час деплою - вона розшифровує стеки на своєму боці;
  3. не публікувати .map-файли на вебсервері (видаляти після завантаження чи забороняти доступ правилом сервера).

Тоді розробники бачать у звітах точні рядки, а відвідувачі - лише мініфікований код.

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

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

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

Живі прив'язки (live bindings). Імпорт у ES-модулях - не копія значення, а посилання на змінну модуля-експортера. Якщо модуль змінить свою змінну, імпортери побачать нове значення:

// counter.js
export let count = 0;
export function increment() { count++; }

// main.js
import { count, increment } from './counter.js';
console.log(count);   // 0
increment();
console.log(count);   // 1 - значення оновилося
count = 5;            // TypeError: Assignment to constant variable

Змінити імпорт ззовні не можна - лише через функції самого модуля. У CommonJS навпаки: const { count } = require('./counter') - копія значення на момент виклику, і increment() її не змінить.

Циклічні імпорти - модуль A імпортує B, а B імпортує A (напряму чи через ланцюжок). ES-модулі це дозволяють, але порядок виконання стає неочевидним:

// a.js
import { b } from './b.js';
export const a = 'A';
console.log('a бачить', b);

// b.js
import { a } from './a.js';
export const b = 'B';
console.log('b бачить', a);   // ReferenceError: Cannot access 'a' before initialization

Запускаємо a.js. Він імпортує b.js - той виконується першим, повністю. На цей момент a.js ще не дійшов до рядка export const a, тож a - у тимчасовій мертвій зоні.

Коли цикл безпечний: якщо звернення до імпорту відбувається пізніше, не під час виконання модуля, а у функції, яку викличуть після завантаження всього графа:

// b.js
import { a } from './a.js';
export function describe() { return `b + ${a}`; }   // a читається при виклику - вже ініціалізовано

Завдяки живим прив'язкам функція побачить значення, щойно його буде встановлено.

Чим небезпечні цикли:

  • помилки залежать від того, який модуль імпортовано першим - змінили точку входу чи порядок імпортів, і код, що працював, падає;
  • з export default class чи const на верхньому рівні - ReferenceError при завантаженні; при наслідуванні class B extends A, де A ще не ініціалізовано, - теж;
  • HMR у Vite у циклічних графах часто перезавантажує всю сторінку замість одного модуля;
  • «бочки» (index.js, що реекспортує все) - типове джерело неявних циклів: компонент імпортує сусіда через index.js, а index.js імпортує сам компонент.

Як лікувати:

  • винести спільний код у третій модуль, від якого залежать обидва;
  • імпортувати напряму з файлу, а не через «бочку»;
  • передавати залежність параметром замість імпорту;
  • знаходити цикли інструментами: madge --circular, правило ESLint import/no-cycle.

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