Factory, Singleton, Adapter, Decorator, Observer, Iterator, Module в JavaScript

Factory, Singleton, Adapter, Decorator, Observer, Iterator, Module  в JavaScript
Photo by Patrick Tomasso / Unsplash

Разработката на софтуер винаги е била свързана с предизвикателства и проблеми. Често срещаните проблеми водят до повтарящи се решения. За да се избегнат тези проблеми и да се подобри качеството на софтуерния код, инженерите използват добре известните "Design Patterns" (Шаблони на проектиране). Тези шаблони са повтарящи се решения на често срещани проблеми и са обобщени, за да могат да се приложат в различни сценарии.

Различните шаблони за проектиране на софтуер имат своите конкретни сценарии на приложение, където могат да бъдат използвани, включително в JavaScript.

Factory

Factory се използва, когато имаме обект с множество подобни подобекти, които могат да бъдат създадени с помощта на обща функция или клас. Вместо да създаваме обекти ръчно, можем да използваме фабрика, която да генерира обекти за нас. Това ни позволява да отделим логиката за създаване на обектите от кода, който ги използва.

class Car {
  constructor(model, year) {
    this.model = model;
    this.year = year;
  }

  getInfo() {
    return `${this.model} (${this.year})`;
  }
}

class CarFactory {
  createCar(model, year) {
    return new Car(model, year);
  }
}

const carFactory = new CarFactory();

const car1 = carFactory.createCar("BMW", 2019);
const car2 = carFactory.createCar("Tesla", 2021);

console.log(car1.getInfo()); // BMW (2019)
console.log(car2.getInfo()); // Tesla (2021)

В този пример създаваме клас Car, който има модел и година на производство. Създаваме също клас CarFactory, който има метод createCar, който приема модел и година на производство на кола и връща нов обект от тип Car. Така можем да създадем нови коли, като използваме фабриката вместо да създаваме обектите ръчно.

Singleton

Singleton се използва, когато имаме клас, който искаме да съществува в единствена инстанция в целия живот на приложението. Това ни позволява да имаме точен контрол върху това как се създава и използва обектът, както и да гарантираме, че обектът няма да бъде създаден повторно, ако вече съществува.

class Singleton {
  constructor() {
    if (Singleton.instance) {
      return Singleton.instance;
    }
    Singleton.instance = this;
  }

  doSomething() {
    console.log("Doing something...");
  }
}

const singleton1 = new Singleton();
const singleton2 = new Singleton();

console.log(singleton1 === singleton2); // true

singleton1.doSomething(); // Doing something...
singleton2.doSomething(); // Doing something...

В този пример създаваме клас Singleton, който има конструктор, който проверява дали съществува вече инстанция на класа. Ако има, връща тази инстанция. Ако няма, създава нова инстанция и я връща. Така можем да гарантираме, че в целия живот на приложението ще има само една инстанция на класа.

Adapter

Adapter се използва, когато имаме две класа с различни интерфейси и искаме да ги свържем за да могат да работят заедно. Можем да използваме адаптер, който да превръща интерфейса на единия клас в интерфейса на другия клас.

class OldClass {
  request(message) {
    console.log(`Old class received ${message}`);
  }
}

class NewClass {
  send(message) {
    console.log(`New class sent ${message}`);
  }
}

class Adapter {
  constructor(oldClass) {
    this.oldClass = oldClass;
  }

  send(message) {
    this.oldClass.request(message);
  }
}

const oldClass = new OldClass();
const adapter = new Adapter(oldClass);
const newClass = new NewClass();

adapter.send("Hello from adapter!");

newClass.send("Hello from new class!");

В този пример имаме два класа - OldClass и NewClass, като първият има метод request, а вторият има метод send. Създаваме също клас Adapter, който приема инстанция на OldClass и има метод send, който извиква метода request на OldClass. Така можем да използваме адаптера, за да свържем OldClass с NewClass.

Decorator

Decorator се използва, когато искаме да добавим допълнителна функционалност към вече съществуващ обект, без да променяме неговия код. Можем да използваме декоратор, който да обгради съществуващия обект и да добави нова функционалност към него.

class Pizza {
  constructor() {
    this.description = "Pizza";
    this.cost = 10;
  }

  getDescription() {
    return this.description;
  }

  getCost() {
    return this.cost;
  }
}

class ToppingDecorator {
  constructor(pizza) {
    this.pizza = pizza;
  }

  getDescription() {
    return this.pizza.getDescription();
  }

  getCost() {
    return this.pizza.getCost();
  }
}

class Cheese extends ToppingDecorator {
  constructor(pizza) {
    super(pizza);
    this.description = "Cheese";
    this.cost = 2;
  }

  getDescription() {
    return `${this.pizza.getDescription()} with ${this.description}`;
  }

  getCost() {
    return this.pizza.getCost() + this.cost;
  }
}

