JS
December 22, 2020

Контекст в JS

З this не просто так роблять меми

Контекст (зазвичай називають просто this) - ключове слово, яке вказує на контекст виконання функції - обʼєкт. По суті це об'єкт, на якому викликана функція. Але важливо уточнити, що:

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

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

Одразу ж питання в студію: що буде виведено тут?

const myFunction = function() {
    console.log(this);
}

myFunction();

Як ми вже знаємо, контекст - це об'єкт, у якому працює функція. Ми викликаємо її там, де й створили, тож тут відповідь – Window.

Насправді, не завжди. Зараз досить часто використовується strict mode, а якщо він увімкнений, то глобальний контекст буде посилатися вже не на Window, а на undefined. Це варто запам'ятати, бо строгий режим увімкнено за замовчуванням вже майже скрізь.

Тепер переходимо до обʼєктів - саме з ними контекст і використовується.

Чого чекати в консолі, якщо викликати цю функцію?

const myObject = {
    myMethod: function() {
        console.log(this);
    }
};

Насправді, тут питання не "чого?", а "як?". А саме, все буде залежати від того, як ми викличемо цю функцію. Ось зібрав кілька варіантів:

//1
myObject.myMethod();

//2
const func = myObject.myMethod;
funct();

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

Звісно, можемо провернути і такой трюк:

const myMethod = function() {
    console.log(this);
};

const myObject = {
    myMethod: myMethod
};

myObject.myMethod(); // myObject

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

Те, що ми щойно зробили, називається implicit binding, тобто неявне звʼязування. У цьому випадку контекст визначається сам, без нашої участі. Але є ще explicit binding і hard binding.

Explicit binding

Мb знаємо, що у всіх функцій є такі методи, як call і apply. Між собою вони майже не відрізняються, а ось їх сенс - викликати функцію з потрібним нам контекстом:

const myMethod = function() {
    console.log(this);
};

const myObject = {
    myMethod: myMethod
};

myMethod(); // this === Window
myMethod.call(myObject, arg1, arg2,...); // this === myObject
myMethod.apply(myObject, [array of args]); // this === myObject

Навіть додати нічого. Скажу тільки, що явне (explicit) звʼязування завжди переважає над неявним (implicit), тому не важливо, як ми викликаємо myMethod - на обʼєкті myObject чи як у фрагменті коду, якщо ми явно привʼязуємо свій контекст.

Hard binding

Звичайно, у функцій є ще один метод - bind. Його призначення відрізняється від попередніх call і apply. Цей метод використовується, щоб назавжди звʼязати функцію з наданим контекстом. Звісно, щоб не "псувати" початкову функцію, метод повертає нам нову функцію з уже привʼязаним контекстом:

const myMethod = function() {
    console.log(this);
};

const myObject = {
    message: "Hello"
};

const newMethod = myMethod.bind(myObject);
newMethod(); // this === myObject

Це називається hard binding, оскільки ми назавжди привʼязуємо до функції наш контекст. Варто зазначити, що тепер явне звʼязування працювати вже не буде:

const myMethod = function() {
    console.log(this.a);
};

const obj1 = {
    a: 2
};

const obj2 = {
    a: 3
};

const newMethod = myMethod.bind(obj1); // { a: 2 }
newMethod.call(obj2); // все ще { a: 2 }

Трохи про минуле

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

function foo(a) {
    this.a = a;
}

const bar = new foo(2);
console.log(bar.a) // 2

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

function foo(something) {
    this.a = something;
}

const obj1 = {};

const bar = foo.bind(obj1);
bar(2);
console.log(obj1.a) // 2

const baz = new bar(3);
console.log(obj1.a) // 2
console.log(baz.a) // 3

Використовуємо функції з контекстом

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

const myObject = {
    myMethod: function() {
        helperObject.doSomething('text', this.onDone);
    },
    
    onDone: function() {
        // Сам бог знає, який "this" тут буде
    }
};

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

