ES6 - obiekty

W tym bardzo krótki rozdziale spojrzymy na pewne usprawnienia, które pojawiły się w ES6, a które dotyczą obiektów.

Małe usprawnienia

Pierwsze z nich dotyczy sposobu deklarowania właściwości. Załóżmy, że mamy 2 stałe:


const name = "Marcin";
const pet = "Szamson";

Gdybyś chciał teraz stworzyć obiekt z właściwościami o takich samych nazwach jak powyższe stałe, zapisał byś to pewnie w taki sposób:


var ob = {
    name : name,
    pet : pet
}

W ES6 możemy takie powtórki uprościć:


const ob = {
    name,
    pet
}

Podobne zmiany tyczą się metod dla literałów. W ES6 już nie musimy używać słowa function, a wystarczą tylko nawiasy.


//ES5
var ob = {
    name : "Marcin",
    pet : "Szamson",
    printName : function() {
        console.log("Nazywam się: " + this.name);
    }
}

//ES6
const ob = {
    name : "Marcin",
    pet : "Szamson",
    printName() {
        console.log(`Nazywam się: ${this.name}`);
    }
}

Ostatnie usprawnienie, które omówimy dotyczy deklaracji nazw kluczy w obiektach.

Do właściwości i metod obiektu możesz odwoływać się przez notację kropki ale też za pomocą nawiasów kwadratowych:


const ob = {
    name : "Marcin",
    pet : "Szamson",
    printName() {
        console.log(`Nazywam się: ${this.name}`);
    }
}

ob.name;
ob['name'];

ob.printName();
ob['printName']();

Ma to zastosowanie w przypadkach, gdy nazwy kluczy nie są prawidłowymi zmiennymi


const ob = {
    name : "Marcin",
    pet : "Szamson"
}

ob['print the name'] = function() {
    console.log("Nazywam się: " + this.name);
}

ob['my surname'] = "Nowak";

ob.print the name; //oczywisty błąd
ob['print the name']();

ob['my surname']; //Nowak

Jak widzisz w powyższym kodzie żeby dodać taką właściwość do obiektu musimy to robić poza nim.
W ES6 możemy to robić bezpośrednio w deklaracji obiektu:


const ob = {
    name : "Marcin",
    pet : "Szamson",
    ['my surname'] = "Nowak",
    ['print the name']() {
        console.log("Nazywam się: " + this.name);
    }
}

Object.assign()

Metoda Object.assign(ob1, ob2...) kopiuje wszystkie przeliczalne właściwości z jednego lub więcej obiektów do obiektu docelowego. Kopiowanie rozpoczyna się od obiektów z prawej strony (końcowe parametry) i idzie w lewą stronę.

Jeżeli obiekt po lewej stronie ma już daną właściwość, zostanie ona nadpisana wartością obiektu po prawej stronie.

W wyniku dostajemy zmodyfikowany obiekt przekazany w pierwszym parametrze, który ma scalone właściwości i metody wszystkich obiektów przekazanych w kolejnych parametrach.


const ob1 = {
    name : "Marcin",
    age : 10
}

const ob2 = {
    name : "Piotr",
    surname : "Nowak"
}

Object.assign(ob1, ob2);

console.log(ob1); //{name: "Piotr", age: 10, surname: "Nowak"}

Bardzo często jako pierwszy parametr przekazuje się pusty obiekt, dzięki czemu zamiast modyfikować bezpośrednio obiekty wejściowe, zyskujemy nowy obiekt ze scalonymi składowymi pozostałych obiektów:


const ob1 = {
    name : "Marcin",
    age : 10
};

const ob2 = {
    name : "Piotr",
    surname : "Nowak"
}

const ob3 = Object.assign({}, ob1, ob2);

console.log(ob1); //{name: "Marcin", age: 10}
console.log(ob2); //{name: "Piotr", surname: "Nowak"}
console.log(ob3); //{name: "Piotr", surname: "Nowak", age: 10}

Metodę assign bardzo często wykorzystuje się do tworzenia kopii obiektów.

Jak wiesz, obiekty działają referencyjnie. Oznacza to, że jeżeli dany obiekt będzie podstawiony pod dwie zmienne, to zmiana jego składowej za pomocą którejkolwiek zmiennej zmieni go we wszystkich zmiennych:


