Rozszerzanie funkcjonalności podstawowych obiektów w JavaScript

by Sandra Wyzujak

TLDR: Budowanie klas dziedziczących po wbudowanych obiektach oferuje atrakcyjne rozwiązania, które mogą być dobrą alternatywą dla rozszerzania prototypu, własnych klas lub funkcji. Szczególnie użyteczne i wygodne jest dziedziczenie po tablicach.

Niestandardowe operacje na podstawowych obiektach w JavaScript to zadanie wielu narzędziowych bibliotek pokroju Lodasha. Oferują one bardzo dużo dodatkowych funkcji ułatwiających pracę z tekstem, liczbami, tablicami i obiektami. W przeszłości te dodatkowe funkcjonalności były realizowane na różne sposoby, a wraz z nadejściem ES 2015 pojawiły się interesujące alternatywy. W tym artykule postaram się pokrótce przedstawić zarówno stare jak i nowe podejścia. 

Użycie funkcji

Funkcja, która przyjmuje bazowy obiekt i operuje na nim. Rozwiązanie, które bardzo często można spotkać z powodu łatwości implementacji i przejrzystości. W każdym miejscu użycia taką funkcję należy zaimportować. Tak właśnie działają dzisiejsze biblioteki typu Lodash, Underscore czy Ramda.

function getVowels(str){
  return str?.match(/[aeiou]/gi) ?? [];
}

console.log(getVowels(`Let\'s release on Friday!`)); // ['e', 'e', 'e', 'a', 'e', 'o', 'i', 'a']

Przed 2015 – bezpośrednie użycie prototypu

Przed rewolucją, jaką wprowadził ES 2015, dziedziczenie odbywało się poprzez bezpośrednie manipulowanie prototypem. Nowe “klasy” oraz dziedziczenie pomiędzy nimi odbywało się poprzez deklarowanie funkcji i dodawanie nowych metod do jej prototypu. W ten sposób można było również manipulować prototypem wbudowanych obiektów, 

np. String, Number, Array. Dzięki temu wszystkie instancje automatycznie otrzymywały rozszerzenie.

String.prototype.getVowels = function getVowels() {
  return this.match(/[aeiou]/gi) ?? [];
}

console.log(‚Hello there’.getVowels()); // [‚e’,’o’,’e’,’e’]
console.log(new String(‚Ah’).getVowels()); // [‚A’]
console.log(String(‚General Kenobi’).getVowels()); // [‚e’, ‚e’, ‚a’, ‚e’, ‚o’, ‚i’]

Number.prototype.isOdd = function isOdd() {
  return (this.valueOf() & 1) === 1;
}

console.log((1).isOdd()); // true
console.log(2..isOdd()); // false
console.log(new Number(31).isOdd()); // true
console.log(Number(44).isOdd()); // false

Boolean.prototype.isTrue = function isTrue(){
  return this.valueOf() === true;
}

console.log(true.isTrue()); // true
console.log(false.isTrue()); // false
console.log(new Boolean().isTrue()); // false
console.log(new Boolean(true).isTrue()); // true
console.log(new Boolean(0).isTrue()); // false
console.log(Boolean(‚a’).isTrue()); // true

Array.prototype.multiply = function multiply(num){
  this.forEach((item, index) => (this[index] = item * num));

  return this;
}

console.log(new Array(1,2).multiply(2)); // [2, 4]
console.log([2,3].multiply(2)); // [4,6]

Jednak to, że wszystkie istniejące i przyszłe instancje otrzymywały to rozszerzenie, było jednocześnie bardzo poważnym problemem. Każdy mógł coś dodać, w dowolnym skrypcie, który mógł udostępnić każdemu. To powodowało konflikty, nadpisywanie funkcji o tej samej nazwie. Takie podejście ma do dzisiaj swoje konsekwencje (np. Array “includes” zamiast “contains” dzięki mootools). Dla lubiących historię, można o tym poczytać tutaj:

Rozszerzanie prototypów z ramki – sandboxing

Oczywiście istniały pewne obejścia, jak rozszerzanie tylko obiektów z <iframe>, ale to powodowało jeszcze więcej problemów – trzeba było bardzo pilnować, aby zawsze używać rozszerzonego elementu z <iframe>, a nie z bazowego obiektu w głównym oknie.

const  iframe = document.createElement(‚iframe’);

document.body.appendChild(iframe);

const sandbox = window.frames[0];

sandbox.Array.prototype.multiply = function multiply(num) {
  this.forEach((item, index) => (this[index] = item * num));

  return this;

}

console.log(new Array(0,1,2).multiply); // undefined
console.log(new sandbox.Array(0,1,2).multiply(2)); // [0, 2, 4]

Klasy w ES 2015 i późniejszych wydaniach