Ця проблема має кілька рішень (і ще одне розглянемо пізніше у цій статті).

Перше: часто бібліотеки надають ще один параметр для передачі нашого контексту для колбеку:

const myObject = {
    myMethod: function() {
        helperObject.doSomething('text', this.onDone, this);
    },
    
    onDone: function() {
        // this === myObject
    }
};

Друге: звичайно ж ми можемо передати колбек з уже привʼязаним контекстом:

const myObject = {
    myMethod: function() {
        helperObject.doSomething('text', this.onDone.bind(this));
    },
    
    onDone: function() {
        // this === myObject
    }
};

Третє: створюємо замикання і зберігаємо this у ньому:

const myObject = {
    myMethod: function() {
        var that = this;    

        helperObject.doSomething('text', function() {
            // Сам бог знає, який "this" тут буде, але у нас є "that"
        });
    }
};

Але так робити не рекомендується, бо це костиль. Таким чином ми починаємо користуватись якимись змінними замість справжнього контексту і можемо загубитись.

Звісно ж, усі ці проблеми зʼявляються, коли ми використовуємо і нативні функції накшталт setTimeout, addEventListener абоforEach... Тож невже ми маємо завжди гратись з контекстом?

Контекст у стрілкових функціях

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

Отже, почнемо з того, що у стрілкових функцій немає свого контексту. Кілька прикладів:

const myFunction = () => {
    console.log(this);
};

myFunction(); // Window || undefined

А щось змінилось? Поки нічого. Але що скажете тут?

const myObject = {
    myMethod: () => {
        console.log(this);
    }
};

myObject.myMethod(); // ???

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

myObject.myMethod(); // Window || undefined

const myMethod = myObject.myMethod;
myMethod(); // Window || undefined

Але чому? Як я вже казав, у стрілкових функцій просто немає контексту. Але звідки ж ми його тоді беремо? Можливо, це "обʼєкт на рівень вище"?

const parentObj = {
    childObj: {
        message: "Hello",
        method: () => {
            console.log(this);
        }
    }
};

parentObj.childObj.method(); // Window || undefined

І знов ні... З більшою вкладеністю теж працювати не буде.

Розуміємо контекст у стрілкових функціях

У більшості статей використовується поняття "батьківський контекст". Забудьте його, оскільки воно може дуже легко заплутати. А нормальне пояснення - нижче.

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

А якщо казати про стрілкові функції, то у них просто немає контексту. Відповідно, коли ми звертаємося до this у стрілковій функції, ми просто "проходимося по всім скоупам" і "знаходимо" якийсь контекст вище, от і все. Якщо знаходимо - він дорівнює контексту функції (звичайної), у якій виконується наша стрілкова функція. Якщо не знаходимо - отримуємо глобальний об'єкт або undefined, залежно від того, чи увімкнений строгий режим, тому що весь код у будь-якому випадку десь виконується, і у цього "десь" є свій контекст.

Привʼязуємо свій контекст до стрілкових функцій

Ну і, звісно ж, оскільки у стрілкових функцій немає контексту, новий приклеїти до них не вийде. Тобто, bind, call та apply ніякого ефекту не дадуть, якщо їх застосовувати до стрілкових функцій.

Також стрілкові функції - це вже не минуле, тому їх не можна викликати як конструктор з використанням ключового слова new.

Використовуємо стрілкові функції без контексту

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

let group = {
    title: "Our Group",
    students: ["John", "Pete", "Alice"],
    
    showList() {
        this.students.forEach(st => alert(`${this.title}: ${st}`));
    }
};

group.showList();

А ось звичайна функція тут просто зламається:

let group = {
    title: "Our Group",
    students: ["John", "Pete", "Alice"],
    
    showList() {
        this.students.forEach(function(student) {
            // Error: Cannot read property 'title' of undefined
            alert(`${this.title}: ${st}`);
        });
    }
};

