JS
December 15, 2020

Замикання і область видимості в JS

Мемів про замикання також досить багато

Перед розглядом складних замикань давайте розглянемо більш прості види областей видимості.

Область видимості

Тож Область видимості (scope) - це концепт в JS, який визначає доступність змінних і функцій в тому чи іншому місці коду. Ця концепція лежить також в основі замикань, поділяючи змінні на локальні та глобальні.

В JS існує кілька видів області видимості. При цьому варто зазначити, що вони звичайно ж можуть бути вкладеними.

Блоковий скоуп

Блоки коду, як if, for, while, або ж штучні ({ }) визначають нову область видимості для змінних, оголошених з використанням let і const. var натомість не має блокової області видимості - змінні, оголошені з використанням var, доступні майже скрізь завдяки механізму hoisting.

Скоуп функції

Функція, змісно ж, також має власну область видимості. Що правда, вона також працює і для var'ів навідміну від блокового скоупу. При цьому не важливо, функція оголошена як function declaration чи function expression.

Скоуп модуля

Логічно, що і у модулів також є свій скоуп. Якщо ми імпортували модуль, але він не експортує будь-яких змінних, ми не зможемо отримати до них доступ:

// модуль circle 
const pi = 3.14; 
console.log(pi); // 3.14 
// кінець модуля circle

// інший модуль
import './circle' 
console.log(pi) // ReferenceError
// кінець іншого модуля

Глобальний скоуп

Це найвища область видимості, яка обмежується скриптом. Вона, до речі, створена для того, щоб ми могли мати доступ до глобальних обʼєктів типу Window або document.

Насправді існує ще одна область видимості - лексична. Саме про це і поговоримо далі, в контексті замикань - так це простіше зрозуміти.

Замикання

Замикання - це функції, які посилаються на незалежні змінні. Тобто, функція, визначена у замиканні, запамʼятовує "середовище", в якому була створена. Незалежні змінні тут - це ті змінні, які були використані у функції як аргументи і не були створені у ній локально.

Отже, маємо такий приклад:

function makeWorker() {
    let name = "Pete";
    
    return function() {
        console.log(name);
    };
};

let name = "John";

let work = makeWorker();
work(); // "Pete", "John" чи взагалі помилка?

На співбесідах достатньо сказати те, що я написав вище, а також, можливо, показати цей приклад. А його суть, а також те, як це працює, я опишу далі.

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

Що таке змінна в JS? Непоганий початок, так? Насправді, змінних не існує. Зате існує таке поняття як лексичне середовище (Lexical Environment). В JS у кожної функції, виконуваного блоку коду чи скрипта є деякий обʼєкт, який має назву лексичне середовище. Цей обʼєкт складається з двох частин:

  • Environment Record - обʼєкт, у якому всі створені в рамках даного блоку коду змінні зберігаються у якості полів;
  • Посилання на зовнішнє лексичне середовище. У глобального дорівнює null.

Отже, змінна - це насправді поле деякого обʼєкта, який звʼязаний з тим блоком коду, в якому ця змінна оголошена.

Приклад глобального лексичного середовища

Це якщо казати про змінні. А як щодо функцій (function declaration) і змінних, оголошених з використанням var? Насправді, все просто: перед початком виконання коду вони вже зберігаються у глобальному лексичному середовищі, дякуючи нашому улюбленому механізму hoisting'у.

Функції у глобальному лексичному середовищі

Тепер поговоримо про вкладені лексичні середовища.

Приклад вкладеного лексичного середовища

Під час виклику функції у нас, крім глобального лексичного середовища, зʼявляється ще одне - те, яке створюється для функції, що ми її викликаємо. Звідси можемо зробити висновок: коли код хоче отримати доступ до якої-небудь змінної, спочатку відбувається її пошук у внутрішньому лексичному середовищі функції, потім у зовнішньому, і так далі аж до глобального, поки змінна не буде знайдена. Саме тому якщо функція хоче отримати доступ до значення деякої змінної, вона завжди спершу шукає його у своєму власному лексичному середовищі. Тобто відповідь до першого фрагменту коду буде така:

function makeWorker() {
    let name = "Pete";
    
    return function() {
        console.log(name);
    };
};

let name = "John";

let work = makeWorker();
work(); // "Pete"
Варто зазначити, що лексичне середовище створюється кожен раз перед викликом функції, тобто новий виклик = нове лексичне середовище.

Вкладені функції

Тепер ближче до теми замикань. Іноді ми створюємо функцію, яка повертає нову функцію. Та функція, що всередині, називається вкладеною:

function makeCounter() { 
    let count = 0;
    
    return function() { 
        return count++; // є доступ до зовнішньої змінної count 
    }; 
} 

let counter = makeCounter(); 

alert(counter()); // 0 
alert(counter()); // 1 
alert(counter()); // 2