class Pepperoni extends ToppingDecorator {
  constructor(pizza) {
    super(pizza);
    this.description = "Pepperoni";
    this.cost = 3;
  }

  getDescription() {
    return `${this.pizza.getDescription()} with ${this.description}`;
  }

  getCost() {
    return this.pizza.getCost() + this.cost;
  }
}

let pizza = new Pizza();
console.log(pizza.getDescription()); // Pizza
console.log(pizza.getCost()); // 10

pizza = new Cheese(pizza);
console.log(pizza.getDescription()); // Pizza with Cheese
console.log(pizza.getCost()); // 12

pizza = new Pepperoni(pizza);
console.log(pizza.getDescription()); // Pizza with Cheese with Pepperoni
console.log(pizza.getCost()); // 15

В този пример имаме клас Pizza, който представлява базовата функционалност - пица с цена 10. Създаваме също клас ToppingDecorator, който има същите методи като Pizza и приема инстанция на Pizza в конструктора си. Създаваме два класа, които наследяват ToppingDecorator - Cheese и Pepperoni. В тези класове добавяме допълнителна функционалност - сирене и пиперони, които съответно добавят 2 и 3 към цената на пицата.

В основната част на програмата създаваме инстанция на Pizza и я обграждаме с декоратори Cheese и Pepperoni, за да добавим допълнителна функционалност към пицата.

Observer

Observer позволява да се известят един или повече обекта, когато състоянието на друг обект се промени. Това позволява на приложението да бъде изградено от отделни компоненти, които не са свързани тясно помежду си.

В JavaScript можем да използваме обекти или функции като наблюдатели, които могат да се абонират за събития (event) и да получават известия, когато те се случат. Например, можем да имаме модел (Model) в нашето приложение, който съдържа данни и методи за работа с тях. Когато тези данни се променят, моделът изпраща известия до всички абонати, че е настъпила промяна.

Ето пример, който демонстрира използването на шаблона Наблюдател в JavaScript:

class Observable {
  constructor() {
    this.observers = [];
  }

  subscribe(observer) {
    this.observers.push(observer);
  }

  unsubscribe(observer) {
    this.observers = this.observers.filter(obs => obs !== observer);
  }

  notify(data) {
    this.observers.forEach(observer => observer.update(data));
  }
}

class Observer {
  update(data) {
    console.log(`Data has been updated: ${data}`);
  }
}

// Създаваме инстанция на нашия наблюдаем обект
const observable = new Observable();

// Създаваме инстанция на наблюдателя
const observer = new Observer();

// Наблюдателят се абонира за известия от наблюдаемия обект
observable.subscribe(observer);

// Наблюдаемият обект известява всички абонати за промяната в данните
observable.notify("New data has arrived!");

// Наблюдателят се отписва от известията на наблюдаемия обект
observable.unsubscribe(observer);

В този пример, Observable е нашата наблюдаема клас, който има методи за абониране, отписване и уведомяване на всички наблюдатели. Observer е клас, който има метод update(), който се изпълнява, когато състоянието на наблюдаемия обект се промени. Създаваме инстанция на Observable, добавяме Observer като абонат, уведомяваме го за промяна и после го отписваме.

Друг начин за използване на шаблона Наблюдател в JavaScript е чрез използване на събития (events), които се поддържат във всеки DOM елемент в браузъра.

const button = document.querySelector('button');

button.addEventListener('click', () => {
  console.log('Button clicked!');
});

В този пример се добавя събитие click към бутона, което ще се изпълни, когато бутона бъде кликнат. Когато това се случи, се изпълнява колбек функцията, която просто извежда съобщение в конзолата.

Можем да използваме и кастомни събития, които да известят всички наблюдатели, че е настъпила промяна. Това може да бъде постигнато чрез използване на клас EventTarget, който е базов клас за обекти, които могат да изпращат събития.

class Observable extends EventTarget {
  constructor() {
    super();
    this.data = null;
  }

  setData(data) {
    this.data = data;
    this.dispatchEvent(new CustomEvent('data-updated', { detail: data }));
  }
}

class Observer {
  constructor() {
    this.handleDataUpdated = this.handleDataUpdated.bind(this);
  }

  observe(observable) {
    observable.addEventListener('data-updated', this.handleDataUpdated);
  }

  unobserve(observable) {
    observable.removeEventListener('data-updated', this.handleDataUpdated);
  }

  handleDataUpdated(event) {
    console.log(`Data has been updated: ${event.detail}`);
  }
}

// Създаваме инстанция на нашия наблюдаем обект
const observable = new Observable();

// Създаваме инстанция на наблюдателя
const observer = new Observer();

// Наблюдателят се абонира за известия от наблюдаемия обект
observer.observe(observable);

// Наблюдаемият обект променя данните си и известява всички абонати
observable.setData("New data has arrived!");

// Наблюдателят се отписва от известията на наблюдаемия обект
observer.unobserve(observable);