const obA = { name : "Piotr" }
const obB = obA;

obB.name = "Marcin";

console.log(obA.name, obB.name); //Marcin, Marcin

Aby móc swobodnie zmieniać składowe obiektu B musimy pod zmienną obB podstawić nie ten sam obiekt, a jego duplikat:


const obA = { name : "Piotr" }

const obB = Object.assign({}, obA);
obB.name = "Marcin";

console.log(obA.name, obB.name); //Piotr, Marcin

Ważną rzeczą jest to, że Object.assign() klonuje tylko płaskie obiekty. Jeżeli któraś z właściwości zawiera w sobie obiekt, zostanie skopiowana tylko referencja do tego obiektu. Oznacza to, że zagnieżdżone obiekty nie są już klonowane, a tylko przekazywana jest do nich referencja:


const ob = {
    name : "Marcin",
    pet : {
        name : "Feliks",
        kind : "cat"
    }
}

const ob2 = Object.assign({}, ob);
ob2.pet.name = "Super Szamson";
ob2.pet.kind = "pies";

console.log(ob.pet.name, ob2.pet.name); //Super Szamson, Super Szamson
console.log(ob.pet.kind, ob2.pet.kind); //pies, pies

Żeby sklonować głęboko obiekt, musimy zastosować inne techniki - np.


const ob = {
    name : "Marcin",
    pet : {
        name : "Feliks",
        kind : "cat"
    }
}

const ob2 = JSON.parse(JSON.stringify(ob));

ob2.pet.name = "Super Szamson";
ob2.pet.kind = "pies";

console.log(ob.pet.name, ob2.pet.name); //Feliks, Super Szamson
console.log(ob.pet.kind, ob2.pet.kind); //kot, pies

Ale nawet to rozwiązanie nie jest w 100% bezpieczne, ponieważ jest wrażliwe na bardziej skomplikowane dane jak np. właściwości z obiektami typu RegExp czy Date.

Jeżeli zdarzy się sytuacja, że będziesz potrzebował kopiować naprawdę skomplikowane obiekty, wtedy warto zainteresować się rozwiązaniami takimi jak https://lodash.com/docs/4.17.4#cloneDeep

Ciekawy artykuł na temat kopiowania obiektów znajduje się pod adresem https://dassur.ma/things/deep-copy/.

Object.assign() w praktyce

Poza duplikowaniem obiektów, Object.assign() bardzo dobrze sprawdza się przy tworzeniu konfiguracji naszych obiektów czy pluginów.

Wyobraź sobie, że tworzysz nowy typ obiektów (powiedzmy że plugin), który oczekuje nastu parametrów konfiguracji:


const Slider = function(type, animSpeed, pauseTime, slideSelector, afterPrevFn, afterNextFn) {
    this.type = type;
    this.animSpeed = animSpeed;
    this.pauseTime = pauseTime;
    this.slideSelector = slideSelector;
    this.afterPrevFn = afterPrevFn;
    this.afterNextFn = afterNextFn;
}

Żeby teraz użyć takiego konstruktora, musiałbyś pamiętać kolejność parametrów:


const slide1 = new Slider("fade", 2000, 2000, "#mainSlide", function() {...}, function() {...});
const slide2 = new Slider("fade", 2000, 2000, "#mainSlide", function() {...}, function() {...});

Jak widzisz, używanie takiego konstruktora nie należy do najprzyjemniejszych, bo każdorazowo musimy pamiętać kolejność wszystkich parametrów, a dodatkowo musimy wszystkie podawać.

O wiele lepszym rozwiązaniem jest stworzenie domyślnej konfiguracji takiego pluginu.

Przy tworzeniu nowego obiektu za pomocą powyższego konstruktora zamiast wypisywać wszystkie parametry, będziemy przekazywać tylko jeden parametr - obiekt konfiguracji, w którym będziemy podawać tylko właściwości, które chcemy zmienić:


