this w JS — czyli kilka słów o kontekście wywołania funkcji

Czy kiedykolwiek spotkałaś/eś się z błędem w aplikacji, który wynikał z tego, że this było ustawione na coś innego, niż się spodziewałaś/eś? Jeśli tak, to nie jesteś jedyna/y. W swojej karierze programisty miałem okazję występować w roli rekrutera na ponad 160-ciu rozmowach kwalifikacyjnych na stanowiska front-endowe. Jeśli nauczyło mnie to jednego, to tego, że odpowiedź na pytanie „na co wskazuje this?” sprawia największą trudność nie tylko kandydatom, ale również doświadczonym programistom w ich codziennej pracy. W tym wpisie pokazuję, że this nie jest tak magiczne jakby się mogło wydawać i omawiam kilka prostych zasad, które determinują odpowiedź na to pytanie.

Wstęp do this w JS

JavaScript to niezwykły język. Jego składnia jest bardzo podobna do tych znanych z C++ czy z Javy. Bardzo wiele wybacza — błąd w JS niekoniecznie skutkuje niezdatnością strony WWW na której wystąpił do użycia, bardzo często działa ona dalej bez najmniejszego problemu. Oba te aspekty bez wątpienia wpłynęły na to, że jest on tak popularny. W końcu wystarczy napisać kilka linijek w pliku *.js i odświeżyć stronę. To wszystko sprawia, że pisać w JS można praktycznie bez żadnego wcześniejszego przygotowania. I dokładnie tak się dzieje.

Z drugiej strony mamy programistów, nie JavaScriptu ogólnie, a konkretnej biblioteki, czy frameworka. Kiedyś było to jQuery, potem Angular.js, a obecnie React.js. Osoby takie często swoją znajomość JS ograniczają właśnie do APi używanego rozwiązania.

Szybko okazuje się jednak, że aplikacja są niewydajne, występują w niej dziwne, nieprzewidziane błędy, a kod jest całkowicie niezrozumiały. Poznanie fundamentów języka, którego używasz nie tylko pozwoli Ci lepiej zrozumieć co pod spodem robi Twoja aplikacja, ale także zapobiec błędom zanim wystąpią — jeszcze na etapie pisania kodu. Dzięki temu pisane przez Ciebie aplikacje będą bardziej wydajne i usiane mniejszą ilością błędów, oraz łatwiejsze w dalszym rozwoju, niezależnie od tego czy ostatecznie korzystasz z Reacta, Vue czy Angulara.

Błąd JavaScript związany z błędnie ustawionym this

Jest to jeden z wielu powodów, dla których warto poznać JavaScript jak najlepiej, na wylot. Po przeczytaniu tego wpisu, mam nadzieję, this w JS będzie jednym z tematów, w których staniesz się ekspertem!

this w programowaniu

W ogólnie pojętym programowaniu obiektowym słowo kluczowe this ma specjalne znaczenie. Wskazuje na obiekt będący kontekstem wykonania. Najprostszym przykładem jest odwołanie się do pola obiektu w jego metodzie. Aby to zrobić napiszesz this.nazwaPola — wtedy kontekstem jest obiekt, na którym wywołana została ta metoda, a this wskazuje właśnie na niego. Tak działa to w językach z klasycznym modelem obiektowości, np. C++, czy Javie.

W JavaScript odpowiedź na pytanie „czym jest this” jest trochę bardziej skomplikowana, ponieważ to, na co wskazuje this zależy nie tylko od sposobu definicji funkcji, ale również od formy jej wywołania. Doskonale opisuje to moim zdaniem termin “late binding” (choć oczywiście rozumiany inaczej niż w klasycznym OOP).

Domyślny this

Jeśli w kodzie użyjesz słowa kluczowego this poza jakąkolwiek funkcją, to zawsze będzie ono wskazywało na obiekt hostawindow w przeglądarkach oraz module.exports w node.js. Dla uproszczenia, będę odnosił się do niego jako window w dalszej części artykułu:

this; // === window

Podobnie jest w przypadku, gdy wywołasz funkcję bezpośrednio przez referencję do niejthis wskazuje w takiej sytuacji na obiekt window:

function fun() {
  return this;
};

fun(); // === window

Specjalnym przypadkiem jest tu sytuacja, gdy funkcja została zdefiniowana w strict mode. W takim wypadku jedną z konsekwencji jest to, że nie jest już używany domyślny this. Zamiast tego otrzymasz wartość undefined:

