Robimy kompendium wiedzy o programowaniu OOP w czystym JavaScript. Podsumowanie najważniejszych zagadnień, z jakimi mieliśmy do czynienia.

Będziemy zajmować się szybkim przypomnieniem zagadnień takich jak:

  • Słówko kluczowe new
  • Klasy przed ES6
  • Klasy po ES6
  • Prywatność properties za pomocą WeakMap
  • Prywatność metod za pomocą WeakSet
  • Unikanie referencji kołowej za pomocą WeakSet
  • Metody statyczne
  • Gettery/Settery
  • Iterator klasy

Słówko kluczowe new:

  • Funkcje konstruujące możemy wołać z new i bez new
  • Funkcja konstruująca posiada new.target
  • Klasę możemy wołać tylko ze słówkiem kluczowym new
  • Klasie możemy zrobić proxy, pułapka apply może przechwycić zawołanie klasy bez new
  • Jedyny typ danych, który można wywołać TYLKO bez słówka new, to Symbol

Ok, przykład funkcji konstruującej i new.target:

function Person(name, age){

  if(!new.target)
      return new Person(name, age);
    
  this.name = name;
  this.age = age;
}

let jim =  Person("Jim", 20);

console.log(jim);
//Object { name: "Jim", age: 20 }

Tu new.target sprawdza, czy zostało użyte słówko new. Jeżeli nie, to poprawnie woła tę funkcję i ten wynik zwraca. Ale new.target może także pokazać kto woła konstruktor.

I myliłem się – w klasach też mamy new.target. Ale nie używamy go do „naprawiania” sytuacji, w której ktoś zapomniał użyć słówka kluczowego new, to jest niemożliwe, bo bez new nie wywoła się konstruktor!

Jasne, w proxy dla klasy można słuchać na metodę apply i wtedy to poprawić, zaraz to zrobimy, najpierw przykład new.target w konstruktorach:

class A {
  constructor() {
    console.log(new.target.name);
  }
}

class B extends A {
  constructor() {
    super();
  }
}

const a = new A(); // Logs "A"
const b = new B(); // Logs "B"

Jak widać czysty JS nie taki zły i ma ogromne możliwości, jak już się w temat wgłębić. A co z „poprawianiem” klasy, aby dało się utworzyć obiekt klasy bez new?

Cóż, nasza klasa ES6+:

class Person {

    constructor(firstName, lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }

    sayHello() {
        return `${this.firstName} ${this.lastName}`;
    }

}

Można jej zrobić proxy. Poza takimi pułapkami jak get, set, has, są jeszcze dwie pułapki funkcyjne:

  • construct, jak ktoś zawoła z new obiekt, do którego proxy robimy
  • apply, jak ktoś zawoła jak funkcję obiekt, do którego proxy robimy

Przykład proxy dla construct:

const ReverseProxy = new Proxy(Person, {

    construct(target, args){
        return new target(args[1], args[0]);
    }

});

let person2 = new ReverseProxy("Kowalski", "Jan");
console.log(person2.sayHello());
//Jan Kowalski

Przykład proxy dla apply, które poprawia fakt, że wołamy klasę bez new:

const PersonProxy = new Proxy(Person, {

    apply(target, thisArg, argumentsList) {
        return new target(...argumentsList);
    }

});

let person = PersonProxy("Jan", "Kowalski");

Ok, przeglądając internet znalazłem fajny github o nazwie robertbunch, naprawdę fajne kody. Jest tam przykład symbolów i setterów/getterów w JS:

const CARCOLOR = Symbol();
console.log(CARCOLOR)
const CARMODEL = Symbol();
const CARYEAR = Symbol();

class Car{
  constructor(color, model, year){
    // requires bracket syntax
    this[CARCOLOR] = color
    this[CARMODEL] = model
    this[CARYEAR] = year
  }
  get color(){
    console.log("getting", this._color)
    return this[CARCOLOR]
  }
  set color(newColor){
    console.log("setting color", newColor)
    this[CARCOLOR] = newColor
  }
}

myCarDeets = ["red","Volvo",2018]
var myCar = new Car(...myCarDeets);
console.log(myCar[CARCOLOR])