6 wersja specyfikacji wydana w 2015 wprowadziła olbrzymią rewolucję w wielu aspektach języka, między innymi wprowadzając składnię pozwalającą na łatwe deklarowanie klas oraz ich dziedziczenie. Wraz z kolejnymi edycjami dodawane są nowe możliwości, w tym pola/metody statyczne oraz prywatne.

class Item {

  constructor(){
    this.id = nextId++;
  }

}

class CustomString extends Item {

  #value // private class field – recent addition

  constructor(value = ”){
    super();
    this.#value = value;
  }

  getVowels(){
    return this.#value.match(/[aeiou]/gi) ?? [];
  }

}

console.log(new CustomString(‚Awsome’).getVowels()); // [‚A’, ‚o’, ‚e’]

Klasy pozwoliły na znaczącą redukcję kodu, zlikwidowanie błędów związanych z użyciem prototype. Definicja pozwala na dodanie wielu nowych metod, tak jak przy rozszerzaniu prototypu. Nie ma potrzeby importowania wszędzie funkcji, których chcielibyśmy użyć na naszym stringu.

Niestety, aby móc użyć już istniejących metod klasy string, trzeba je dodawać do naszej klasy.

Sytuacja komplikuje się również, kiedy nasza klasa ma jako wartość tablicę:

class MultiplyArray {

  #value

  constructor(…value){
    this.#value = new Array(…value);
  }

  multiply(num){
    this.#value.forEach((item, index) => (this.#value[index] = item * num));

    // Well, return what? class instance or the value?
    return this.#value;
  }

}

console.log(new MultiplyArray(1,2,3).multiply(2));

W przypadku metod takich jak “multiply” powyżej, nie ma jednoznacznej reguły, co właściwie taka metoda ma zwracać. Może zwracać instancję klasy, lecz wtedy musimy się dodatkowo postarać, aby wszystkie metody takie jak “filter” też zwracały nową instancję klasy. Jeśli będziemy zwracać naszą prywatną “value”, to nie tylko udostępnimy ją publicznie, ale również stracimy dostęp do wszystkich innych metod oraz wartości, które mieliśmy zadeklarowane w klasie.

Dziedziczenie po wbudowanych obiektach.

Problemy opisane powyżej zostały zauważone również przez twórców specyfikacji ES 2015. Założyli oni, że istniejące już obiekty wbudowane w specyfikację również można dziedziczyć. Pomyślnie można dziedziczyć po: Array, Boolean, Date, Error, Function, Map, Number, Promise, RegExp, Set, String oraz Typed Arrays. Z różnych względów nie da się dziedziczyć po: Math, JSON, Reflect, Proxy, Symbol oraz BigInt. Możliwe jest również rozszerzanie HTMLElement oraz jego pochodnych, jednak jest to temat na inny artykuł.

Wsparcie przeglądarek

Wszystkie ostatnie wersje przeglądarek, za wyjątkiem IE 11, posiadają wsparcie  dla takiego dziedziczenia. Problem IE rozwiązuje Babel 7. Oprócz kodu, który jest potrzebny do definiowania klas oraz dziedziczenia po klasach zewnętrznych, dodatkowy kod do dziedziczenia po wbudowanych obiektach jest niewielki.

Dziedziczenie prymitywów: Number, String, Boolean.

Jak już wyżej wspominałem, dziedziczenie po BigInt niestety nie jest możliwe. Ciekawostką jest, że aby serializować BigInta do JSON, trzeba dodać toJSON do jego prototypu. Inaczej dostaniemy elegancki błąd mówiący o tym, że przeglądarka nie wie, jak serializować BigInta, co w sumie ma sens.

class CustomNumber extends Number {
  isOdd(){
    return (this.valueOf() & 1) === 1;
  }
}

console.log(new CustomNumber(1).isOdd()); // true
console.log(new CustomNumber(2).isOdd()); // false
console.log(new CustomNumber('3').isOdd()); // true
console.log(new CustomNumber('ab').isOdd()); // false

Rozwiązanie to pozwala na dodawanie nowych metod oraz pól i jednoczesne cieszenie się już istniejącymi (lub tymi dodanymi w przyszłości) możliwościami klasy. 

Oczywiście, jeśli zdecydujemy się nadpisać wartość takiej liczby zwykłą liczbą, stracimy nasze rozszerzenie.

class CustomNumber extends Number {
  isOdd(){
    return (this.valueOf() & 1) === 1;
  }
}

console.log(new CustomNumber(1).isOdd()); // true
console.log(new CustomNumber(2).isOdd()); // false
console.log(new CustomNumber('3').isOdd()); // true
console.log(new CustomNumber('ab').isOdd()); // false

Dziedziczenie po tablicach

