Первоначально опубликовано в blog.shams-nahid.com

Шаблоны проектирования — это надежное решение распространенных проблем проектирования программного обеспечения. Каждый шаблон можно рассматривать как общий шаблон для конкретной проблемы.

Шаблоны проектирования подразделяются на следующие четыре категории:

  • Творческий шаблон проектирования
  • Шаблон структурного проектирования
  • Шаблон поведенческого проектирования
  • Шаблон архитектурного проектирования

Здесь мы обсудим несколько распространенных шаблонов проектирования с точки зрения JavaScript (или TypeScript).

  • Нулевой объект
  • Фабрика [Творчество]
  • Синглтон [Творческий]
  • Строитель [Творческий]
  • Прототип [Творческий]
  • Декоратор [Структура]
  • Фасад [Структура]
  • Адаптер [Структура]
  • Прокси [Структура]
  • Цепочка ответственности [Поведение]
  • Команда [Поведение]
  • Наблюдатель [Поведение]

Шаблон нулевого объекта

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

Это может случиться в методе getBand, name, который мы передаем как string, не имеет объекта Band. Таким образом, он возвращает неопределенное значение. В этом случае, когда мы пытаемся прочитать genra из бэнда, это дает нам ошибку Cannot read properties of undefined (reading 'genre').

Мы должны добавить дополнительную проверку в объекте, если объект существует только тогда, когда должно быть напечатано genra.

class Band {
    name: string;
    genra: string;js
    constructor(name: string, genra: string) {
        this.name = name;
        this.genra = genra;
    }
}

const bands = [
    new Band('Warfaze', 'Heavy Metal'),
    new Band('Artcell', 'Progressive Rock Metal')
];
const getBand = (name: string) => {
    return bands.find(band => band.name === name);
}
// one way to print genre is, to check the object's existence beforehand
const myBand = getBand('Iron Maiden');
if (myBand !== undefined) {
  console.log(myBand.genra);
}
// otherwise it will throw the following error
// @ts-ignore
console.log(getBand('Iron Maiden').genra); // Cannot read properties of undefined (reading 'genra')

Используя Null Object Pattern, мы можем решить проблему, используя объект Null, в данном случае NullBand. Когда соответствующий нулевой объект не найден, мы используем объект NullBand.

class Band {
    name: string;
    genra: string;
constructor(name: string, genra: string) {
        this.name = name;
        this.genra = genra;
    }
}
class NullBand {
    name: string;
    genra: string;
    constructor() {
        this.name = '';
        this.genra = '';
    }
}
const bands = [
    new Band('Warfaze', 'Heavy Metal'),
    new Band('Artcell', 'Progressive Rock Metal')
];
const getBand = (name: string) => {
    return bands.find(band => band.name === name) || new NullBand;
}
console.log(getBand('Iron Maiden').genra);

Заводской узор

Фабричный шаблон позволяет создать объект, используя другой объект. Он централизовал логику создания объектов.

Например, у нас есть организация, в которой есть два типа сотрудников,

  • Разработчик
  • Тестер

Мы можем создать разработчика и тестировщика следующим образом:

function Developer(name) {
  this.name = name;
  this.jobType = 'Developer';
}

function Tester(name) {
  this.name = name;
  this.jobType = 'Tester';
}
const developer = new Developer('John');
const tester = new Tester('Doe');
console.log(developer); // Developer { name: 'John', jobType: 'Developer' }
console.log(tester); // Tester { name: 'Doe', jobType: 'Tester' }

Вместо использования Factory Pattern мы можем создать фабричный объект EmployeeFactory, что позволит нам создавать новые объекты,

function Developer(name) {
  this.name = name;
  this.jobType = 'Developer';
}

function Tester(name) {
  this.name = name;
  this.jobType = 'Tester';
}
function EmployeeFactory (name, type) {
    switch(type) {
      case 'coding':
        return new Developer(name);
      case 'testing':
        return new Tester(name);
    }
}

