Головна / Статті

Закриття, підйом та це: правила JavaScript, які спричиняють помилки

Підйом змінних, замикання та інші аспекти JavaScript, пояснені через помилки, які ускладнюють їх розуміння: помилки TDZ, спільні прив’язки змінних у циклах та відокремлені методи.

Видавець: REACTAPP.TOP5 хв читання1108 слів

Джерела: Closures, Hoisting, and `this`: The JavaScript Concepts That Finally Clicked for Me

Замикання, підйом та this: концепції JavaScript, які постійно з’являються

Багато розробників протягом місяців працюють над повнофункціональним JavaScript-кодом, все ще вважаючи три аспекти мови дрібницями під час співбесід: підйом, замикання та this. Запам’ятовані визначення можуть допомогти пройти сортування кандидатів. Однак помилки в продакшені розкривають ці прогалини — таймаути, які фіксують неправильний індекс, обробники, які втрачають свою інстанцію, змінні, які мають значення undefined замість того, щоб викидати помилку. Ціна цього зазвичай — час: нескінченне переглядання коду, який здається правильним, поки правила місця виклику нарешті не пояснять ситуацію.

Нотатки нижче не є глосарієм. Вони детально описують моменти, коли ці три ідеї перестають бути абстрактними та починають пояснювати реальні проблеми. Кожен розділ починається з невеликої програми, яка створює сюрприз, а потім називає правило, яке робить цей сюрприз неминучим.

Підйом

Перше здивування виглядає так:

console.log(greet()); // "Hello!"
function greet() {
  return "Hello!";
}

Функція розташована вище за свою декларацію, проте повертає "Hello!". Загальноприйнята логіка підказує, що це має бути неможливим. Декларації функцій відрізняються: перед виконанням операторів движок піднімає всю декларацію — включаючи її тіло — тому greet вже може викликатися.

Схожий приклад поводиться інакше:

console.log(name); // undefined
var name = "Prashant";

Не виникає помилки ReferenceError, лише значення undefined. Движок знав про існування змінної name ще на початку, але присвоєння ще не виконувалося. Обидва випадки мають спільний механізм: декларації піднімаються на вершину свого області видимості. Декларації функцій разом із своїм тілом. За допомогою var піднімається лише зв’язок із змінною; ініціалізатор залишається там, де був написаний, тому початкове значення є порожнім.

Зміна зв’язку на let змінює спосіб виникнення помилок:

console.log(name); // ReferenceError: Cannot access 'name' before initialization
let name = "Prashant";

Це призводить до виникнення помилки. Поширеним уявленням є твердження, що let “не піднімає” змінні. Це некоректно. Зв’язок створюється рано, а потім залишається недоступним у тимчасовій “мертвій зоні” до моменту оголошення. Ім’я змінної існує; проте її використання до ініціалізації є незаконним. Такий стандарт є безпечнішим, ніж var, який тихо присвоює значення undefined та дозволяє помилці поширюватися далі.

Замикання

Часто проблеми, пов’язані з замиканнями, виникають під час виконання циклів, а не через класичні синтаксичні помилки. Розгляньмо приклад з запізнілим записом у журнал, який має вивести 1, 2, 3:

for (var i = 1; i <= 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}
// what I expected: 1, 2, 3
// what I got: 4, 4, 4

Очікується послідовні числа. Результат — 4, 4, 4. Змінна var має область видимості на рівні функції, а не блоку, тому цикл користується однією змінною i. Коли таймери спрацьовують після завершення циклу, ця спільна змінна вже має значення 4. Кожен калебек читає це саме значення.

Є одне ключове слово, яке вирішує цю проблему:

for (let i = 1; i <= 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}
// 1, 2, 3

let має область видимості на рівні блоку, тому кожна ітерація має власну змінну i. Кожен калебек працює зі своєю власною копією цієї змінної.

Це поєднання — функція та збережені змінні — є практичним визначенням замикання. Калебек setTimeout — це не лише функція; вона також містить середовище, необхідне їй після закінчення області видимості циклу. Як тільки ви очікуєте такого збереження, результат 4, 4, 4 вже не здається багом платформи, а стає ознакою спільного змінного стану.

Як тільки вони стають видимими, закриті функції з’являються у звичайному коді React та Node: хуки, які повертають функції, обробники подій, що читають стан компонента, та майже кожен оновлювач useState. Це стосується не лише інтерв’ю; саме так працює значна частина коду інтерфейсу, заснованого на калебеках.

this

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

Приклад класового лічильника ілюструє цю проблему:

class Counter {
  constructor() {
    this.count = 0;
  }
  increment() {
    this.count++;
    console.log(this.count);
  }
}const counter = new Counter();
const btn = document.querySelector("button");btn.addEventListener("click", counter.increment); // this is undefined

Виклик методу increment через екземпляр працює. Передача counter.increment безпосередньо до addEventListener часто зазнає невдачі через те, що значення this усередині методу дорівнює undefined. Від’єднання методу призводить до простого виклику функції; у суворому режимі такий виклик не отримує початкового об’єкта як this.

Два способи вирішення проблеми допомагають краще її запам’ятати:

// bind it explicitly
btn.addEventListener("click", counter.increment.bind(counter));
// or wrap it in an arrow function
btn.addEventListener("click", () => counter.increment());

.bind(counter) фіксує об’єкт, до якого спрямований виклик. Функція-стрілка здійснює виклик через екземпляр, тому зберігається формат виклику методу. Функції-стрілки не створюють власне значення this; вони успадковують зв’язки з навколишнім контекстом, тому другий спосіб вирішення не потребує явного використання bind.

Чотири правила щодо місця виклику охоплюють більшість повсякденних ситуацій:

  1. Звичайний виклик функції → у режимі strict значення this дорівнює undefined, інакше — глобальний об’єкт.
  2. obj.method() → this дорівнює obj.
  3. .call(), .apply() або .bind() → this дорівнює значенню, яке було явно передано.
  4. Функції-стрілки → не мають власного this; вони успадковують його з оточуючого контексту.

Саме через останнє правило функції-стрілки часто використовуються за замовчуванням у класах та компонентах, які працюють з полями інстанції: це допомагає уникнути помилок випадкового від’єднання.

Що насправді допомогло

Одні лише визначення рідко залишаються у пам’яті. Ефективні методи:

Пишіть маленькі, навмисно пошкоджені фрагменти коду — п’ять-шість рядків, спрямованих на одну конкретну помилку, — щоб її було легко спостерігати та досліджувати.

Попередьте про результат виконання console.log ще до запуску файлу. Примусове прогнозування перетворює запам’ятоване правило на гіпотезу, яку можна перевірити.

Поясніть механізм вголос. Проблеми проявляються миттєво, коли виникає ефект hoisting або потрібно описати закриття функцій новими словами.

Висновок

Ефект hoisting, закриття функцій та змінна this — це не дрібниці. Вони пояснюють проблеми під час розробки: спільні зв’язки в циклах, помилки TDZ та обробники подій, які втрачають свою інстанцію. Крім того, вони не завжди однозначно переносяться на інші мови, тому плутанина на початковому етапі є нормальною. Навмисне створення помилок, прогнозування та повторне опис ускорюють момент, коли поведінка стає передбачуваною. Тримайте файл із зламаними фрагментами коду; повторний огляд їх через тиждень часто корисніший, ніж перечитування довгого розділу.