Як це працює? При виконанні функції counter починається пошук змінної count, яка буде знайдена у батьківському лексичному середовищі. Тож є два питання:

  • Чому значення змінної count зберігається? Ти ж казав, що кожного разу при виклику функції створюється нове лексичне середовище...
  • Чи буде ця змінна спільною, якщо ми створимо два таких лічильники:
    let counter1 = makeCounter(); 
    let counter2 = makeCounter(); 
    
    alert(counter1()); // 0 
    alert(counter1() ; // 1 
    alert(counter2()); // 1 чи 0?

Отже, по-перше, всі функції при "народженні" отримують приховане поле (функції ж насправді - об'єкти) під іменем [[Environment]], яке містить посилання на лексичне середовище місця, де функція була створена. Таким чином, функції знають, де вони були створені. Саме тому, коли ми визначаємо функцію makeCounter, у її поле [[Environment]] записується посилання на глобальне лексичне середовище. Далі ми викликаємо цю функцію і записуємо результат її виконання у змінну counter, яка вже, до речі, є у глобальному середовищі, але ще не задана (містить undefined).

counter вже є у глобальному лексичному середовищі

При виклику makeCounter створюється її лексичне середовище, в якому є поле count. Далі ми створюємо анонімну функцію, яка одразу ж отримує посилання на батьківське лексичне середовище, тобто лексичне середовище makeCounter, і повертаємо її.

Анонімна функція отримує посилання на лексичне середовище makeCounter зі змінною сount

Отже, ми записали вкладену функцію у змінну counter і тепер хочемо її викликати. Оскільки у цій функції записане посилання на лексичне середовище makeCounter, вона звертається при всіх своїх викликах до однієї і тої ж змінної counter. Тобто, так, при виклику counter кожен раз саме для неї створюється нове лексичне середовище, але батьківське натомість залишається тим самим. Це відповідь на перше питання.

Функція в counter має посилання на батьківське лексичне середовище

І друге питання: чи буде ця змінна спільною для різних лічильників. Відповідь - ні, тому що як раз тут при кожному виклику makeCounter створюється нове лексичне середовище, в якому зберігається початкове значення змінної counter, унікальної для кожного виклику.

Ось тепер маємо відповідь на найперше питання - що ж таке замикання. Замикання - це функція, яка запамʼятовує свої зовнішні змінні і може отримати до них доступ. І найголовніше - в JS усі функції за замовчуванням є замиканнями. А ось лексичне середовище, по суті - це реалізація механізму області видимості (Scope).

Блоки коду

Попередні приклади зосереджувались на функціях, але лексичне середовище існує для будь-якого блоку коду (інакше як би все працювало без змінних :О).

Лексичне середовище створюється при виконанні блоку коду і містить локальні змінні цього блоку. Наприклад:

Лексичне середовище для блоку if

А ось приклад для циклів:

for (let i = 0; i < 10; i++) {
    let x = i + 1;
} 
alert(x); // Помилка, немає такої змінної

Тут особливість в тім, що, по-перше, на кожній ітерації циклу створюється нове лексичне середовище, а по-друге, хоч і здається, що змінна i - лічільник циклу, візуально знаходиться не в його тілі, насправді це один і той же блок коду.

IIFE

В минулому в JS не було лексичного середовища на рівні блоків коду, тому вигадали "костиль". Оскільки лексичне середовище працювало лише для функцій, їх почали використовувати, щоб створити ізольований блок коду:

(function() { 
    let message = "Hello"; 
    alert(message); // Hello 
})();

Так, тут ми просто створюємо анонімну функцію, яку одразу ж виконуємо, все просто. Це має назву immediately invoked function expression - IIFE.

Сейчас так делать не нужно, лексическое окружение создается для любого блока кода.

Збірка сміття

Зазвичай лексичне середовище видаляется одразу ж після того, як функція чи блок коду виконались:

function f() { 
    let value1 = 123; 
    let value2 = 456; 
} 

f();

Але якщо провернути трюк зі вкладеною функцією - у неї залишиться посилання на лексичне середовище батьківської функції, тому воно просто не зможе видалитись, поки не видалиться вкладена функція. У наступному прикладі зберігаються одразу ж три лексичні середовища:

function f() { 
    let value = Math.random(); 
    return function() { 
        alert(value); 
    }; 
} 

// Три функції в масиві, кожна з яких посилається на 
// лексичне середовище з відповідного виклику f() 
let arr = [f(), f(), f()];

Сюрпризи в дебагері

Єдине, про що варто також зазначити - приколи дебагера. Якщо ви якось захочете скористатись дебагером у браузері з двигуном V8, знайте, що він любить все оптимізувати, тому при відладці можуть бути сюрпризи:

let value = "Сюрприз!"; 

function f() { 
    let value = "найближче значення"; 
    
    function g() { 
        debugger; // в консолі напишіть alert(value): отримаєте "Сюрприз!" 
    }
     
    return g; 
} 

let g = f(); 
g();

або ж:

function f() { 
    let value = Math.random(); 
    
    function g() { 
        debugger; // в консолі напишіть alert(value): такої змінної немає! 
    }
     
    return g; 
} 

let g = f(); 
g();

Ще раз: такі приколи зʼявляються лише у відладці, при звичайній роботі все буде ок.

Висновки

В JS існує 5 видів області видимості: блокова, функція, модуль, глобальна, а також лексична. До блокових відносяться всі конструкції циклів, умовні конструкції, а також штучні блоки коду.
Глобальна область видимості створена для роботи з полями глобального обʼєкту: Window, document, Math...
Просте визначення замикання: замикання - це функції, що посилаються на незалежні змінні. Таким чином, функція, що визначена у іншій функції (замиканні) запамʼятовує середовище, в якому була створена.
Під капотом замикання засновані на механізмі лексичного середовища (Lexical Environment).
В JS всі функції є замиканнями (і стрілкові теж), а лексичне середовище по суті - це реалізація механізму області видимості (Scope).
У блоків коду в JS, таких як цикли, умовні конструкції, а також у штучних блоків коду ({ }) також є лексичне середовище.
Раніше у блоків коду не було власного лексичного середовища, тому використовували лайфхак: створювали анонімні функції і одразу ж викликали їх. Це зветься immediately invoked function expression (IIFE).
Лексичне середовище видаляється одразу ж після закінчення виконання функції чи блоку. Але якщо у скоупі відповідної функції є вкладена функція - батьківське лексичне середовище буде жити допоки на нього існують посилання.

Джерела

https://learn.javascript.ru/closure