dzikowski.github.io

Piszę o IT po polsku, więc z góry przepraszam za zagęszczenie kolokwializmów, ale co w angielskim brzmi naturalnie, w polskim często nie ma nawet odpowiedników.

Kontekstualne this

JavaScript to świetny język, ale chyba jak każdy ma swoje słabe strony. Jedną z rzeczy, których najbardziej nie lubię jest działanie this, które zależy od kontekstu wywołania, a przez to często jest mylące. Jeśli na przykład napiszesz sobie arrow function, w której wnętrzu wywołasz this, nie masz pewności, które this zostanie wykorzystane. Ale zacznijmy od prostego przykładu.

function Demo1() {
  this.ctx = 'Demo1 context';
  console.log(this.ctx);
}

Demo1(); // Uncaught TypeError: Cannot set property 'ctx' of undefined
new Demo1(); // Demo1 context

Na tym przykładzie widać, że użycie słówka new przed funkcją spowodowało, że oprócz tego, że został utworzony obiekt, this przestaje być undefined i wskazuje na utworzony obiekt. (Więcej o new można znaleźć tutaj). Proste, prawda? No to lecimy dalej.

const Demo2 = () => {
  this.ctx = 'Demo2 context';
  console.log(this.ctx);
};

Demo2(); // Demo2 context
new Demo2(); // Uncaught TypeError: Demo2 is not a constructor

Tutaj doskonale widać jedną z fundamentalnych różnic pomiędzy zwykłymi funkcjami w JavaScripcie, a tzw. arrow functions – mianowicie z tych drugich nie da się tworzyć obiektów i nie mają swojego this.

No dobra, ale w jakiś sposób wywołanie this.ctx = 'Demo2 context'; zadziałało? Skąd się wziął this, do którego został wpisany ctx?

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

const Demo3b = () => {
  this.ctx = 'Demo3b context';
  console.log(this.ctx);
};

Demo3a(); // undefined
Demo3b(); // Demo3b context
Demo3a(); // Demo3b context

Jasne. Teraz już wiemy, że w tym wypadku this jest globalne, poza Demo3a i Demo3b. Arrow functions biorą sobie this z zewnątrz. I tu zaczynają się schody.

Spróbujmy na obiektach:

this.ctx = 'global context';

const obj1 = {
  ctx: 'obj1 context',
  log: () => {
    console.log(this.ctx);
  }
};

const obj2 = {
  ctx: 'obj2 context',
  log: function () {
    console.log(this.ctx);
  }
};

obj1.log(); // global context
obj2.log(); // obj2 context
new obj2.log(); // undefined

W przypadku pierwszego obiektu wszystko wydaje się logiczne. Mamy w środku arrow function, a arrow function bierze sobie this z zewnątrz. Dlatego w konsoli pojawiło się global context.

W ostatnim wywołaniu pojawiło się undefined, ponieważ został stworzony obiekt z funkcji obj2.log() (funkcja została wywołana jako konstruktor), a tym samym this wskazuje właśnie na ten obiekt (w którym ctx nie zostało zdefiniowane). Czyli to też w miarę proste.

Ale obj2 context? Z jakiegoś powodu this wskazuje nie na wnętrze funkcji logm tylko na obj2. Z pomocą przychodzi Stack Overflow: Kiedy funkcja jest wywołana jako metoda, wtedy this wskazuje na obiekt, który ma tę metodę.

Wszystko dobrze, ale wróćmy w takim razie do pierwszego obiektu. Bo obj1 to też jest obiekt, prawda? A jeśli tak, to dlaczego arrow function odniosło się do globalnego this, a nie do obj1? Głowy nie dam, ale nasuwa mi się odpowiedź: bo arrow function nigdy nie jest metodą. Niech będzie.

I wreszcie przykład, na którym sam się już kilka razy złapałem – przekazywanie funkcji przez referencję.

function Demo5() {

  this.ctx = 'Demo5 context';

  this.handle = function () {
    console.log(this.ctx);
  };
}

function call(fn) {
  fn();
}

const obj = new Demo5();

obj.handle(); // Demo5 context
call(() => obj.handle()); // Demo5 context
call(obj.handle); // Uncaught TypeError: Cannot read property 'ctx' of undefined

Co tutaj się wydarzyło?

Wygląda na to, że przy przekazaniu metody przez referencję całkowicie zostało utracone this. Zadziałało natomiast opakowanie wywołania funkcją. W jakiś sposób, najpewniej poprzez mechanizm domknięcia (closure), referencja do this została w tym wywołaniu zachowana.

Ale może być jeszcze ciekawiej:

function Demo6() {

  this.ctx = 'Demo6 context';

  this.handle = () => {
    console.log(this.ctx);
  }
}

function call(fn) {
  fn();
}

const obj = new Demo6();

obj.handle(); // Demo6 context
call(() => obj.handle()); // Demo6 context
call(obj.handle); // Demo6 context

Jedyną rzeczą, która została zmieniona, to metoda handle. Teraz w zasadzie nie jest ona już metodą, tylko atrybutem, który jest funkcją. I okazuje się, że tutaj wszystko działa. Dlaczego? Szczerze powiedziawszy, sam do końca nie wiem 🙂.

Ten sam przykład pokażę może jeszcze na klasach, choć tak w zasadzie klasy to tak naprawdę tylko syntactic sugar w JavaScripcie, pod spodem i tak siedzą funkcje.

class Demo7 {

  ctx = 'Demo7 ctx';

  handle1 = () => {
    console.log(this.ctx);
  };

  handle2() {
    console.log(this.ctx);
  };
}

function call(fn) {
  fn();
}

const obj = new Demo7();

call(() => obj.handle1()); // Demo7 context
call(obj.handle1); // Demo7 context
call(() => obj.handle2()); // Demo7 context
call(obj.handle2); // Uncaught TypeError: Cannot read property 'ctx' of undefined

Notka na marginesie: Wszystkie powyższe przykłady były wykonywane w tzw. strict mode, czyli z deklaracją 'use strict';. Korzystałem z Google Chrome w wersji 56.