const Slider = function(opts = {}) {
    //sprawdzam czy przy wywołaniu został przekazany obiekt
    if (opts instanceof Object && opts.constructor === Object) {
        throw Error('Parametr opcji musi być obiektem');
    }

    //domyślna konfiguracja
    const defaultOptions = {
        type : "fade",
        animSpeed : 1000,
        pauseTime : 5000,
        slideSelector : ".slider",
        afterPrevFn : function() {},
        afterNextFn : function() {}
    }

    //jeżeli ktoś przekaże do naszego obiektu opts
    //to jego właściwości nadpiszą domyślne wartości
    //w wyniku dostaniemy this.options ze scalonymi właściwościami
    this.options = Object.assign({}, defaultOptions, opts);

    console.log(this.options);
}

Przy takim podejściu używanie naszego konstruktora jest o wiele przyjemniejsze, bo wywołując go możemy podawać tylko to, co nas interesuje. Cała reszta zostanie wzięta z domyślnej konfiguracji:


const slide = new Slider({
    animSpeed : 3000,
    afterPrevFn : function() {
        console.log('Zakończyłem animację przewinięcia do poprzedniego');
    }
});

console.log(slide.options)

setPrototypeOf

Wracamy na chwile do naszego ukochanego działu, czyli prototypów.

Jeżeli mamy konstruktor, który ma jakieś właściwości i metody i na jego bazie stworzymy obiekty:


const Car = function(type, color) {
    this.type = type;
    this.color = color;
}
Car.prototype.drive = function() {
    console.log("Jadę!");
}

const car1 = new Car('Toyota', 'niebieski');
const car2 = new Car('Mercedes', 'czerwony');

To każdy obiekt stworzony na bazie konstruktora Car dostaje prototyp Car.

Czasami będziesz chciał zmienić prototyp danej instancji na inny. W powyższym przykładzie przypuśćmy, że taką czynność chcesz wykonać dla car2:


const fastCar = function() {
    drive : function() {
        console.log('Jadę bardzo szybko!');
    }
}

Object.setPrototypeOf(car2, fastCar)

car1.drive(); //Jadę!
car2.drive(); //Jadę bardzo szybko!

Podobną metodą jest getPrototypeOf (posłużyliśmy się ją już tutaj), która zwraca obiekt prototypu danego obiektu:


const car1 = new Car('Toyota', 'niebieski');
console.log(Object.getPrototypeOf(car1));

Niektórzy zamiast tych metod stosują bezpośrednie przypisania do właściwości __proto__, ale nie jest to zalecane na rzecz użycia powyższej metod.

Trening czyni mistrza

Poniżej zamieszczam kilka zadań, które w ramach ćwiczenia możesz wykonać:

  1. Za pomocą nowej notacji stwórz obiekt literał translateToEN który zawiera:
    - właściwość wordsPL - tablica 5 słów po Polsku
    - właściwość wordsEN - tablica 5 słów po Angielsku
    - metodę translate - która przyjmie 1 parametr - pojedyncze słowo.
    Jeżeli to słowo znajduje się w tablicy wordsEN to metoda niech zwraca słowo o danym indeksie z tablicy wordsPL. Jeżeli danego słowa nie ma w tablicy wordsPL, funkcja niech zwraca "nieznane słowo"
    
                    const translateToEN = {
                        wordsPL : ["kot", "pies", "chomik", "ninja", "wiewiórka"],
                        wordsEN : ["cat", "dog", "hamster", "ninja", "squirell"],
                        translate(word) {
                            if (this.wordsPL.indexOf(word) !== -1) {
                                return this.wordsEN[this.wordsPL.indexOf(word)];
                            } else {
                                return "nieznane słowo";
                            }
                        }
                    }
                    
  2. Stwórz duplikat powyższego obiektu o nazwie translateToPL.
    Nadpisz metodę translate. Tym razem niech metoda sprawdza istnienie słowa w tablicy wordsEN. Jeżeli takie słowo jest, niech zwraca odpowiednie słowo z wordsEN. W przeciwnym razie niech zwraca "unknown word"
    
                    const translateToPL = Object.assign({}, translateToEN);
                    translateToPL.translate = function(word) {
                        if (this.wordsEN.indexOf(word) !== -1) {
                            return this.wordsPL[this.wordsEN.indexOf(word)];
                        } else {
                            return "unknown word";
                        }
                    }