function fun() {
  “use strict”;
  return this;
};

fun(); // === undefined

Warto pamiętać, że w kontekście modułów ES2015 (import / export) oraz class, tryb strict jest domyślny.

Metoda obiektu czyli wywołanie z kropką

Przy ustalaniu this kolejnym pod względem ważności sposobem wywołania funkcji jest wywołanie jej jako metody obiektu, czyli z kropką po lewej. Obiekt taki nazywany jest obiektem kontekstowym.

var o = {
  a: "o object",
  method: function() {
    return this;
  }
};

o.method(); // === o

Przy takim wywołaniu this wskazuje na obiekt będący bezpośrednio w lewej strony kropki — w tym wypadku o.

var o = {
  a: "o object",
  method: function() {
    return this;
  }
};

var otherO = {
  a: "otherO object",
  method: o.method
}

otherO.method(); // === otherO

Zwróć uwagę, że po przypisaniu referencji do metody do obiektu otherO i wywołaniu jej jako jego metody this wskazuje właśnie na ten obiekt. Zupełnie ignorowany jest fakt, że oryginalnie ta metoda została zdefiniowana w obiekcie o.

Przekazanie referencji to nie wywołanie

Przy tej okazji warto wspomnieć o częstym problemie napotykanym przez programistów. Przekazujesz do jakiejś funkcji referencję do swojej metody tylko po to, aby dowiedzieć się, że this wskazuje na window, a nie oczekiwany obiekt. Dzieje się to np. kiedy chcesz przekazać callback jako thenPromise.

fetch('https://example.com/endpoint').then(o.method); // === window

Powodem takiego zachowania jest fakt, że mimo przekazania referencji do metody z użyciem obiektu kontekstowego, z kropką, fetch (który zwraca Promise) wywołuje Twoją funkcję w sposób samodzielny, czyli przez referencję:

function insideThen(fn) {
  fn();
}

Więcej o Promise’ach możesz przeczytać w tym wpisie:

9 rzeczy, których nie wiesz na temat Promise

Innym, często budzącym zaskoczenie, przypadkiem jest sytuacja, w której funkcja, do której przekazałaś/eś callback celowo zmienia jego this. Idealnym przykładem jest przypięcie funkcji jako callbacka dla zdarzenia DOM, np. kliknięcia. W takim wypadku jako this ustawiany jest element DOM, na którym zaszło zdarzenie. Podobnie zachowuje się biblioteka jQuery.

lnk.addEventListener("click", o.method); // === kliknięty element DOM

Wymuszenie konkretnego obiektu jako kontekstu

Poprzedni przypadek wymagał zmodyfikowania obiektu kontekstowego przez dodanie do niego nowej metody w celu ustawienia konkretnego obiektu jako this metody. Na szczęście istnieją inne mechanizmy pozwalające sprecyzować czym ma być this podczas lub przed wywołaniem funkcji.

Zanim zapoznasz się z tymi mechanizmami, warto zwrócić uwagę na dwie cechy JavaScriptu, które nam to umożliwiają. Po pierwsze, funkcje w JS są tzw. obywatelami pierwszej kategorii (first class citizens) oraz obiektami. Oznacza to, że możesz je przekazywać jako parametry do innych funkcji, oraz że same mogą mieć metody. Po drugie, prototypowa natura JS sprawia, że wszystkie obiekty danego typu mogą mieć dostępne wspólne dla nich pola i metody.

Metody call i apply

Ustawienie konkretnego obiektu jako this podczas wywołania funkcji możliwe jest przy pomocy metod callapply:

const o = {
  a: "o object",
  method: function() {
    console.log(this, arguments); // wypisuje this oraz przekazane do funkcji argumenty
  }
};

const x = {
  a: "x object"
};

o.method(1, 2); // this === o, [1, 2]
o.method.call(x, 1, 2, 3); // this === x, [1, 2, 3]
o.method.apply(x, [1,2,3]); // this === x, [1, 2, 3]

Jak zapewne zauważyłaś/eś, callapply różnią się jedynie sposobem w jaki przekazują parametry do wywoływanej funkcji — pierwsza przyjmuje je jako swoje argumenty, druga przyjmuje je jako tablicę, której elementy są kolejno podstawiane. Obie metody za pierwszy parametr przyjmują obiekt, który ma zostać użyty jako this.

Metoda bind