Chcąc zmienić wartość String, Number lub Boolean, bardzo łatwo popełnić błąd. Dużo łatwiejsze i bezpieczniejsze jest operowanie na tablicach, gdzie manipulujemy zawartością, bez zmieniania samej tablicy.

class CustomArray extends Array {
  multiply(num){
    this.forEach((item, index) => (this[index] = item * num));

    return this;
  }

  clone() {
    return new this.constructor(...this);
  }

  clone2(){
      return this.constructor.from(this);
  }
}

console.log(new CustomArray(1,2).multiply(2)); // [2, 4]

/* Changing value */
const customArray = new CustomArray(1, 2, 3);

console.log(customArray); // [1, 2, 3]
customArray[2] = 10;

console.log(customArray.multiply(2)); // [2, 4, 20]

Rozszerzona tablica zachowuje wszystkie dotychczasowe właściwości bazowej tablicy. Ponadto, wszystkie jej metody, które zwracają nową instancję tablicy, zwrócą instancję naszej klasy:

const customArray = new CustomArray(1, 2, 3);
const filteredArray = customArray.filter((value) => value !== 2);

console.log(filteredArray.multiply(2)); // [2, 6]
console.log(filteredArray instanceof Array, filteredArray instanceof CustomArray); // true, true
console.log(typeof filteredArray); // object
console.log(Array.isArray(filteredArray)); // true

To zachowanie można zmienić poprzez deklarację symbolu “species”.

Tablice – praktyczne zastosowanie

Takie rozszerzone tablice znakomicie nadają się do implementowania własnych kolekcji, struktur matematycznych, np. wektorów czy macierzy. Poniżej przykładowa, skrócona implementacja wektora.

class Vec3 extends Array {
  constructor(...args) {
    if (args.length !== 3 && args.length !== 0) {
      throw Error('Vec3 requires 0 or 3 arguments');
    }

    const components = args.length ? args : [0, 0, 0];

    super(...components);
  }

  get x() {
    return this[0];
  }

  set x(x) {
    this[0] = x;
  }

  get y() {
    return this[1];
  }

  set y(y) {
    this[1] = y;
  }

  get z() {
    return this[2];
  }

  set z(z) {
    this[2] = z;
  }

  clone() {
    return new this.constructor(...this);
  }

  distance(vec3) { // don't care if we got Vec3 or normal array
    return Math.hypot(this.x - vec3[0], this.y - vec3[1], this.z - vec3[2]);
  }

  add(vec3) {
    this.x += vec3[0];
    this.y += vec3[1];
    this.z += vec3[2];

    return this;
  }

  substract(vec3) {
    this.x -= vec3[0];
    this.y -= vec3[1];
    this.z -= vec3[2];

    return this;
  }

  divide(vec3) {
    this.x /= vec3[0];
    this.y /= vec3[1];
    this.z /= vec3[2];

    return this;
  }

  normalize() {
    const len = Math.hypot(...this);

    return this.divide([len, len, len]);
  }

}

console.log(new Vec3(1,2,3)) // [1, 2, 3]

Dzięki takiej konstrukcji bardzo dużo operacji na wektorach staje się proste, bez utraty podstawowych własności tablic:

const pointFirst = new Vec3(2, 3, 4);
const pointSecond = new Vec3(4, -1, 2);

console.log(pointFirst.distance(pointSecond));  // 4.898979485566356
console.log(pointFirst.add(pointSecond)); // [6, 2, 6]
console.log(pointFirst.substract(pointSecond));  // [2, 3, 4]
console.log(pointFirst.divide(pointSecond)); // [0.5, -3, 2]

pointSecond.x = 1;
pointSecond.y = 1;
pointSecond.z = 1;

// '0.5773502691896258,0.5773502691896258,0.5773502691896258'
console.log(pointSecond.normalize().join(',')); 

Podsumowanie

Od początku swojego istnienia JavaScript oferował olbrzymią elastyczność w implementacji rozwiązań. Patrząc wstecz, łatwo sobie wyobrazić, że gdyby dziedziczenie po wbudowanych obiektach było możliwe od początku istnienia języka, biblioteki takie jak jQuery czy Backbone (Collection) z pewnością skorzystałyby z możliwości rozszerzenia błędów czy też tablic. 

Klasy dziedziczące po wbudowanych obiektach pozwalają na bezpieczne dodawanie nowych rozszerzeń, które będą zawsze dostępne wraz z instancją, bez potrzeby importowania dodatkowych funkcji. Tworząc nowy zestaw narzędzi bądź bibliotekę jest to rozwiązanie warte rozważenia.


Jakub, Senior JavaScript Developer

Zapalony fan aplikacji webowych i JavaScriptu od początku jego istnienia. Prywatnie uwielbia piesze wycieczki, a w domu gra na pianinie dla rodziny i buduje kolejne meble dla swoich kotów.