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

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

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

3 питання

Поле 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