В этой статье мы разберём несколько шаблонов проектирования в JavaScript.

Когда запускается новый проект, вы не бросаетесь тут же писать код. Для начала нужно определить цель и область применения проекта, далее перечислить его функционал и спецификации. Только после этого вы можете начать писать код, а если это большой проект, тогда вам следует выбрать для него наиболее подходящие шаблоны.

Что такое шаблоны проектирования?

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

Почему следует пользоваться шаблонами?

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

Самое главное — шаблоны позволяют разработчикам «говорить на одном языке». Тот, кто читает ваш код моментально поймёт его назначение.

Например, если вы используете шаблон Декоратор, то другой программист быстро поймёт, за что отвечает эта часть кода. Вместо того чтобы разбираться в вашем коде, он сможет сфокусироваться на решении проблем.

Теперь мы знаем, что такое шаблоны проектирования и почему они важны. Давайте перейдём к шаблонам применяемым в JavaScript.


Модульный шаблон

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

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

В отличии от других языков программирования, в JavaScript нет модификаторов доступа. Это значит, что вы не можете объявить переменную как private или public. Так вот, модульный шаблон можно использовать для эмуляции концепции инкапсуляции.

Этот шаблон использует IIFE (немедленно вызываемые функции), замыкания и область видимости функции, чтобы симулировать эту концепцию. Пример:

const myModule = (function() {
  
  const privateVariable = 'Hello World';
  
  function privateMethod() {
    console.log(privateVariable);
  }

return {
    publicMethod: function() {
      privateMethod();
    }
  }
})();

myModule.publicMethod();

Так как это IIFE, код исполняется немедленно, а возвращённый объект присваивается к переменной myModule. Благодаря замыканию, возвращённый объект всё ещё имеет доступ к функциям и переменным, определённым внутри IIFE, даже после завершения IIFE.

Поэтому переменные и функции, определённые внутри IIFE, фактически скрыты от внешней видимости. Это делает их private для переменной myModule.

После выполнения кода, переменная myModule выглядит так:

const myModule = {
  publicMethod: function() {
    privateMethod();
  }};

Мы можем вызвать publicMethod(), который в свою очередь вызовет privateMethod(). Пример:

// Prints 'Hello World'
module.publicMethod();

Выявляющий модульный шаблон (Revealing Module Pattern)

Это улучшенная (Christian Heilmann’ом) версия модульного шаблона. Проблема модульных шаблонов в том, что нам приходится создавать новые public функции только для того, чтобы вызывать private функции и переменные.

В этом шаблоне мы привязываем свойства возвращаемого объекта к private функциям, которые мы хотим показать как public. Поэтому он называется Выявляющий модульный шаблон. Пример:

const myRevealingModule = (function() {
  
  let privateVar = 'Peter';
  const publicVar  = 'Hello World';

function privateFunction() {
    console.log('Name: '+ privateVar);
  }
  
  function publicSetName(name) {
    privateVar = name;
  }

function publicGetName() {
    privateFunction();
  }
/** reveal methods and variables by assigning them to object properties */
return {
    setName: publicSetName,
    greeting: publicVar,
    getName: publicGetName
  };
})();

myRevealingModule.setName('Mark');

// prints Name: Mark
myRevealingModule.getName();

Благодаря этому шаблону нам легче понять, какая из функций и переменных доступна публично, что улучшает читабельность кода.

myRevealingModule ― после выполнения кода, выглядит так:

const myRevealingModule = {
  setName: publicSetName,
  greeting: publicVar,
  getName: publicGetName
};

Мы можем вызвать myRevealingModule.setName('Mark'), что является отсылкой к внутреннему publicSetName и myRevealingModule.getName(), что в свою очередь отсылка к внутреннему publicGetName. Пример:

myRevealingModule.setName('Mark');

// prints Name: Mark
myRevealingModule.getName();

Преимущества Выявляющего модульного шаблона перед обычным:

  • Мы можем изменить элементы с public на private и наоборот, изменив одну строку в операторе return.
  • Возвращаемый объект не содержит определений функций, все выражения с правой стороны определены внутри IIFE, что делает код — чистым и читабельным.

ES6 модули

До появления ES6, JavaScript не имел встроенных модулей, поэтому разработчики полагались на сторонние библиотеки или модульные шаблоны для выполнения модулей. С ES6, в JavaScript появились встроенные модули.

ES6 модули хранятся в файлах. Для каждого модуля отдельный файл. По умолчанию, всё что внутри модуля определено как private. Доступ к функциям, переменным и классам открывает ключ export. Код внутри модуля всегда выполняется в строгом режиме.

