Патерни JavaScript у дії

19 хв. читання

У цій статті ми розглянемо:

Породжувальні патерни:

  • «Фабрика» (Factory);
  • «Абстрактна фабрика» (Abstract Factory);
  • «Прототип» (Prototype);
  • «Будівельник» (Builder).

Структурні патерни:

  • «Адаптер» (Adapter);
  • «Декоратор» (Decorator);
  • «Компонувальник» (Composite).

Поведінкові патерни:

  • «Ланцюжок обов'язків» (Chain of Responsibility);
  • «Спостерігач» (Observer);
  • «Посередник» (Mediator).

Наприкінці статті ви знатимете, як і коли краще використовувати шаблони проєктування. Поговоримо про них детальніше.

Патерн «Фабрика» (Factory)

Найбільш базовий патерн у JavaScript. Можливо, ви вже використовували його, не знаючи цього. Патерн «Фабрика» — це функція (яка називається фабричною), котра відповідає за створення подібних об'єктів.

Розглянемо простий приклад. Припустимо, ми створюємо застосунок для компанії, котра доставляє товари. Будь-яке замовлення на доставлення ми обробляємо так:

class Delivery {
  constructor(address, item) {
    this.address = address;
    this.item = item;
  }
  //... інший код класу
}
 
const delivery = new Delivery(
  "178 Deli Ave, Toronto, ON, Canada",
  "Nitendo 360"
);

Ми створили дуже базовий клас. Передаємо йому два параметри: адресу та безпосередньо товар, який доставлятимемо, і створюємо екземпляр цього класу.

Припустимо, ми розпочали власний бізнес з доставленням велосипедом. Після декількох місяців роботи база наших клієнтів збільшується, з'являються більш віддалені пункти призначення. Тож ми вирішуємо: якщо відстань буде більшою за 10 км — доставляємо товар машиною, а якщо більше за 50 км — вантажівкою.

Як об'єднати нашу бізнес-логіку, щоб потім додавати зміни було набагато легше? Наприклад, ми захочемо організовувати доставлення повітрям чи морем.

🏭 Патерн «Фабрика» поспішає на допомогу...

Ми можемо створити фабричний метод, який динамічно створюватиме екземпляр способу доставлення. Розглянемо код нижче:

