Лексичне середовище в JavaScript
Лексичне середовище (lexical environment) - це внутрішня структура JavaScript, яка пов'язує імена змінних з їхніми значеннями для конкретного scope, і зберігає посилання на оточуючий scope, де написаний код.
Теорія
TL;DR
- Кожна функція, блок
{}та глобальна область отримують власне лексичне середовище під час виконання - Кожне середовище має дві частини: Environment Record (самі змінні) і зовнішнє посилання (вказівник на батьківський scope)
- Scope визначається місцем написання коду, а не місцем виклику
- Ланцюжок зовнішніх посилань - це і є ланцюг областей видимості (scope chain)
- Замикання (closure) працюють тому, що функції зберігають посилання на лексичне середовище місця свого створення
Швидкий приклад
let city = "Kyiv";
function greet() {
let name = "Anna";
console.log(name + " from " + city); // "Anna from Kyiv"
}
greet();Коли greet виконується, JavaScript створює для неї лексичне середовище. Воно зберігає name = "Anna" і має зовнішнє посилання на глобальне середовище, де живе city = "Kyiv". greet знаходить city, піднімаючись по ланцюжку.
Дві частини лексичного середовища
Environment Record зберігає самі прив'язки: змінні, оголошення функцій, параметри. По суті це map з ключами і значеннями для цього scope.
Зовнішнє посилання (outer reference) вказує на лексичне середовище коду, який оточує функцію у вихідному файлі. Не там, де функцію викликають. Там, де її написано.
function makeCounter() {
let count = 0; // зберігається в Environment Record makeCounter
return function () {
count++; // знаходиться через outer reference до середовища makeCounter
return count;
};
}
const counter = makeCounter();
counter(); // 1
counter(); // 2Після повернення makeCounter її контекст виконання зникає. Але внутрішня функція досі тримає зовнішнє посилання на середовище makeCounter. Це середовище залишається живим, бо на нього є активне посилання. Це і є замикання (closure).
Лексичний проти динамічного scope
JavaScript використовує лексичний scope. Змінні шукаються там, де код написаний, а не там, де він виконується.
let x = 10;
function log() {
console.log(x);
}
function run() {
let x = 20;
log(); // виводить 10, не 20
}
run();log визначена в глобальному scope. Її зовнішнє посилання веде до глобального середовища. x = 20 всередині run для log невидима, бо log там не шукає.
Мови на кшталт Perl або Bash можуть використовувати динамічний scope, де пошук змінних іде по стеку викликів. JavaScript так не робить.
Як рушій будує ланцюжок
Коли функція створюється (не викликається), рушій прикріплює поточне лексичне середовище до неї через внутрішній слот [[Environment]]. Коли функція викликається, для цього виклику створюється нове лексичне середовище, і його зовнішнє посилання встановлюється в те, що зберігається в [[Environment]]. Тому scope завжди відповідає місцю визначення.
Типові помилки
Плутати лексичне середовище з контекстом виконання (execution context). Контекст виконання - це запис про виклик функції під час роботи програми. Лексичне середовище - один з компонентів цього контексту. Вони пов'язані, але не одне й те саме.
Думати, що scope іде за стеком викликів. Початківці часто очікують, що log(), викликана з run(), побачить змінні run. Ні. Ланцюжок фіксується в момент створення функції.
Забувати, що var ігнорує блочні середовища. let і const створюють прив'язки в Environment Record блоку. var пропускає блочні середовища і прив'язується до найближчої функції.
{
let blockVar = "блочна змінна";
var funcVar = "функціональна змінна";
}
console.log(typeof blockVar); // "undefined" - недоступна поза блоком
console.log(funcVar); // "функціональна змінна"Вважати, що середовище знищується після повернення функції. Збирач сміття видалить його тільки тоді, коли не залишиться жодного посилання. Живе замикання тримає середовище живим.
Де зустрічається у реальному коді
- React хуки:
useState,useCallbackіuseEffect- замикання, які захоплюють значення з лексичного середовища в момент створення. Баги застарілого замикання (stale closure) уuseEffect, де коллбек читає застаріле значення, - напевно, найпоширеніша проблема, яку я бачу, коли команди переходять з class-компонентів - Модульний патерн: IIFE-модулі зберігають приватний стан у закритому лексичному середовищі
- Обробники подій у циклах: класичний баг з
var+setTimeoutтрапляється, бо всі обробники ділять одне середовище і читаютьiпісля завершення циклу - Мемоізація:
useMemoі ручні memoize-функції покладаються на замикання над кешованим результатом
Follow-up питання
Q: Яка різниця між лексичним середовищем і ланцюгом областей видимості (scope chain)?
A: Scope chain - це ланцюжок зовнішніх посилань між лексичними середовищами. Лексичне середовище - окремий вузол цього ланцюжка. Коли JavaScript шукає змінну, він проходить по цьому ланцюжку.
Q: Коли створюється лексичне середовище?
A: Нове середовище створюється щоразу, коли викликається функція або починається блок. Кожен виклик функції отримує своє середовище, тому рекурсивні виклики не заважають одне одному.
Q: Чи може лексичне середовище пережити виклик функції, яка його створила?
A: Так. Якщо внутрішня функція закривається над ним, середовище залишається живим, поки ця функція доступна. Це і є механізм замикань.
Q: Що відбувається зі змінними let до рядка їхнього оголошення?
A: Вони існують в Environment Record, але в неініціалізованому стані. Звернення до них до рядка оголошення кидає ReferenceError. Цей діапазон називається Temporal Dead Zone (TDZ).
Q: Чим поведінка var відрізняється від let щодо лексичних середовищ?
A: Оголошення var піднімаються (hoisting) до Environment Record найближчої функції, а не блоку. Вони також одразу ініціалізуються значенням undefined, на відміну від let і const.
Приклади
Замикання, що тримає середовище живим
function makeAdder(x) {
// x живе в Environment Record makeAdder
return function (y) {
return x + y; // x знаходиться через outer reference
};
}
const add5 = makeAdder(5);
const add10 = makeAdder(10);
console.log(add5(3)); // 8
console.log(add10(3)); // 13makeAdder викликано двічі, тому існують два окремі середовища, кожне зі своїм x. add5 і add10 - замикання над різними середовищами. Тому однаковий аргумент 3 дає різні результати.
Проблема var у циклі і рішення через let
// Баг: всі коллбеки ділять одне середовище, i = 3 до моменту їх виклику
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // виводить 3, 3, 3
}
// Рішення: let створює нове блочне середовище на кожній ітерації
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // виводить 0, 1, 2
}З var є одна прив'язка в середовищі функції, і всі три коллбеки вказують на одну й ту саму i. З let цикл створює нове блочне середовище на кожній ітерації з власним i, тому кожне замикання захоплює щось своє.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.