Экспорт модуля

Есть два способа экспортировать объявление функции и переменной:

  • Добавить ключевое слово export перед объявлением функции или переменной. Пример:
// utils.js

export const greeting = 'Hello World';
export function sum(num1, num2) {
  console.log('Sum:', num1, num2);
  return num1 + num2;
}

export function subtract(num1, num2) {
  console.log('Subtract:', num1, num2);
  return num1 - num2;
}

// This is a private function
function privateLog() {
  console.log('Private Function');
}
  • Добавить ключевое слово export в конец кода, содержащего имена функций и переменных, которые мы хотим экспортировать. Пример:
// utils.js
function multiply(num1, num2) {
  console.log('Multiply:', num1, num2);
  return num1 * num2;
}

function divide(num1, num2) {
  console.log('Divide:', num1, num2);
  return num1 / num2;
}

// This is a private function
function privateLog() {
  console.log('Private Function');
}

export {multiply, divide};

Импорт модуля

Как и при экспорте, есть два способа импортировать модуль, используя ключевое слово import . Пример:

  • Импорт нескольких элементов одновременно
// main.js
// importing multiple items

import { sum, multiply } from './utils.js';

console.log(sum(3, 7));
console.log(multiply(3, 7));
  • Импорт модуля целиком
// main.js
// importing all of module

import * as utils from './utils.js';

console.log(utils.sum(3, 7));
console.log(utils.multiply(3, 7));

Можно использовать alias для импорта и экспорта

Чтобы не было конфликта имён, вы можете изменить имя экспорта, в обоих случаях, как при экспорте, так и при импорте. Пример:

  • Переименование экспорта
// utils.js
function sum(num1, num2) {
  console.log('Sum:', num1, num2);
  return num1 + num2;
}

function multiply(num1, num2) {
  console.log('Multiply:', num1, num2);
  return num1 * num2;
}

export {sum as add, multiply};
  • Переименование импорта
// main.js
import { add, multiply as mult } from './utils.js';

console.log(add(3, 7));
console.log(mult(3, 7));

Шаблон Синглтон (Singleton)

Синглтон — это объект, который может быть создан только в одном экземпляре. Шаблон Синглтон создаёт новый экземпляр класса, если он не существует. Если экземпляр существует, то шаблон возвращает ссылку на этот объект. Любой повторный вызов конструктора всегда будет извлекать тот же объект.

В JavaScript всегда были Синглтоны. Просто мы называли их литералами объекта. Пример:

const user = {
  name: 'Peter',
  age: 25,
  job: 'Teacher',
  greet: function() {
    console.log('Hello!');
  }
};

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

Пример того, как можно скопировать переменную user в другую переменную и изменить её:

const user1 = user;
user1.name = 'Mark';

Мы увидим, что оба объекта изменены, потому что объекты в JavaScript передаются по ссылке, а не по значению. В памяти это один и тот же объект. Пример:

// prints 'Mark'
console.log(user.name);

// prints 'Mark'
console.log(user1.name);

// prints true
console.log(user === user1);

Шаблон Синглтон может быть реализован с помощью функции-конструктора. Пример:

let instance = null;

function User() {
  if(instance) {
    return instance;
  }

instance = this;
  this.name = 'Peter';
  this.age = 25;
  
  return instance;
}

const user1 = new User();
const user2 = new User();

// prints true
console.log(user1 === user2);

При вызове этой функции-конструктора проверяется существование объекта instance. Если объект не существует, то переменная this присваивается переменной instance. А если объект существует, то он будет возвращён.

Синглтоны можно реализовать, используя модульный шаблон. Пример:

const singleton = (function() {
  let instance;
  
  function init() {
    return {
      name: 'Peter',
      age: 24,
    };
  }

return {
    getInstance: function() {
      if(!instance) {
        instance = init();
      }
      
      return instance;
    }
  }
})();

const instanceA = singleton.getInstance();
const instanceB = singleton.getInstance();

// prints true
console.log(instanceA === instanceB);

В этом коде, мы создаём новый экземпляр, вызывая метод singleton.getInstance. Если экземпляр уже существует, то метод просто вернёт этот экземпляр. Если экземпляр не существует, то будет создан новый, вызовом функции init().

Фабричный шаблон

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

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

class Car{
  constructor(options) {
    this.doors = options.doors || 4;
    this.state = options.state || 'brand new';
    this.color = options.color || 'white';
  }
}