group.showList();

Вся сппава в тім, що звичайна функція отримує свій контекст, який бог знає чому рівний. І швидш за все, поля title у тому контексті не буде. А у стрілкової функції немає свого контексту, тому ми шукаємо вже існуючий контекст і отримуємо посилання на обʼєкт group. Саме з цієї причини стрілкові функції ідеально підходять для роботи із замиканнями або у якості колбеків.

Як працює this

Ну і наостанок хотів би розповісти, як насправді this дає нам посилання на обʼєкт, у якому виконується функція. Почнемо з прикладу:

let user = {
    name: "John",
    hi() { alert(this.name); },
    bye() { alert("Bye"); }
};

user.hi(); // "John"
(user.name === "John" ? user.hi : user.bye)(); // Error!

Як бачимо, такі функції (методи) ще і викликати правильно треба вміти! Чому ж при другому виклиці отримуємо помилку?

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

Ми казали, що те, що нам поверне this, залежить від того, як ми викличемо функцію. Тому справа тут якраз у операторі-крапці – саме він передає інформацію про об'єкт далі, до круглих дужок.

Так ось насправді оператор-крапка повертає не саму функцію, а деяке спеціальне значення, зване Reference Type. Цей тип є внутрішнім, і містить три значення: base, name, strict. Як ви вже здогадалися, в base лежить посилання на обʼєкт, name - це ім'я поля, а strict - чи увімкнений строгий режим. Тобто коли ми робимо виклик user.hi(), до круглих дужок доходить таке значення: (user, "hi", true). І коли дужки застосовуються до такого значення (відбувається виклик), вони отримують повну інформацію про об'єкт, і можуть визначити правильний this.

Що відбувається далі, на наступному рядку? Справа в тім, що значення Reference Type – це не звичайне посилання. А коли ми застосовуємо тернарний оператор (або будь-який інший оператор) до значень Reference Type, цей Reference Type змушений конвертуватися у звичайне значення, у даному випадку - функцію. І коли ми доходимо до круглих дужок - вони отримують лише функцію, а не значення Reference Type, що і призводить до втрати контексту. Ось ще один приклад для закріплення:

let obj, method;

obj = {
    go: function() { alert(this); }
};

obj.go();                  // (1) [object Object]

(obj.go)();                // (2) [object Object]

(method = obj.go)();       // (3) undefined

(obj.go || obj.stop)();    // (4) undefined

Висновки

Контекст - це обʼєкт, в контексті якого працює функція (обʼєкт, на якому викликана функція).
this - ключове слово, яке при виконанні повертає посилання на контекст.
Контекст залежить від того, як викликана функція, і визначається лише при її виконанні.
Глобальний контекст рівний обʼєкту Window або undefined, в залежності від того, чи увімкнений строгий режим.
Implicit binding - це неявне визначення контексту при виклику функції.
Explicit binding - це явне звʼязування контексту з функцією з використанням call и apply. call від apply відрізняється лише тим, що call приймає список послідовних аргументів, а apply - масив з усіма аргументами.
Hard binding - це жорстке звʼязування контексту і функції з використанням bind. Функція з контекстом, що був привʼязаний у такий спосіб, завжди буде посилатися лише на цей контекст, і жоден call чи apply не зможуть це змінити.
У cтрілкових функцій немає свого контексту, тому при зверненні в них до this шукається і повертається найближчий існуючий контекст. call, apply и bind не працюють зі стрілковими функціями.
Стрілкові функції створені для того, щоб бути переданими в інший код у якості колбеку.

Джерела

https://www.codementor.io/@dariogarciamoya/understanding--this--in-javascript-du1084lyn

https://www.codementor.io/@dariogarciamoya/understanding-this-in-javascript-with-arrow-functions-gcpjwfyuc

https://learn.javascript.ru/object-methods

https://learn.javascript.ru/arrow-functions