В този пример, Observable се разширява с EventTarget и извиква dispatchEvent() метода, за да извести всички абонати, че е настъпила промяна. Observer се разширява с методи за абониране и отписване на събития, както и с колбек функция, която се изпълнява, когато събитието се стартира.

Iterator

Iterator позволява обхождане на елементите на дадена колекция, без да е необходимо да знаем подробности за имплементацията на тази колекция. Вместо това, използваме обект, който познава подробностите и ни предоставя начин да обхождаме елементите.

В JavaScript можем да създадем итератори за всякакъв вид колекции, като например масиви, обекти или дори произволни структури от данни. Ето няколко примера за създаване на итератор в JavaScript:

// Итератор за масив
function ArrayIterator(array) {
  let index = 0;
  return {
    next: function() {
      if (index < array.length) {
        return { value: array[index++], done: false };
      } else {
        return { done: true };
      }
    }
  };
}

// Създаване на масив и итериране чрез итератора
const myArray = [1, 2, 3, 4, 5];
const iterator = ArrayIterator(myArray);

let result = iterator.next();
while (!result.done) {
  console.log(result.value);
  result = iterator.next();
}

// Итератор за обект
function ObjectIterator(obj) {
  let keys = Object.keys(obj);
  let index = 0;
  return {
    next: function() {
      if (index < keys.length) {
        let key = keys[index++];
        return { value: { key: key, value: obj[key] }, done: false };
      } else {
        return { done: true };
      }
    }
  };
}

// Създаване на обект и итериране чрез итератора
const myObj = { a: 1, b: 2, c: 3 };
const iterator = ObjectIterator(myObj);

let result = iterator.next();
while (!result.done) {
  console.log(result.value);
  result = iterator.next();
}


В тези примери създаваме два различни итератора - един за масиви и един за обекти. Идеята е да върнем обект, който има метод next(), който връща следващия елемент от колекцията. Този метод връща обект с две свойства - value и done. value съдържа стойността на текущия елемент, а done указва дали сме достигнали края на колекцията или не.

Итераторите са много полезни при обхождането на големи колекции, тъй като ни позволяват да правим това ефективно и да използваме минимално количество памет. Освен това, те позволявaт да итерираме и произволни структури от данни, което може да бъде много полезно в различни приложения.

В ES6 съществува вграден итератор в някои от основните типове данни, като например масиви и обекти. Това означава, че можем да използваме for...of цикли, за да обходим тези колекции:

// Итератор за масив
const myArray = [1, 2, 3, 4, 5];

for (const element of myArray) {
  console.log(element);
}

// Итератор за обект
const myObj = { a: 1, b: 2, c: 3 };

for (const key in myObj) {
  console.log(`${key}: ${myObj[key]}`);
}

Когато използваме for...of цикли, JavaScript автоматично създава итератор за колекцията, която итерираме. Това прави кода по-четим и по-лесен за поддръжка.

Итераторите са мощен инструмент в JavaScript, който ни позволява да обхождаме колекции по ефективен начин, без да знаем подробности за тяхната имплементация. Те могат да бъдат създадени за всякакъв вид колекции, като масиви, обекти или произволни структури от данни. В ES6 имаме вграден итератор за някои от основните типове данни, който прави итерирането по-лесно и по-четимо.

Module

Module е шаблон за проектиране, който ни позволява да организираме кода си в отделни, изолирани модули, като същевременно се гарантира, че всяко име, дефинирано в един модул, не е достъпно в друг модул, освен ако явно не бъде експортирано. Това помага да се избегне конфликт на имената и да се поддържа по-лесна поддръжка на кода.

Съществуват няколко начина за имплементация на Module шаблона в JavaScript, но един от най-разпространените е използването на Immediately Invoked Function Expressions (IIFE). Това са функции, които се извикват незабавно след дефинирането им и се използват за дефиниране на модули.

// myModule.js
const myModule = (() => {
  const privateVariable = 'I am a private variable';
  const publicVariable = 'I am a public variable';

  const privateMethod = () => {
    console.log('I am a private method');
  };

  const publicMethod = () => {
    console.log('I am a public method');
    console.log(`I can access ${privateVariable}`);
    privateMethod();
  };

  return {
    publicVariable,
    publicMethod,
  };
})();

export default myModule;

Тук използваме IIFE (Immediately Invoked Function Expression), за да създадем модул с приватни и публични методи и променливи. За да направим myModule достъпен от други файлове, го експортираме като по подразбиране (export default).

Сега можем да използваме myModule в друг файл:

// index.js
import myModule from './myModule.js';

console.log(myModule.publicVariable); // "I am a public variable"
myModule.publicMethod(); // "I am a public method", "I can access I am a private variable", "I am a private method"

Тук импортираме myModule от файла myModule.js и извикваме неговите публични методи и променливи.

Този пример демонстрира, как можем да използваме ES6 модулите, за да организираме функционалността нашите модули и да ги направим по-лесни за поддръжка и тестване.