Kolejną dostępną metodą jest bind. W odróżnieniu od poprzedników nie wywołuje on funkcji na miejscu, ale zwraca referencję do funkcji, której this zawsze wskazuje na przekazany obiekt. Kolejne parametry przekazane do bind zostaną podstawione jako pierwsze parametry oryginalnej funkcji podczas wywołania — zostanie więc wykonana częściowa aplikacja (partial application) oryginalnej funkcji:

const m = o.method.bind(x, 1, 2);
m(3,4); // this === x, [1,2,3,4]
setTimeout(m); // this === x, [1,2]

Raz zbindowanej funkcji nie można już nadpisać obiektu kontekstowego w ten sam sposób. Dlatego poniższy kod zadziała inaczej, niż byś tego chciał(a). Zwróć uwagę, że kolejne parametry zostały zaaplikowane mimo zignorowania nowej wartości this.

const m2 = m.bind(o2, 3, 4);
m2(5, 6); // this === x, [1,2,3,4,5,6]

Ignorowanie wartości this

Korzystając z powyższych metod w niektórych wypadkach możesz chcieć zignorować wartość this (np. interesuje Cię jedynie ustawienie domyślny wartości argumentów) lub specjalnie ustawić ją na „nic”. Naturalnym pomysłem, który przychodzi do głowy jest użycie null lub undefined jako wartości pierwszego argumentu:

const ignored = o.method.call(null, 1); // this === window, [1]

W takiej sytuacji, nasz nowy kontekst zostanie jednak zignorowany, a w jego miejsce użyte zostanie… tak, window! Jest to szczególnie ważne, że kod biblioteczny mógłby w ten sposób nadpisać zmienne globalne. Dużo bezpieczniej jest przekazać w takim wypadku pusty obiekt {} lub wynik Object.create(null) czyli pusty obiekt bez prototypu. Obiekt taki jest naprawdę pusty i nie posiada żadnych pól ani metod.

o.method.call(Object.create(null), 1); // this === {}, [1]

Wywołanie z new — funkcje-konstruktory

Funkcję w JS możesz wywołać również jako konstruktor, czyli z użyciem operatora new. To, co dokładnie się dzieje podczas wywołania funkcji z new i jak różni się to od języków takich jak C++, czy Java jest materiałem na osobny post. W tym momencie skup się jedynie na tym, że kiedy funkcja jest wywoływana z new, powstaje nowy, pusty obiekt, który następnie jest ustawiany jako kontekst wywołania tej funkcji:

function Clazz(a,b) {
  this.a = 1;
  this.b = 2;

  return this;
}

Clazz.prototype.method = function() {
  l("Prototype", this);
};

const toBind = { c: 3 };

const instance = new Clazz(); // this === nowy obiekt
const secondInstance = new (Clazz.bind(toBind))()); // this === nowy obiekt

Wywołanie z new ma tak wysoki priorytet, że nadpisuje nawet this ustawiony za pomocą metody bind.

Funkcje strzałkowe — arrow functions

ECMAScript 6 / 2015, czyli standard na bazie którego powstają implementacje JavaScript, dał nam do dyspozycji nowy sposób definiowania funkcji — funkcje strzałkowe. Główną cechą takich funkcji, oprócz skondensowanej składni, jest fakt, że this jest w nich ustawiany w sposób leksykalny i zależy od miejsca, w którym taka funkcja została zdefiniowana.

Widzisz więc zmianę w porównaniu do standardowego mechanizmu działania this w JavaScript. this w funkcji strzałkowej zawsze wskazuje na to samo, co w funkcji „powyżej”. Oznacza to, że gdy przekazujesz callback do jakiejś biblioteki, albo wywołujesz setTimeout z wnętrza metody w klasie, nie musisz się martwić, że this zostanie zgubiony. Będzie on wskazywał na to, na co wskazywałby w tej funkcji (lub na window dla arrow function zdefiniowanej w zakresie globalnym, poza inną funkcją).

function arrowReturner() {
  // this w arrow function poniżej będzie wskazywał na to, na co wskazywałby w tej linijce
  return () => {
    return this;
  };
}

var firstObj = {
  a: 2
};

var secondObj = {
  a: 3
};

var bar = arrowReturner.call(firstObj);

bar(); // this === firstObj
bar.call(secondObj); // this === firstObj
new bar(); // Uncaught TypeError: bar is not a constructor

