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

Middle: питання на співбесіді з теми «Функції й замикання»

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

4 питання

У звичайній функції this визначається в момент виклику, а не там, де функцію написано:

  • obj.method() - this це obj;
  • просто fn() - undefined у строгому режимі (у модулях і класах він завжди строгий), глобальний об'єкт - у нестрогому;
  • new Fn() - новий об'єкт;
  • fn.call(ctx), fn.apply(ctx), fn.bind(ctx) - явно заданий ctx;
  • в обробнику addEventListener - елемент, на якому висить обробник.

Типова пастка - метод, переданий як колбек:

class Timer {
  seconds = 0;
  tick() { this.seconds++; }
}

const timer = new Timer();
setInterval(timer.tick, 1000); // this === undefined -> TypeError

Метод «відірвався» від об'єкта, і викликається вже як просто функція.

Стрілкова функція не має власного this - вона бере this з місця, де її створено (лексично), і змінити його не можна навіть через call чи bind:

setInterval(() => timer.tick(), 1000);   // працює
// або поле-стрілка в класі:
class Timer2 { seconds = 0; tick = () => { this.seconds++; }; }

Ще відмінності стрілкових функцій: немає власного arguments, їх не можна викликати через new, вони не мають prototype.

Коли НЕ стрілка: методи об'єкта, яким потрібен this цього об'єкта ({ name: 'x', greet: () => this.name } не працює), і обробники, де потрібен this як елемент.

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

Усі три дозволяють явно задати this для звичайної функції:

  • fn.call(ctx, a, b) - викликає функцію одразу, аргументи передаються через кому.
  • fn.apply(ctx, [a, b]) - те саме, але аргументи - масивом.
  • fn.bind(ctx, a) - не викликає, а повертає нову функцію з назавжди прив'язаним this (і, за бажанням, частиною аргументів).
function greet(greeting, punctuation) {
  return `${greeting}, ${this.name}${punctuation}`;
}

const user = { name: 'Оля' };

greet.call(user, 'Привіт', '!');          // 'Привіт, Оля!'
greet.apply(user, ['Вітаю', '.']);         // 'Вітаю, Оля.'

const hello = greet.bind(user, 'Привіт');
hello('?');                                // 'Привіт, Оля?'

Де це трапляється:

  • bind - щоб передати метод як колбек, не загубивши this: button.addEventListener('click', this.onClick.bind(this)). Сьогодні частіше пишуть стрілкову функцію.
  • Часткове застосування: const double = multiply.bind(null, 2).
  • apply історично використовували, щоб передати масив як аргументи: Math.max.apply(null, numbers). Тепер є spread: Math.max(...numbers).
  • Позичання методів: Array.prototype.slice.call(arguments) - тепер замість цього Array.from().

Пастки: функцію, прив'язану через bind, повторний bind уже не перевизначить. А на стрілкові функції call, apply і bind щодо this не впливають узагалі.

Докладніше в документації: Function.prototype.bind

IIFE (immediately invoked function expression) - функція, яку оголошують і одразу викликають:

(function () {
  const secret = 'видно лише тут';
  // ...
})();

(() => {
  // те саме стрілковою функцією
})();

Дужки навколо функції потрібні, щоб рушій сприйняв її як вираз, а не як оголошення функції (яке не можна одразу викликати).

Навіщо їх використовували:

  • ізоляція області видимості. До ES-модулів усі скрипти на сторінці ділили одну глобальну область. Змінні з var на верхньому рівні ставали властивостями window і конфліктували між бібліотеками. IIFE створювала приватну область;
  • шаблон «модуль» - приватний стан плюс публічний інтерфейс:
const counter = (function () {
  let count = 0;
  return {
    increment: () => ++count,
    current: () => count,
  };
})();
  • копія значення в циклі з var - до появи let кожна ітерація ділила одну змінну.

Що змінилося:

  • ES-модулі мають власну область видимості: змінні верхнього рівня модуля не глобальні. Vite, вбудовані <script type="module"> - IIFE для ізоляції не потрібні;
  • let і const мають блокову область видимості - досить фігурних дужок;
  • замикання для приватного стану тепер часто замінюють приватні поля класів (#count).

Де IIFE досі трапляються:

  • асинхронний код на верхньому рівні звичайного скрипта (не модуля), де немає top-level await:
(async () => {
  const config = await loadConfig();
  start(config);
})();
  • результат збирання. Збирачі пакують код для старих оточень і бібліотеки у формат IIFE (наприклад, format: 'iife' у Rollup/Vite) - так вони підключаються одним <script> без конфліктів;
  • ініціалізація константи складною логікою в одному виразі (хоча зазвичай читабельніша окрема функція).

Пастка: рядок перед IIFE без крапки з комою. const a = b і на наступному рядку (function () {})() рушій прочитає як виклик b(...). Звідси звичка ставити ; перед IIFE у коді без крапок з комою.

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

Змінні let, const (і класи) теж «піднімаються» на початок своєї області видимості, але не ініціалізуються. Від початку блоку до рядка оголошення змінна існує, але звертання до неї кидає ReferenceError. Цей проміжок називають тимчасовою мертвою зоною (temporal dead zone).

{
  console.log(price);   // ReferenceError: Cannot access 'price' before initialization
  let price = 100;
}

З var поведінка інша - змінна ініціалізується як undefined, і помилка тихо проходить:

console.log(total);   // undefined
var total = 100;

Чому це «тимчасова», а не «просторова» зона. Важливий момент виконання, а не позиція в тексті:

function showPrice() {
  console.log(price);   // функцію оголошено вище за let...
}
let price = 100;
showPrice();            // ...але викликано після ініціалізації - працює

Неочевидні випадки:

  • typeof не рятує. Для неоголошеної змінної typeof x повертає 'undefined', але для змінної в TDZ - кидає ReferenceError;
  • затінення:
const limit = 10;
function check() {
  console.log(limit);   // ReferenceError: внутрішня limit у TDZ
  const limit = 20;
}

Внутрішнє оголошення «закриває» зовнішнє для всього блоку, а не лише від свого рядка;

  • параметри за замовчуванням: function f(a = b, b = 1) {} - при виклику f() параметр b ще в TDZ;
  • класи: new User() до рядка class User {} теж кидає помилку, на відміну від функцій.

Навіщо це зроблено так:

  • помилка доступу до неініціалізованої змінної видна одразу, а не перетворюється на загадкове undefined;
  • const повинна мати рівно одне значення - побачити її «до» присвоєння було б порушенням цього правила.

Практичний висновок: оголошувати змінні до використання, на початку блоку чи безпосередньо перед ним. Помилки TDZ найчастіше трапляються при циклічних імпортах модулів: модуль звертається до експортованого const іншого модуля, який ще не встиг виконатися.

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