Wyjaśnienie:

  • Jak chcemy property name np. ze znakami, które nie nadają się na nazwę zmiennej, albo dynamiczne (zmienna przechowuje property name) to używamy nawiasów kanciastych
  • Symbol zawsze wywołujemy bez new
  • Każde zawołanie symbol bez argumentu utworzy unikalną wartość
  • Każde zawołanie symbol z argumentem zwróci wartość unikalną ale z tym samym – tą samą (symbol dla 'blabla’ zawsze będzie ten sam)
  • Po to to wszystko, aby nie było kolizji nazw gdy mamy dynamiczne wartości jako property name
  • gettery i settery znamy już, znamy też ich ograniczenia, zatem jak chcemy jakąś lepszą kontrolę, to potrzebujemy proxy i handlery, już to przerabialiśmy

Klasy przed ES6 – używają funkcji konstuujących, można je zawołać bez new, ale wtedy nic pod this się nie przypisze. Zwyczaj (tylko zwyczaj), że nazwy ich są wielką literą.

Uwaga – tylko to, co przypiszemy pod this zostanie zapisane. Jakiś let czy const albo inny var – nie. To znaczy przestanie istnieć i dostaniemy obiekt z tym co pod this przypisaliśmy.

Przykład z internetu:

// The old way
function SuperHero(name, strength, speed, weapon, cape){
	this.name = name;
	this.strength = strength;
	this.speed = speed;
	this.weapon = weapon;
	this.cape = cape; 

	// this.goodHero = true;
	// this.powerUp = function(){
	// 	this.strength += 5;
	// }
}

SuperHero.prototype.goodHero = true;
SuperHero.prototype.powerUp = function(){
	this.strength += 5;
}

let hero1 = new SuperHero("Hank", 10,5,"Fist",true);
// hero1.name = "Ed"
hero1.powerUp();
console.log(hero1)

I zasada jest taka, że to, co współdzielone przez wszystkie instancje, czyli properties, których nie ustala użytkownik w konstruktorze tylko jakieś defaultowe i jednakowe, później nie zmieniane, oraz funkcje, to jest w prototypie.

Bo inaczej każdy obiekt będzie miał to definiowane. To, co jest wspólne i może być w jednym miejscu, co widzimy w powyższym przykładzie. Ok, jedziemy dalej:

class SuperHero{
	constructor(name, strength, speed, weapon, cape){
		// add _ and it makes it a pseudo private property
		this._name = name;
		this._strength = strength;
		this._speed = speed;
		this._weapon = weapon;
		this._cape = cape; 

	}
	powerUp(){
		this.strength += 5;		
	}
	get name(){
		console.log("Getting Name");
	}
	set name(newName){
		console.log("Setting name");
		this._name = newName;
	}
	static goodHero(){
		return true;
	}
}

Tak się robi klasy ES6. Bawiliśmy się klasami, jak pisaliśmy web componenty, więc mam nadzieję, że to dla nas nie pierwszyzna. Ok, statyczne metody:

class DoMath{
	static add(x,y){
		return x + y;
	}
	static subtract(x,y){
		return x - y;
	}
	static square(x){
		return x * x;
	}
}


let x = DoMath.add(2,5)
console.log(x)

Też powinniśmy znać. Mam też nadzieję, że statyczne zmienne pamiętamy. W funkcjach mogliśmy je uzyskać przez closure (pamiętamy jeszcze funkcję once?).

Natomiast dziwnie z nich korzystamy w JS:

static staticProperty = 'static value';

  static staticMethod() {
    console.log(this.staticProperty);
  }

This w każdym języku programowania sugerowałoby, że mamy do czynienia z property obiektu i byłoby nie do zawołania w metodzie statycznej, ale mniejsza. To i tak wszystko umowne, w JS nie ma prawdziwej obiektówki, gdzie jest podział na klasę i obiekt. Tutaj wszystko jest obiektem, wszystko ma konstruktor i prototyp, to tylko cukier składniowy.

Nieważne. Prywatność można niby osiągnąć przez #, choć jak już na takie rewiry wchodzimy, to lepiej chyba zainstalować sobie TSa i tam mamy pełne OOP i pełne typowanie, nawet generyczne typy, jak w C# po prostu.

Prywatność możemy osiągnąć WeakMapą:

const Person = (function() {

    const privateData = new WeakMap();

    return class Person {

        constructor(firstName, lastName) {
            privateData.set(this, {
                firstName,
                lastName
            });
        }

        sayHello() {
            let data = privateData.get(this);

            return `${data.firstName} ${data.lastName}`;
        }

    };

})();


let person3 = new Person("Jan", "Kowalski");

console.log(person3.firstName);

console.log( person3.sayHello() );

Jest jeszcze coś takiego, jak prywatność przez moduł, tworzymy zmienną, powyżej definicji klasy, ale tej zmiennej nie eksportujemy. Wada jest taka, że ta zmienna jest współdzielona przez wszystkie instancje tej klasy, o tym napisałem osobny artykuł po tym artykule (edit z przyszłości).

Ok, WeakSet, jak zrobić, aby nie dało się „pożyczać” metod:

const people = new WeakSet();

class Person {
    constructor(firstName, lastName) {

        this.firstName = firstName;
        this.lastName = lastName;

        people.add(this);

    }

    sayHello() {

        if ( !people.has(this) ) {
            throw new TypeError("Person.prototype.sayHello wywołana na niekompatybilnym obiekcie");
        }

        return `${this.firstName} ${this.lastName}`;

    }
}

let person4 = new Person("Jan", "Kowalski");

console.log( person4.sayHello() );

Teraz nie możemy zrobić czegoś takiego:

person4 = null;

let person5 = {
    firstName: "Anna",
    lastName: "Nowak"
};

console.log( Person.prototype.sayHello.call(person5) ); //nie można

To są wszystko dobre przykłady, mamy jeszcze ten dziwny co wisi na MDN jako przykład WeakSet, w sumie to ok przykład:

function execRecursively(fn, subject, _refs = new WeakSet()) {
  // Avoid infinite recursion
  if (_refs.has(subject)) {
    return;
  }

  fn(subject);
  if (typeof subject === "object" && subject) {
    _refs.add(subject);
    for (const key in subject) {
      execRecursively(fn, subject[key], _refs);
    }
    _refs.delete(subject);
  }
}

const foo = {
  foo: "Foo",
  bar: {
    bar: "Bar",
  },
};

foo.bar.baz = foo; // Circular reference!
execRecursively((obj) => console.log(obj), foo);

Omawialiśmy, teraz tylko przypominamy. Dobra, z takich jeszcze głupotek, robiliśmy kiedyś class expression:

function createInstance(cls, ...args){
        return new cls(...args);
}


let john = createInstance(class {
    constructor(name, age){
        this.name = name;
        this.age = age;
    }
}, "John", 33);

console.log(john);
//Object { name: "John", age: 33 }

Był też przykład, gdzieś z internetu, jak robić iterator oraz jak zrobić go z generatorem. Najpierw „tradycyjne” podejście:

class Collection {

    constructor(arr) {
        this.arr = arr;
    }

    [Symbol.iterator]() {
       let items = this.arr;
       let index = 0;

        return {
            next: function() {
                return {
                    done: (index === items.length) ? true : false,
                    value: items[index++]
                };
            }
        };
    }

}

Jak się komuś chce tak bawić. Generalnie interfejs iteratora i podejście jest bardzo podobne w innych językach programowania. Oraz we wzorcu projektowym iterator.

Natomiast możemy użyć generatorów jeżeli chcemy dać możliwość iterowania po jakiejś tablicy, do której generatory możemy przecież robić bardzo prosto:

function hasIterator(obj) {
    return obj && typeof obj[Symbol.iterator] === "function";
}

class Collection {

    constructor(arr) {
        this.arr = arr;
    }

    *[Symbol.iterator]() {
        yield *this.arr;
    }

}

let items = new Collection([1,2,3,4,5]);
console.log(hasIterator(items));
//true

for(let el of items) {
    console.log(el);
}

// 1
// 2
// 3
// 4
// 5

Ten hasIterator to też fajny touch, omawialiśmy. I to tyle tytułem jak pisać klasy od strony składniowej. Od strony dobrego kodu musimy robić to co ja – przeglądać internet, poszukiwać dobrych wzorców, umieć dobry od złego odróżnić, jakoś to zweryfikować, cały czas rozwijać się w pisaniu dobrego kodu OOP nawet w czystym JS (a może przede wszystkim, już te różne kombinacje powinny nam pokazać, że pisać dobry OOP w JS to większe wyzwanie i większa kreatywność).

Mamy jeszcze metody klasy Object, ja przypomnę assign, który możemy użyć, aby móc mixiny mieć w klasach JS:

export function mixin(...mixins) {

    const fn = function() {};

    Object.assign(fn.prototype, ...mixins);

    return fn;

}

To z takiego projektu DOM Wrapper jest, też żeśmy te mixiny omawiali. Sam ten projekt kiedyś robiłem, jak się JS uczyłem. Taki mixin to jest wyeksportowany obiekt zawierający metody.

Takich obiektów można mieć wiele w wielu miejscach a potem zrobić jedną klasę, extends mixin(mixin1,mixin2,mixin3). Nigdy nigdzie nie widziałem lepszego podejścia, aby mieć mixiny w czystym JS.

I to by było na tyle, więcej JS w drodze!