Funkcje strzałkowe nie dają możliwości nadpisania this w jakikolwiek sposób — ostatecznie zawsze zostaną wykonane z tym oryginalnym. Co ciekawe, jest to zasada tak restrykcyjna, że wywołanie arrow function jako konstruktora kończy się błędem.

Warto jednak pamiętać, że powyższy przykład jest mało życiowy. Głównym zastosowaniem funkcji strzałkowych są wszelkiego rodzaju callbacki i w praktyce raczej nie udostępniasz ich na zewnątrz zwracając referencję.

Podsumowanie

Określenie czym będzie this w wykonywanej funkcji wymaga od Ciebie znalezienia miejsca jej definicji oraz bezpośredniego wywołania. Następnie możesz skorzystać z tych 5 zasad w kolejności od najważniejszej do najmniej ważnej:

  1. arrow function — użyj this z otaczającego scope
  2. wywołanie z new — użyj nowo tworzonego obiektu
  3. call/apply/bind — użyj przekazanego obiektu
  4. wywołanie z kropką, jako metoda — użyj obiektu, na którym została wywołana
  5. domyślnie — undefined w strict mode, obiekt globalny w innym wypadku

Mam nadzieję, że dzięki temu artykułowi odpowiedź na pytanie „czym jest this” stanie się dla Ciebie chociaż trochę łatwiejsza. Jeśli w swojej karierze natrafiłaś/eś na jakieś ciekawe lub zabawne problemy związane z wartością this, podziel się nimi w komentarzu!

  • Jako uzupełnienie świetnego artykułu dodam tylko taki mały przykład:

    //Składnia metody Array.prototype.map:
    //arr.map( callback function [, thisObject] );

    const arr = [5,10];
    const thisObj = { x: 100 };

    arr.map( ( number ) => number * this.x, thisObj ); [NaN, NaN]

    arr.map( function( number ) {
    return number * this.x;
    }, thisObj ); [500, 1000]

    Oczywiście w strict mode dostaniemy błąd, dlatego celowo nie owijałem tego w IIFE z ‚use strict’. W praktyce w sumie nie korzystałem jeszcze ze wskazywania drugiego argumentu dla metod Array.prototype ale jak już jesteśmy w temacie this to można wspomnieć, że użycie arrow function nie pozwala na to. Jest to oczywiście wynikiem precyzyjnego wskazywania this dla arrow function co opisałeś i nie ma od tego wyjątków 🙂
    Pozdrawiam

    • wojtiku

      To kolejny – świetny – przykład pułapki w jaką można wpaść w odniesieniu do this. Dzięki! Przy jego okazji wspomniałbym o dwóch rzeczach jakie przychodzą mi do głowy.

      Z jednej strony w praktyce do `map` pewnie przekazalibyśmy właśnie arrow function, ale bez wskazania drugiego argumentu. Będąc w jakimś komponencie czy instancji „klasy” najczęściej chcemy użyć `this` z kontekstu wyżej. W takim wypadku `this` wskazywałby na spodziewaną instancję. Dokładnie po to powstały arrow functions.

      Druga rzecz, o której warto pamiętać to fakt, że w ogromnej większości środowisk produkcyjnych taki kod by zadziałał. Dlaczego? Osobiście nie znam nikogo, kto używa elementów języka dostępnych od ES6/2015 bez transpilacji do ES5. W praktyce więc taki kod, zanim dotrze do przeglądarki, będzie zawierał zwykłą funkcję i wszystko zadziała tak jak… dla zwykłej funkcji :).

      Warto o tym pamiętać gdyż może się okazać, że za kilka lat nasza aplikacja nadal będzie musiała być transpilowana… aby działać poprawnie. Właśnie przez takie „kwiatki”.

      • heh, a wiesz że nie pomyślałem o tym co wypluwa babel 🙂 czlowiek codziennie używa i z czasem rutyna daje o sobie znać… ale zawsze mamy tez node gdzie nie zawsze trzeba robic transpilacje, chyba ze np. robimy ssr react itp.

      • Akurat teraz (ale jestem niemal pewien, że kiedyś tak nie było) babel robi to poprawnie 🙂 Tzn. podany kod skompiluje się do: `number * undefined.x;` (bo babel zawsze kompiluje do „use strict”) co zakończy się wyjątkiem.

        • A faktycznie, rzadko sprawdzam sobie jak wygląda kod po babelowaniu (ale ze mnie polonista :P) ale faktycznie, daje undefined.
          Tak swoją drogą to ciekawym projektem musiało być stworzenie Babela i testów do niego 🙂