class Truck {
  constructor(options) {
    this.doors = options.doors || 4;
    this.state = options.state || 'used';
    this.color = options.color || 'black';
  }
}

class VehicleFactory {
  createVehicle(options) {
    if(options.vehicleType === 'car') {
      return new Car(options);
    } else if(options.vehicleType === 'truck') {
      return new Truck(options);
      }
  }
}

Здесь, я создал два класса — Car и Truck (со значениями по умолчанию), которые использую чтобы создать новые объекты – car и truck. Я также определил класс VehicleFactory, чтобы создать и вернуть новый объект на основе свойства vehicleType, полученного в объекте options.

const factory = new VehicleFactory();

const car = factory.createVehicle({
  vehicleType: 'car',
  doors: 4,
  color: 'silver',
  state: 'Brand New'
});

const truck= factory.createVehicle({
  vehicleType: 'truck',
  doors: 2,
  color: 'white',
  state: 'used'
});

// Prints Car {doors: 4, state: "Brand New", color: "silver"}
console.log(car);

// Prints Truck {doors: 2, state: "used", color: "white"}
console.log(truck);

Я создал новый объект factory класса VehicleFactory. После этого можно создавать новые объекты Car или Truck, вызывая factory.createVehicle и передавать объект options со свойством vehicleType и значением Car или Truck.

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

Декоратор применяют чтобы расширить функциональность объекта, не модифицируя при этом существующий класс или функцию-конструктор. Этот шаблон можно использовать для добавления новых фич к объекту, не затрагивая базовый код.

Простой пример такого шаблона:

function Car(name) {
  this.name = name;

// Default values
  this.color = 'White';
}

// Creating a new Object to decorate
const tesla= new Car('Tesla Model 3');

// Decorating the object with new functionality

tesla.setColor = function(color) {
  this.color = color;
}

tesla.setPrice = function(price) {
  this.price = price;
}

tesla.setColor('black');
tesla.setPrice(49000);

// prints black
console.log(tesla.color);

Более практичный пример:

Допустим стоимость авто зависит от опций, имеющихся в его комплектации. Без декоратора нам придётся создавать классы для каждой комбинации опций автомобиля. У каждой комбинации есть метод «cost» для расчёта стоимости. Пример:

class Car() {
}

class CarWithAC() {
}

class CarWithAutoTransmission {
}

class CarWithPowerLocks {
}

class CarWithACandPowerLocks {
}

С шаблоном Декоратор мы можем создать базовый класс Car и добавить стоимость разных конфигураций к его объекту, используя функции декоратора. Пример:

class Car {
  constructor() {
  // Default Cost
  this.cost = function() {
  return 20000;
  }
}
}

// Decorator function
function carWithAC(car) {
  car.hasAC = true;
  const prevCost = car.cost();
  car.cost = function() {
    return prevCost + 500;
  }
}

// Decorator function
function carWithAutoTransmission(car) {
  car.hasAutoTransmission = true;
   const prevCost = car.cost();
  car.cost = function() {
    return prevCost + 2000;
  }
}

// Decorator function
function carWithPowerLocks(car) {
  car.hasPowerLocks = true;
  const prevCost = car.cost();
  car.cost = function() {
    return prevCost + 500;
  }
}

Сначала мы создаём базовый класс Car для создания объектов Car. Далее мы создаём Декоратор для опций, которые мы хотим добавить и передаём объект Car как параметр. После этого мы перезаписываем функцию cost этого объекта, которая возвращает обновлённую стоимость авто и добавляет новое свойство к этому объекту, показывающее какая опция была добавлена.

Для добавления новой опции, мы можем сделать так:

const car = new Car();
console.log(car.cost());

carWithAC(car);
carWithAutoTransmission(car);
carWithPowerLocks(car);

В конце мы можем посчитать стоимость авто таким образом:

// Calculating total cost of the car
console.log(car.cost());

Заключение

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

Важно знать шаблоны проектирования, но не менее важно не переусердствовать, применяя их. Перед тем как использовать шаблон, следует внимательно обдумать — подходит ли этот шаблон для решения вашей проблемы. Чтобы принять верное решение вам следует изучить сам шаблон и приложения, в которых он применяется.

На этом всё.

Перевод статьи Sukhjinder Arora : Understanding Design Patterns in JavaScript

Предыдущая статьяЧто определяет настоящего “Senior” разработчика?
Следующая статьяНасколько хорошо вы разбираетесь в Go?