const employee = [
  new EmployeeFactory('John', 'coding'),
  new EmployeeFactory('Doe', 'testing')
];
/**
 * Output
 * Name: John Role: Developer
 * Name: Doe Role: Tester
 * /
employee
  .map(({ name, jobType }) => console.log(`Name: ${name} Role: ${jobType}`));

Синглтон шаблон

Могут быть случаи, когда мы не хотим создавать экземпляры класса более одного раза. Вместо этого мы используем один единственный экземпляр класса глобально.

Представьте, что у нас есть менеджер журналов, и мы собираем журналы со всего приложения. Мы хотим хранить все эти журналы в одном объекте, а не использовать несколько объектов. Без синглтона каждый объект хранит свои журналы,

class LogManager {
  constructor() {
    this.logs = [];
  }
  insert(log) {
    this.logs.push(log);
  }
  getLogs() {
    return this.logs;
  }
}
// Log from file 1
const logManager1 = new LogManager();
logManager1.insert('My Log 1');
// log from file 2
const logManager2 = new LogManager();
logManager2.insert('My Log 2');
// Want to see all the logs
console.log(logManager1.getLogs()); // ['My Log 1']
console.log(logManager2.getLogs()); // ['My Log 2']

Мы гарантируем, что при использовании одноэлементного шаблона диспетчер журналов не создает несколько экземпляров. Таким образом, с помощью одного объекта мы можем получить все журналы в одном месте.

let instance;
class LogManager {
  constructor() {
    if (instance) {
      return instance;
    }
  this.logs = [];
    instance = this;
  }
  insert(log) {
    this.logs.push(log);
  }
  getLogs() {
    return this.logs;
  }
}
// Log from file 1
const logManager1 = new LogManager();
logManager1.insert('My Log 1');
// log from file 2
const logManager2 = new LogManager();
logManager2.insert('My Log 2');
// Want to see all the logs
console.log(logManager1.getLogs()); // ["My Log 1", "My Log 2"]
console.log(logManager2.getLogs()); // ["My Log 1", "My Log 2"]

Шаблон строителя

Рассмотрим следующий пример, где мы создаем информацию о полосе с помощью name, lineup, genre и origin. Когда у нас есть только name и origin, но lineup и genra отсутствуют, при создании объекта мы должны передать два значения undefined.

class Band {
  constructor(name, lineup, genra, origin) {
    this.name = name;
    this.lineup = lineup;
    this.genra = genra;
    this.origin = origin;
  }
}

const warfaze = new Band('Warfaze', undefined, undefined, 'Dhaka');
console.log(warfaze);

С помощью JS мы можем разрешить это, используя специальный шаблон построителя JS, где мы берем только name, но другие свойства будут переданы как свойства объекта. В данном случае нам не нужно иметь дело со значением undefined.

class Band {
  constructor(name, {
    lineup,
    genra, 
    origin
  } = {}) {
    this.name = name;
    this.lineup = lineup;
    this.genra = genra;
    this.origin = origin;
  }
}

const warfaze = new Band('Warfaze', {
  origin: 'Dhaka'
});
console.log(warfaze);

Мы можем решить это традиционным способом, как показано ниже.

class Band {
  constructor(name, lineup, genra, origin) {
    this.name = name;
    this.lineup = lineup;
    this.genra = genra;
    this.origin = origin;
  }
}

class BandBuilder {
  constructor(name) {
    this.band = new Band(name)
  }
  setLineup(lineup) {
    this.band.lineup = lineup;
    return this;
  }
  setGenra(genra) {
    this.band.genra = genra;
    return this;
  }
  setOrigin(origin) {
    this.band.origin = origin;
    return this;
  }
  build() {
    return this.band;
  }
}
const warfaze = new BandBuilder('Warfaze').setOrigin('Dhaka').build();
console.log(warfaze);

Образец прототипа

С es6 шаблон прототипа в JS похож на классическое наследование классов. Это позволяет расширять функциональность унаследованных классов, не копируя их.

В следующем примере у нас есть класс Shape, который расширяется на Circle и Rectangle.

Затем класс Rectangle наследует класс Square.

Базовый класс Shape имеет метод logInfo, а метод logInfo доступен для всех унаследованных классов. Мы можем использовать класс logInfo из объектов классов Circle, Reactange и Square.

Этот класс logInfo не копируется в унаследованные классы, а доступен только в памяти класса Shape.

class Shape {
  constructor(name) {
    this.name = name;
  }

  logInfo() {
    console.log(this);
  }
}

class Circle extends Shape {
  constructor(name) {
    super(name);
  }
}
class Reactangle extends Shape {
  constructor(name, width, height) {
    super(name);
    this.width = width;
    this.height = height;
  }
}
class Square extends Reactangle {
  constructor(name, width) {
    super(name, width, width);
  }
}
const circle = new Circle('circle');
circle.logInfo(); // Circle { name: 'circle' }
const reactangle = new Reactangle('reactangle', 10, 20);
reactangle.logInfo(); // Reactangle { name: 'reactangle', width: 10, height: 20 }
const square = new Square('square', 15);
square.logInfo(); // Square { name: 'square', width: 15, height: 15 }

Шаблон декоратора

Шаблон декоратора позволяет обновлять поведение существующих классов.

В следующем примере у нас есть метод Vehicle, который используется для создания объектов. Теперь, после создания объекта транспортного средства, мы можем видеть name, model и price объекта.

Мы будем использовать два типа декораторов,

  • Один для обновления модели
  • Другой для обновления цены транспортного средства, если к транспортному средству добавлено AC Sub Engine
function Vehicle(name) {
  this.name = name;
  this.model = 'Default';
  this.getPrice = () => {
    return 2;
  };
}

const bus = new Vehicle('Volvo');
// Decorator 1
bus.setModel = function (model) {
  this.model = model;
}
bus.setModel('B7R');
// Decorator 1
function IncludeAcSubEngine(bus) {
  const existingPrice = bus.getPrice();
  bus.getPrice = function() {
    return existingPrice + 1;
  }
}
console.log(bus.name, bus.model, bus.getPrice()); // Volvo B7R 2
IncludeAcSubEngine(bus);
console.log(bus.name, bus.model, bus.getPrice()); // Volvo B7R 3

Шаблоны оформления фасадов

В нашей системе после оформления заказа рассмотрите возможность использования полосы для оплаты.

// initiate stripe
const stripe = {
  pay: (amount) => {}
};

const purchase = (itemName, price) => {
  stripe.pay(price);
}

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

// initiate PayPal
const paypal = {
  pay: (amount) => {}
};

const purchase = (itemName, price) => {
  paypal.pay(price);
}

Вместо этого, если мы выделим функции оплаты в отдельный модуль, этот рефакторинг кода будет намного проще,

const purchase = (itemName, price) => {
  makePayment(price);
}

const makePaymet = (price) => {
  // initialize stripe or paypal
  // implement the payment functionality with stripe/paypal 
}

В этом случае над модулем Stripe/PayPal есть оболочка, которая поможет сохранить существующие коды без изменений.

Другим примером может быть функция выборки данных. Допустим, мы используем axios для получения данных. Позже мы можем решить использовать нативный модуль fetch. Если бы все функции извлечения данных находились в отдельном модуле, было бы легко провести рефакторинг кодовой базы позже при переключении между модулями fetch и axios.

Шаблон адаптера

Когда две функции несовместимы, между ними появляется шаблон адаптера, который делает их совместимыми. Реальный сценарий может быть таким: мы использовали аудиоразъем 3.5 mm, а позже появился вход typeC. Чтобы использовать вход типа C с аудиоразъемом 3.5 mm, мы используем converter или adapter.

Рассмотрим следующий класс калькулятора, он просто выполняет сложение и вычитание,

class Calculator {
  constructor(num1, num2) {
    this.num1 = num1;
    this.num2 = num2;
  }

  operation(operationType) {
    switch(operationType) {
      case 'ADD':
        return this.num1 + this.num2;
      case 'SUBSTRACT':
        return this.num1 - this.num2;
      default:
        return NaN;
    }
  }
}
const calculator = new Calculator(10, 6);
console.log(calculator.operation('ADD')); // 16
console.log(calculator.operation('SUBSTRACT')); // 4

Теперь, позже, мы придумали новую функциональность ядра калькулятора, и она имеет лучшую производительность,

class CalculatorCore {
  constructor(num1, num2) {
    this.num1 = num1;
    this.num2 = num2;
  }
  add() {
    return this.num1 + this.num2;
  }
  substract() {
    return this.num1 - this.num2;
  }
}

Этот новый CalculatorCore не совместим с нашим существующим классом, в нем нет метода operation, а также он не принимает operationType. В этом случае мы можем использовать Adapter Pattern, чтобы сделать наш существующий класс совместимым с новым классом Calculator Core,

class CalculatorCore {
  constructor(num1, num2) {
    this.num1 = num1;
    this.num2 = num2;
  }

  add() {
    return this.num1 + this.num2;
  }
  substract() {
    return this.num1 - this.num2;
  }
}
class CalculatorAdapter {
  constructor(num1, num2) {
    this.calculator = new CalculatorCore(num1, num2);
  }
  operation(operationType) {
    switch(operationType) {
      case 'ADD':
        return this.calculator.add();
      case 'SUBSTRACT':
        return this.calculator.substract();
      default:
        return NaN;
    }
  }
}
const calculatorAdapter = new CalculatorAdapter(10, 6);
console.log(calculatorAdapter.operation('ADD')); // 16
console.log(calculatorAdapter.operation('SUBSTRACT')); // 4

Шаблон проектирования прокси

Шаблон проектирования прокси создает тот же класс, аналогичный исходной реализации, и сокращает объем операций. Подумайте о том, чтобы следовать CurrencyAPI классу. Метод getCurrency берет coinName и вызывает длительный вызов API для получения валюты.

Если мы будем искать валюту bitCoin, она будет получать API каждый раз. Мы можем сократить операцию, используя прокси-класс.

class CurrencyAPI {
  // long running api call
  getCurrency(coinName) {
    switch(coinName) {
      case 'BitCoin':
        return '100$';
      case 'Ethereium':
        return '80$';
      default: 
        return 'Invalid Coin';
    }
  }
}

const fetch = new CurrencyAPI();
// getting from long api call
console.log(fetch.getCurrency('BitCoin')); // 100$
// getting from long api call
console.log(fetch.getCurrency('BitCoin')); // 100$

В соответствии с Proxy Design Pattern мы создаем аналогичный класс под названием ProxyCurrencyAPI. Он использует исходный CurrencyAPI и дополнительно кэширует результат. В первый раз он извлекает API, кэширует его и при следующем вызове извлекает результаты из кэша.

class CurrencyAPI {
  getCurrency(coinName) {
    switch(coinName) {
      case 'BitCoin':
        return '100$';
      case 'Ethereium':
        return '80$';
      default: 
        return 'Invalid Coin';
    }
  }
}

class ProxyCurrencyAPI {
  cache = {};
  constructor() {
    this.fetch = new CurrencyAPI();  
  }
  getCurrency(coinName) {
    if (this.cache[coinName]) {
      return this.cache[coinName];
    }
    this.cache[coinName] = this.fetch.getCurrency(coinName);
    return this.cache[coinName];
  }
}
const fetch = new ProxyCurrencyAPI();
// fetch from api
console.log(fetch.getCurrency('BitCoin')); // 100$
// retrieve from cache
console.log(fetch.getCurrency('BitCoin')); // 100$

Схема цепочки ответственности

С Chain of Responsibility Pattern, если один объект не может выполнить операцию, он передает операцию своему преемнику.

В следующем примере мы обрабатываем Managerial, который имеет тенденцию обрабатывать операции Manager типов. В случае, если он не разрешает операцию, он передает операции своему преемнику,

class Handler {
  constructor() {
    this.successor = null;
  }

  setSuccessor(successor) {
    this.successor = successor;
  }
}
class AdminHandler extends Handler {
  handleOperation(operationType) {
    if (operationType === 'Admin') {
      console.log('Handled By Admin');
      return;
    }
  }
}
class ManagerialHandler extends Handler {
  handleOperation(operationType) {
    if (operationType === 'Manager') {
      console.log('Handled By Manager');
      return;
    }
    if (this.successor) {
      this.successor.handleOperation(operationType);
    }
  }
}
const magerialHandler = new ManagerialHandler();
magerialHandler.handleOperation('Manager'); // Handled By Manager
const adminHandler = new AdminHandler();
magerialHandler.setSuccessor(adminHandler);
magerialHandler.handleOperation('Admin'); // Handled By Admin

Шаблон команды

Шаблон команды позволяет отменить операцию. Это может быть очень полезно в некоторых сценариях, например, при вставке пользователя в БД и последующем решении отменить операцию.

В следующем примере калькулятор принимает команду (пример: AddCommand). Он может выполнять операции, а затем иметь возможность отменить операцию.

class Calculator {
  constructor() {
    this.value = 0;
    this.history = [];
  }
  executeCommand(command) {
    this.value = command.execute(this.value);
    this.history.push(command);
  }
  undo() {
    const command = this.history.pop();
    this.value = command.undo(this.value);
  }
}
class AddCommand {
  constructor(valueToBeAdded) {
    this.valueToBeAdded = valueToBeAdded;
  }
  execute(currentValue) {
    return currentValue + this.valueToBeAdded;
  }
  undo(currentValue) {
    return currentValue - this.valueToBeAdded;
  }
}
const calculator = new Calculator();
calculator.executeCommand(new AddCommand(5));
console.log(calculator.value); // 5
calculator.executeCommand(new AddCommand(7));
console.log(calculator.value); // 12
calculator.undo(); // 5
console.log(calculator.value);

Шаблон наблюдателя

В шаблоне наблюдателя есть субъект и пара наблюдателей. Наблюдатели – это методы, действующие как подписчики субъектов. Эти наблюдатели подписываются на тему и бегают по изменениям в теме.

В следующем примере у нас есть тема, в которой мы можем добавлять или удалять подписчиков.

Мы можем запускать всех наблюдателей методом runObservers после их подписки. Позже можно запускать этот наблюдатель при изменении определенного значения в файле Subject.

class Subject {
  constructor() {
    this.observer = [];
  }
  subscribe(fn) {
    this.observer.push(fn);
  }
  unsubscribe(fn) {
    this.observer = this.observer.filter(f => f !== fn);
  }
  runObservers() {
    this.observer.map(fn => fn());
  }
}

const subject = new Subject();
const observer1 = () => console.log('I am observer1 method.');
const observer2 = () => console.log('I am observer2 method.');
subject.subscribe(observer1);
subject.subscribe(observer2);
subject.runObservers();

Ресурсы