Більшість людей починають вивчати JavaScript, створюючи щось практичне. Ви підключаєте кнопку, отримуєте дані та спостерігаєте, як змінюється DOM. А потім абстракції починають «протікати». З'являються помилки, які не мають сенсу: змінні існують до їхнього оголошення, функції пам'ятають змінні, до яких вони не повинні мати доступу, а ключове слово this вказує на window, на кнопку або взагалі ні на що. Зазвичай саме в цей момент ви усвідомлюєте, що потрібно зазирнути «під капот» і зрозуміти, що насправді робить рушій.

Контекст виконання: двофазна підготовка

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

Фаза створення пам'яті. Під час цього початкового проходу рушій сканує всю область видимості та виділяє пам'ять для кожного знайденого оголошення змінної чи функції. Якщо він бачить var, він резервує місце і зберігає undefined як заповнювач. Якщо він бачить оголошення функції, він зберігає весь тіло функції. Саме тому функцію, оголошену за допомогою традиційного ключового слова function, можна викликати з рядків, що розташовані раніше в тій самій області видимості. Рушій уже знає про неї до того, як почне виконання.

Фаза виконання коду. Тепер рушій виконує ваш код рядок за рядком. Саме тут відбуваються присвоєння. Тут обчислюються вирази. Тут викликаються функції. Якщо ви написали var name = "Alice";, заповнювач з першої фази нарешті замінюється рядком. Розуміння цієї двопрохідної поведінки розсіює чимало труднощів. Рушій не працює недбало; він дотримується суворого алгоритму підготовки.

Змінні та тимчасова мертва зона

Вибір між var, let та const — це не просто стилістична перевага. var має область видимості в межах функції, що означає, що він повністю ігнорує фігурні дужки. Оголосіть його всередині блоку if, і він «протікає» назовні. Така поведінка могла мати сенс у ранньому JavaScript, але в сучасних застосунках вона створює реальні проблеми при підтримці коду. let та const мають блочну область видимості. Вони поважають фігурні дужки та зникають, коли блок завершується.

Також існує тонка, але вирішальна різниця в тому, як механізм підняття (hoisting) поводиться з ними. Оголошення var піднімаються і відразу ініціалізуються значенням undefined. let та const технічно теж піднімаються; рушій знає про їхнє існування ще до того, як дійде до рядка оголошення. Але вони не ініціалізовані. Вони перебувають у стані невизначеності, який називається тимчасовою мертвою зоною (temporal dead zone). Якщо ви спробуєте прочитати їх до того, як виконається рядок оголошення, ви отримаєте жорстку помилку ReferenceError замість непомітного undefined. Цей збій насправді корисний. Він не дає логіці продовжуватися на основі неініціалізованих даних.

Лексична область видимості та замикання

Область видимості (scope) дає відповідь на просте запитання: де я можу отримати доступ до цієї змінної? JavaScript використовує лексичну область видимості, що означає: права доступу функції визначаються тим, де саме вона була фізично написана в коді, а не тим, де вона зрештою викликається. Якщо ви визначаєте функцію всередині іншої функції, внутрішня функція може звертатися назовні, щоб прочитати змінні свого батьківського контексту. Зовнішня функція не може звертатися всередину. Цей зв'язок є статичним. Ви могли б передати цю внутрішню функцію між модулями, зберегти її в глобальній змінній і викликати з зовсім іншого файлу. Вона все одно пам'ятатиме змінні з тієї області видимості, де вона була створена.

Така поведінка природним чином породжує замикання (closures). Замикання утворюється, коли внутрішня функція зберігає посилання на змінну зі своєї зовнішньої області видимості. Навіть після того, як зовнішня функція завершила виконання і її локальні змінні мали б бути видалені збирачем сміття, JavaScript зберігає їх у пам'яті, тому що внутрішня функція все ще потребує їх. Внутрішня функція несе своє оточення з собою.

Це не просто академічна деталь. Замикання дають вам практичний спосіб створення приватного стану в мові, якій бракує явних модифікаторів доступу.

function makeCounter() {
  let count = 0;
  return function() {
    count = count + 1;
    return count;
  };
}

const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2

Тут count приховано. Ніщо за межами повернутої функції не може скинути його або прочитати напряму. Це приватна змінна, створена виключно завдяки механіці областей видимості.

Підняття (Hoisting) на практиці

Часто можна почути, що JavaScript «переносить оголошення вгору». Це корисна ментальна модель, але насправді код не переписується. Під час фази створення пам'яті рушій просто реєструє оголошення перед початком виконання. Такий оператор, як var x = 5;, поводиться так, ніби оголошення та ініціалізація розділені. Оголошення var x; обробляється на ранньому етапі та ініціалізується значенням undefined. Присвоєння x = 5; залишається саме там, де ви його написали, і виконується під час фази виконання.

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

Як this отримує своє значення

Якщо контекст виконання та область видимості визначають, де «живуть» змінні, то this визначає, який об'єкт зараз керує процесом. На відміну від лексичних змінних, значення this не залежить від того, де написана функція. Воно повністю залежить від того, як функція викликається.

Прив'язка за замовчуванням (Default binding) відбувається, коли ви викликаєте звичайну, незалежну функцію. У нестрогому режимі (non-strict mode) this стає глобальним об'єктом. У браузері це window. Викличте функцію без будь-якого контексту, і ви можете випадково змінити глобальний стан, навіть не усвідомлюючи цього.

Неявна прив'язка (Implicit binding) відбувається, коли ви викликаєте функцію як метод об'єкта. Якщо ви напишете user.sayName(), крапка непомітно вказує рушію встановити this на user на час цього виклику. Місце виклику має більше значення, ніж місце визначення.

Явна прив'язка (Explicit binding) дозволяє вам вручну перевизначити все. call() та apply() викликають функцію негайно, примусово встановлюючи this на конкретний об'єкт, який ви надаєте. Єдина різниця між ними полягає в тому, як передаються аргументи: call приймає список через кому, тоді як apply приймає масив. bind() працює інакше. Він не викликає функцію відразу. Замість цього він повертає нову функцію, у якої this назавжди закріплено за наданим вами значенням. Це неоціненне для колбеків (callbacks), які інакше могли б втратити свій контекст під час передачі.

Прив'язка через new (New binding) вступає в дію, коли ви використовуєте ключове слово new перед викликом функції. Рушій створює абсолютно новий порожній об'єкт, встановлює зв'язок із прототипом і спрямовує this всередині конструктора на цей новий екземпляр.

Написання коду, який перевіряє час

Розуміння механізмів — це лише половина майстерності. Інша половина — це написання коду, який люди зможуть читати через шість місяців.

DRY (Don't Repeat Yourself — не повторюйтеся) звучить очевидно, але це правило постійно порушується. Якщо ви помічаєте, що пишете однакову логіку валідації або один і той самий патерн виклику API у трьох різних файлах, винесіть це. Напишіть одну функцію. Єдине джерело істини означає одне місце для оновлення при зміні вимог, що економить час так значно, що це важко переоцінити.

KISS (Keep It Simple, Stupid — роби простіше) — це захист від его. Вкладені тернарні оператори та замикання в один рядок здаються розумними, але вони коштують годин під час налагодження. Патерн замикання, який я показав раніше, є потужним, проте вкладення на п'ять рівнів лише тому, що ви можете це зробити — це помилка. Простий код виживає під час зміни складу команди, інцидентів на продакшені та тих нічних сповіщень, коли щось ламається, а ніхто не пам'ятає чому.

Справжня винагорода

Вивчення виконання