Контекст в JS
Контекст (зазвичай називають просто 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