Skip to main content

Лексичне середовище в JavaScript

Лексичне середовище (lexical environment) - це внутрішня структура JavaScript, яка пов'язує імена змінних з їхніми значеннями для конкретного scope, і зберігає посилання на оточуючий scope, де написаний код.

Теорія

TL;DR

  • Кожна функція, блок {} та глобальна область отримують власне лексичне середовище під час виконання
  • Кожне середовище має дві частини: Environment Record (самі змінні) і зовнішнє посилання (вказівник на батьківський scope)
  • Scope визначається місцем написання коду, а не місцем виклику
  • Ланцюжок зовнішніх посилань - це і є ланцюг областей видимості (scope chain)
  • Замикання (closure) працюють тому, що функції зберігають посилання на лексичне середовище місця свого створення

Швидкий приклад

javascript
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) вказує на лексичне середовище коду, який оточує функцію у вихідному файлі. Не там, де функцію викликають. Там, де її написано.

javascript
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. Змінні шукаються там, де код написаний, а не там, де він виконується.

javascript
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 пропускає блочні середовища і прив'язується до найближчої функції.

javascript
{ 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.

Приклади

Замикання, що тримає середовище живим

javascript
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)); // 13

makeAdder викликано двічі, тому існують два окремі середовища, кожне зі своїм x. add5 і add10 - замикання над різними середовищами. Тому однаковий аргумент 3 дає різні результати.

Проблема var у циклі і рішення через let

javascript
// Баг: всі коллбеки ділять одне середовище, 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, тому кожне замикання захоплює щось своє.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?