function deliveryFactory(address, item) {
  // ... спочатку розрахуємо відстань
  if (distance > 10 && distance < 50) {
    return new DeliveryByCar(address, item);
  }
 
  if (distance > 50) {
    return new DeliveryByTruck(address, item);
  }
 
  return new DeliveryByBike(address, item);

В нашій фабричній функції ми спочатку розраховуємо відстань, залежно від якої створюємо екземпляр класу відповідного способу доставлення. Розглянемо згадані класи:

class DeliveryByBike {
  constructor(address, item) {
    this.address = address;
    this.item = item;
  }
}
 
class DeliveryByTruck {
  constructor(address, item) {
    this.address = address;
    this.item = item;
  }
}
 
class DeliveryByCar {
  constructor(address, item) {
    this.address = address;
    this.item = item;
  }
}

Ми можемо створити екземпляр відповідного класу за допомогою фабричного методу.

const newDelivery = deliveryFactory(
 "121 baily ave, Toronto, canada",
 "nitendo 360"
)

Так ми інкапсулюємо логіку доставлення і код стає логічно об'єднаним. Якщо треба, ми можемо додати нові класи способів доставлення, не зачіпаючи наявну логіку. Це стосується й змін вже наявного коду.

Наприклад, нам необхідно додати чайові за доставлення вантажем. Для цього створюємо відповідне поле для класу вантажівки.

Патерни JavaScript у дії

Абстрактна «Фабрика»

Ми можемо піти далі й додати ще один рівень абстракції. Але навіщо нам це?

Уявімо, що наша бізнес-логіка стає складнішою. Ми додали дві нові фічі: доставлення в той самий день та експрес-доставлення. Перша фіча доступна лише для деяких адрес, тож тепер нам потрібна додаткова перевірка, а експрес-доставлення доступна лише вантажівкою та повітрям. Тож нам хотілося б додати ще один рівень абстракції над нашою «Фабрикою».

Поглянемо на діаграму:

Патерни JavaScript у дії

Тепер у нас є функція абстрактної «Фабрики», яка викликатиме наші попередні фабричні функції та здійснюватиме ініціалізацію.

Розглянемо реалізацію:

function abstractFactory(address, item, options) {
 if (options.isSameday) {
   return sameDayDeliveryFactory(address, item);
 }
 if (options.isExpress) {
   return expressDeliveryFactory(address, item);
 }
 
 return deliveryFactory(address, item);
}

Фабричні функції sameDayDeliveryFactory та expressDeliveryFactory створюють та повертають відповідний екземпляр класу, як і раніше.

Патерн «Прототип» (Prototype)

JavaScript базується на прототипному наслідуванні, саме тому згаданий патерн найбільш популярний в цій мові. Найімовірніше, ви вже мали з ним справу.

Патерн «Прототип» створює новий екземпляр об'єкта — клонує його від певного прототипу. Ми додаємо нові властивості до створеного об'єкта під час виконання програми. Розглянемо детальніше на прикладі.

const vehicle = {
 type: "",
 transport(item) {
   console.log("transport", item);
 }
};
 
const car = Object.assign({}, vehicle);
car.type = "civic";
car.engine = "v6";
 
const car2 = Object.assign({}, vehicle);
car2.type = "ford";
car2.engine = "v8";
 
car.transport("some apples");
car2.transport("bananas");
 
console.log("1 --->>>>", car);
console.log("2 --->>>>", car2);

У нас є об'єкт vehicle — наш прототип. Ми створюємо екземпляри car та car2 прототипу та автоматично успадковуємо його поведінку. Тож код виводить таке:

Патерни JavaScript у дії

Патерн «Будівельник» (Builder)

Цей породжувальний патерн використовується для створення складних об'єктів крок за кроком. Так ви можете створити різні конфігурації того ж об'єкта, використовуючи той самий конструктор. Найчистіша реалізація цього патерну представлена в мовах з класичним ООП — на зразок Java чи C#.

Розглянемо певний сценарій, аби краще розуміти, навіщо нам патерн «Будівельник». Припустимо, у нас є застосунок, де користувач має налаштовуваний профіль. Ми даємо користувачу можливість змінювати:

  • розташування меню;
  • аватар;
  • тему;
  • пункти меню.

Для цього створюємо клас:

class Profile {
 constructor(
   menuLocation = "top",
   borders = "normal"
   theme = "dark",
   profileImage = "default.jpg"
 ) {
   this.menuLocation = menuLocation;
   this.borders = borders;
   this.theme = theme;
   this.profileImage = profileImage;
 }
}

Звісно, все працює. Однак скоро ми зрозуміємо, що необхідно передати більше аргументів, оскільки наш застосунок стає дедалі популярнішим, тож користувачам треба більше фіч.

class Profile {
 constructor(
   menuLocation = "top",
   borders = "normal",
   theme = "dark",
   profileImage = "default.jpg",
   backgroundImage = "default.png",
   backgroundColor = "cyan",
   profileFont = "Roboto Mono"
 ) {
   this.menuLocation = menuLocation;
   this.borders = borders;
   this.theme = theme;
   this.profileImage = profileImage;
   this.backgroundImage = backgroundImage;
   this.backgroundColor = backgroundColor;
   this.profileFont = profileFont;
 }
}
 
new Profile(null, "soft", "dark", null, null, "red");

Наш клас росте, тож збільшується кількість параметрів, котрі ми передаємо. Як наслідок, отримуємо вразливий код, який складно тестувати.

Саме тут час використати патерн Builder.

class ProfileBuilder {
 constructor(){ }
 
 /* Оголошуємо всі кроки, необхідні для створення профілю*/
 
 setMenu(position) {
     this.menuLocation = position;
     return this;
 }
 
 setBorders(style) {
     this.borders = style;
     return this;
 }
 
 setTheme(style) {
     this.theme = style;
     return this;
 }
 
 setCoverImage(url) {
     this.coverImage = url;
     return this;
 }
 
 setBackgroundColor(color) {
     this.backgroundColor = color;
     return this;
 }
 
 setMenuColor(color) {
     this.menuColor = color;
     return this;
 }
 
 setProfileFont(fontFamily) {
     this.profileFont = fontFamily;
     return this;
 }
 
 /* Також може називатись getProfile() */
 build() {
     return new Profile(this);
 }

Ми також змінимо клас Profile, аби він приймав екземпляр класу будівельника, замість окремих параметрів.

class Profile {
 constructor(builder) {
   this.menuLocation = builder.menuLocation;
   this.borders = builder.borders;
   this.theme = builder.theme;
   this.profileImage = builder.profileImage;
   this.backgroundImage = builder.backgroundImage;
   this.backgroundColor = builder.backgroundColor;
   this.profileFont = builder.profileFont;
 }
}

Тепер ми можемо явно викликати клас будівельника для створення об'єкта і налаштовувати його, як нам потрібно.

const userA = new ProfileBuilder()
 .setBorders("dotted")
 .setMenu("left")
 .setProfileFont("San Serif")
 .build();
 
console.log("user A", userA);

Тож Builder дає нам кращий рівень абстракції та контроль над розширенням застосунку для більш гнучких до налаштувань та тестувань об'єктів.

Патерн «Адаптер» (Adapter)

Патерн використовується для перетворення одного інтерфейсу на інший. Припустимо, у нас є застосунок для бронювання квитків на літак. У нього вже є користувачі, а старий інтерфейс має такий вигляд:

// старий інтерфейс
function TicketPrice() {
 this.request = function(start, end, overweightLuggage) {
   // розрахунок вартості ...
   return "$150.34";
 };
}

Тепер припустимо, що ми додаємо фічу, яка розраховуватиме вартість квитків зі знижкою. Ми хочемо дати знижку лише певним користувачам. Тож тільки частина юзерів користуватиметься новим інтерфейсом.

// новий інтерфейс
function NewTicketPrice() {
 this.discount = function(discountCode) { /* обробляємо облікові дані */ };
 this.setStart = function(start) { /* встановлюємо пункт вильоту */ };
 this.setDestination = function(destination) { /* встановлюємо відстань */ };
 this.calculate = function(overweightLuggage) {
     // розраховуємо вартість ...
     return "$120.20";
 };
}

Але ми не хочемо вносити якихось змін до API застосунку, який вже в продакшені. То як нам використати новий інтерфейс з наявним API? Для цього використаємо інтерфейс-адаптер, одразу розглянемо його реалізацію:

// інтерфейс-адаптер
function TicketAdapter(discount) {
 var pricing = new NewTicketPrice();
 
 pricing.discount(discount);
 
 return {
   request: function(start, end, overweightLuggage) {
     pricing.setStart(start);
     pricing.setDestination(end);
     return pricing.calculate(overweightLuggage);
   }
 };
}
 
const pricing = new TicketPrice();
const discount = { code: "asasdw" };
const adapter = new TicketAdapter(discount);
 
// старий розрахунок вартості квитків
var price = pricing.request("Toronto", "London", 20);
console.log("Old --->>> " + price);
 
// новий розрахунок вартості квитків
price = adapter.request("Toronto", "London

Замість того щоб використовувати новий інтерфейс напряму, ми створюємо адаптер, тому клієнтський застосунок може працювати без значних змін.

Патерн «Декоратор» (Decorator)

В класичному об'єктно-орієнтованому програмуванні цей патерн використовується, щоб розширити функціональність певних класів. У JavaScript ми можемо реалізувати патерн за допомогою функцій вищого порядку.

Зверніть увагу: не плутайте патерн «Декоратор» з декораторами в Typescript/ES7, які ми не розглядаємо у цій статті.

Припустимо, у нас є застосунок для магазину сендвічів. Візьмемо такий код:

let ingredients = {
  bread: "plain",
  meat: "chicked",
  mayo: true,
  mastard: true,
  lettuce: true,
  type: "6 inch"
};
class Sandwich {
  constructor(ingredients) {
    this.ingredients = ingredients;
  }
 
  getPrice() {
    return 5.5;
  }
 
  setBread(bread) {
    this.ingredients.bread = bread;
  }
}
 
let chickenSandwitch = new Sandwich(ingredients);
console.log(chickenSandwitch.getPrice());

Іноді клієнти замовляють великий сендвіч з усіма начинками. Звичайно, він відрізняється від стандартного, тому деякі властивості треба змінити, але не всі. В такому випадку ми не хочемо переписувати наявний код, а використати доступну кодову базу якомога більше. Для цього ми створюємо функцію-декоратор, яка забезпечить додаткову логіку:

function footLong(ingredients) {
 let sandwich = new Sandwich(ingredients);
 
 sandwich.ingredients.type = "foot long";
 
 let price = sandwich.getPrice();
 sandwich.getPrice = function() {
   return price + 3;
 };
 return sandwich;
}
 
let footlong = footLong(ingredients);
console.log("---->>", footlong.getPrice());
console.log("type", footlong.ingredients);

Ми можемо мати будь-яку кількість користувацьких об'єктів. Наприклад, якщо треба створити великий сендвіч з телятиною, ми просто додаємо ще одну функцію-декоратор. Розглянемо код:

function beefSandwichFootLong(ingredients) {
 let sandwich = footLong(ingredients);
 sandwich.ingredients.meat = "beef";
 let price = sandwich.getPrice();
 sandwich.getPrice = function() {
   return price + 1;
 };
 return sandwich;
}
 
let beefFootLong = beefSandwichFootLong(ingredients);
console.log("Beef foot", beefFootLong.ingredients.meat);
console.log("Beef foot price", beefFootLong.getPrice());

Патерн «Декоратор» дозволяє наслідувати принцип DRY (Do not repeat yourself — не повторюй себе). Завдяки функціональній композиції ми використовуємо код повторно.

Патерн «Компонувальник» (Composite)

Цей патерн дозволяє створити деревоподібну структуру коду, де базовий об'єкт чи клас утворюватиме кореневий вузол. Цей вузол матиме безліч дочірніх вузлів, які наслідуватимуть функціонал батьківського вузла та додаватимуть щось своє. Дочірні вузли можуть мати власних нащадків, які також розширюють функціонал.

Спершу все звучить досить заплутано. Розберемося на структурі уявного застосунку.

Створимо базовий клас Vehicle, який матиме нащадків, що наслідуватимуть його властивості та перевизначать деякі з них.

class Vehicle {
 constructor() {
   this.type = "Car";
   this.model = "Honda Civic";
   this.engine = "v6";
   this.speed = "180 km/h";
   this.weight = "4000 kg";
   this.engineType = "gasoline";
 }
 
 drive() {
   console.log(`driving ${this.type} ${this.model} with ${this.speed}`);
 }
 
 refuel() {
   console.log("Fueling with regular gas");
 }
 
 getEngine() {
   console.log(`Getting Engine ${this.engine}`);
 }
}
 
const generic = new Vehicle();
generic.getEngine();

Що ж буде кореневим вузлом у нашому дереві? Припустимо, ми хотіли б створити машину, вантажівку та позашляховик, які загалом схожі за структурою, але мають кількісні та якісні відмінності. Ми можемо реалізувати наслідування з ключовим словом специфікації ES6 extends. Так ми формуємо деревоподібну структуру класів.

class Car extends Vehicle {
drive() {
   super.drive();
   console.log("Also a car :) subclass");
 }
}
 
const car = new Car();
console.log(car.drive());
 
class Truck extends Vehicle {
 constructor() {
   super();
   this.type = "Truck";
   this.model = "Generic Truck Type";
   this.speed = "220 km/h";
   this.weight = "8000 kg";
 }
 
 drive() {
   super.drive();
   console.log(`A ${this.type} with max speed of ${this.speed}`);
 }
}
 
const truck = new Truck();
console.log("truck", truck.drive());

Як бачимо, ми перевизначили деяку поведінку батьківського класу, а також додали окремий функціонал для кожного класу. Так само ви можете створювати й більш комплексних нащадків. Основна перевага патерну у тому, що він дозволяє створювати складні сутності з вже наявних, тобто кількість коду у вашому застосунку значно зменшується.

Патерн «Ланцюжок обов'язків» (Chain of Responsibility)

Розглянемо популярний патерн у Node.js. Ви, мабуть, знайомі з ним, якщо колись використовували міделвар у Node.js. Його суть в тому, що запит не турбується про функцію, якою він буде опрацьований, натомість він проходить через ланцюжок функцій, серед яких є саме та, яка виконує обробку.**

Патерни JavaScript у дії

Розглянемо простий приклад:

// обробник запиту яблука
function AppleProcess(req) {
 if (req.payload === "apple") {
   console.log("Processing Apple");
 }
}
 
// обробник запиту апельсина
function OrangeProcess(req) {
 if (req.payload === "orange") {
   console.log("Processing Orange");
 }
}
 
// обробник запиту манго
function MangoProcess(req) {
 if (req.payload === "mango") {
   console.log("Processing Mango");
 }
}
const chain = [AppleProcess, OrangeProcess, MangoProcess];

Вище ми визначили 3 функції-обробники та розмістили в масиві chain.

Далі ми створимо функцію processRequest та пробіжимось ланцюжком з тестовим запитом.

function processRequest(chain, request) {
 let lastResult = null;
 let i = 0;
 chain.forEach(func => {
   func(request);
 });
}
 
let sampleMangoRequest = {
 payload: "mango"
};
processRequest(chain, sampleMangoRequest);

Як бачимо, принцип роботи дуже подібний до міделварів в Node.js. Коли маємо справу з асинхронними даними (наприклад, міделвар Express.js), цей патерн надзвичайно корисний.

Патерн «Спостерігач» (Observer)

Один з найбільш популярних патернів ES6/ES7. Суть патерну в тому, що є так званий суб'єкт, який взаємодіє зі списком інших об'єктів (спостерігачів) і автоматично сповіщає їх про будь-які зміни стану.

Патерн «Спостерігач» є базою для таких реактивних фреймворків як React та Vue. Розглянемо його суть на прикладі.

Уявімо, що є застосунок, де нам треба оновити декілька елементів DOM у відповідь на певну подію (введення користувача, клік мишкою).

Припустимо, ми хочемо змінити стан A, B та C, коли користувач пише щось в поле вводу. Тож A, B та C слухатимуть будь-які зміни та оновлюватимуть свій стан відповідно. Тепер після вводу трьох літер ми не хочемо змінювати C. Тож нам треба перестати відстежувати подію для C, тобто відписатися від функції у класі Observable. Розпочнемо саме з його реалізації:


class Observable {
 constructor() {
   this.observers = [];
 }
 subscribe(f) {
   this.observers.push(f);
 }
 unsubscribe(f) {
   this.observers = this.observers.filter(subscriber => subscriber !== f);
 }
 notify(data) {
   this.observers.forEach(observer => observer(data));
 }
}

Тепер ми можемо створити декілька DOM-посилань:

const input = document.querySelector(".input");
const a = document.querySelector(".a");
const b = document.querySelector(".b");
const c = document.querySelector(".c");

З посиланнями ми можемо створити декілька спостерігачів:

const updateA = text => a.textContent = text;
const updateB = text => b.textContent = text;
const updateC = text => c.textContent = text;

Далі формуємо екземпляр Observable та підписуємось на створені елементи:

JavaScript
const headingsObserver = new Observable();
 
// підписуємось на спостережувані об'єкти
headingsObserver.subscribe(updateA);
headingsObserver.subscribe(updateB);
headingsObserver.subscribe(updateC);
 
// сповіщаємо їх про оновлення даних при певній події 
input.addEventListener("keyup", e => {
 headingsObserver.notify(e.target.value);
});

Ми розробили дуже спрощену версію патерну «Спостерігач», на якому базується концепція реактивного програмування. Його активно використовують і такі бібліотеки, як React.js та RxJS.

Патерн «Посередник» (Mediator)

Наш застосунок утворюють класи. З розвитком застосунку збільшується й кількість класів, тож зв'язок між ними дедалі ускладнюється. Це призводить до того, що код важко читати, тестувати та підтримувати.

З патерном «Посередник» класи можуть спілкуватися один з одним через об'єкт-посередник. Тоді класи не залежать один від одного напряму, тож і зв'язування послаблюється.

Припустимо, ми створюємо чат (наприклад, Slack). Учасники можуть приєднуватись до певних чат-кімнат, зареєструвавшись в них. Вони також можуть надсилати повідомлення один одному. Об'єкт чату в такому застосунку виконуватиме роль посередника. Учасник лише посилає повідомлення, а обробка його роутинга лежить на чат-кімнаті.

Як, наприклад, у цьому коді:

class Participant {
  constructor(name) {
    this.name = name;
    this.chatroom = null;
  }
 
  send(message, to) {
    this.chatroom.send(message, this, to);
  }
 
  receive(message, from) {
    log.add(from.name + " to " + this.name + ": " + message);
  }
}
 
let Chatroom = function() {
  let participants = {};
 
  return { 
    register: function(participant) {
      participants[participant.name] = participant;
      participant.chatroom = this;
    },
  
    send: function(message, from, to) {
      if (to) {                      // одиничне повідомлення
        to.receive(message, from);    
      } else {                       // широкомовне повідомлення
        for (let key in participants) {   
          if (participants[key] !== from) {
            participants[key].receive(message, from);
          }
        }
      }
    }
  };
};
 
// логування
log = (function() {
    let log = '';
 
    return {
        add: msg => { log += msg + '\
'; },
        show: () => { alert(log); log = ''; }
    }
})();
 
function run() {
  let yoko = new Participant('Yoko'),
      john = new Participant('John'),
      paul = new Participant('Paul'),
      ringo = new Participant('Ringo'),
      chatroom = new Chatroom(),
 
  chatroom.register(yoko);
  chatroom.register(john);
  chatroom.register(paul);
  chatroom.register(ringo);
 
  yoko.send('All you need is love.');
  yoko.send('I love you John.');
  john.send('Hey, no need to broadcast', yoko);
  paul.send('Ha, I heard that!');
  ringo.send('Paul, what do you think?', paul);
 
  log.show();
}
 
run();
Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.6K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація