Автор материала, перевод которого мы публикуем, говорит, что, начиная проект, к написанию кода приступают не сразу. В первую очередь определяют цель и границы проекта, затем — выявляют те возможности, которыми он должен обладать. Уже после этого либо сразу пишут код, либо, если речь идёт о достаточно сложном проекте, подбирают подходящие паттерны проектирования, которые ложатся в его основу. Этот материал посвящён паттернам проектирования в JavaScript. Он рассчитан, преимущественно, на начинающих разработчиков.



Что такое паттерн проектирования?


В сфере разработки программного обеспечения паттерн проектирования (design pattern) — это повторяемая архитектурная конструкция, представляющая собой решение проблемы проектирования в рамках некоторого часто возникающего контекста. Паттерны проектирования представляют собой обобщение опыта профессиональных разработчиков ПО. Паттерн проектирования можно рассматривать как некий шаблон, в соответствии с которым пишут программы.

Зачем нужны паттерны проектирования?


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

Самое важное здесь, пожалуй, то, что применение паттернов даёт разработчикам ПО нечто вроде словаря общеизвестных терминов, которые весьма полезны, например, при разборе чужого кода. Паттерны раскрывают предназначение тех или иных фрагментов программы для тех, кто пытается разобраться с устройством какого-нибудь проекта.

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

Теперь, когда мы разобрались с тем, что такое паттерны проектирования, и с тем, для чего они нужны, перейдём, собственно, к паттернам и к описанию их реализации с использованием JavaScript.

Паттерн «Модуль»


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

Модули — это составная часть любого современного JavaScript-приложения. Они помогают поддерживать чистоту кода, способствуют разделению кода на осмысленные фрагменты и помогают его организовывать. В JavaScript существует множество способов создания модулей, одним из которых является паттерн «Модуль» (Module).

В отличие от других языков программирования, JavaScript не имеет модификаторов доступа. То есть, переменные нельзя объявлять как приватные или публичные. В результате паттерн «Модуль» используется ещё и для эмуляции концепции инкапсуляции.

Этот паттерн использует IIFE (Immediately-Invoked Functional Expression, немедленно вызываемое функциональное выражение), замыкания и области видимости функций для имитации этой концепции. Например:

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

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

В результате переменные и функции, объявленные внутри IIFE, скрыты от механизмов, находящихся во внешней по отношению к ним области видимости. Они оказываются приватными сущностями константы myModule.

После того, как этот код будет выполнен, myModule будет выглядеть следующим образом:

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

То есть, обращаясь к этой константе, можно вызвать общедоступный метод объекта publicMethod(), который, в свою очередь, вызовет приватный метод privateMethod(). Например:

// Выводит 'Hello World'
module.publicMethod();

Паттерн «Открытый модуль»


Паттерн «Открытый модуль» (Revealing Module) представляет собой немного улучшенную версию паттерна «Модуль», которую предложил Кристиан Хейльманн. Проблема паттерна «Модуль» заключается в том, что нам приходится создавать публичные функции только для того, чтобы обращаться к приватным функциям и переменным.

В рассматриваемом паттерне мы назначаем свойствам возвращаемого объекта приватные функции, которые хотим сделать общедоступными. Именно поэтому данный паттерн и называют «Открытый модуль». Рассмотрим пример:

const myRevealingModule = (function() {
  
  let privateVar = 'Peter';
  const publicVar  = 'Hello World';
  function privateFunction() {
    console.log('Name: '+ privateVar);
  }
  
  function publicSetName(name) {
    privateVar = name;
  }
  function publicGetName() {
    privateFunction();
  }
  /** открываем функции и переменные, назначая их свойствам объекта */
return {
    setName: publicSetName,
    greeting: publicVar,
    getName: publicGetName
  };
})();
myRevealingModule.setName('Mark');
// Выводит Name: Mark
myRevealingModule.getName();

Применение этого паттерна упрощает понимание того, какие функции и переменные модуля общедоступны, что способствует улучшению читабельности кода.

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

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

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

myRevealingModule.setName('Mark');
// выводит Name: Mark
myRevealingModule.getName();

Рассмотрим преимущества паттерна «Открытый модуль» перед паттерном «Модуль»:

  • «Открытый модуль» позволяет делать общедоступными скрытые сущности модуля (и снова скрывать их, если нужно), модифицируя, для каждой из них, лишь одну строку в объекте, возвращаемом после выполнения IIFE.
  • Возвращаемый объект не содержит определения функций. Всё, что находится справа от имён его свойств, определено в IIFE. Это способствует чистоте кода и упрощает его чтение.

Модули в ES6


До выхода стандарта ES6 в JavaScript не было стандартного средства для работы с модулями, в результате разработчикам приходилось использовать сторонние библиотеки или паттерн «Модуль» для реализации соответствующих механизмов. Но с приходом ES6 в JavaScript появилась стандартная система модулей.

Модули ES6 хранятся в файлах. Один файл может содержать лишь один модуль. Всё, что находится внутри модуля, по умолчанию является приватным. Функции, переменные и классы можно делать публичными с использованием ключевого слова 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;
    }
    // Это - приватная функция
    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;
    }
    // Это приватная функция
    function privateLog() {
      console.log('Private Function');
    }
    export {multiply, divide};

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


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

  • Импорт нескольких избранных элементов. Например:

    // main.js
    // Импорт нескольких избранных элементов
    import { sum, multiply } from './utils.js';
    console.log(sum(3, 7));
    console.log(multiply(3, 7));
  • Импорт всего, что экспортирует модуль. Например:

    // main.js
    // импорт всего, что экспортирует модуль
    import * as utils from './utils.js';
    console.log(utils.sum(3, 7));
    console.log(utils.multiply(3, 7));

?Псевдонимы для экспортируемых и импортируемых сущностей


Если имена экспортируемых в код функций или переменных могут вызвать коллизию, их можно изменить при экспорте или при импорте.

Для переименования сущностей при экспорте можно поступить так:

// 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, мы получаем ссылку на один и тот же объект.

Паттерн «Синглтон» можно реализовать с использованием функции-конструктора. Выглядит это так:

let instance = null;
function User(name, age) {
  if(instance) {
    return instance;
  }
  instance = this;
  this.name = name;
  this.age = age;
  
  return instance;
}
const user1 = new User('Peter', 25);
const user2 = new User('Mark', 24);
// выводит true
console.log(user1 === user2);

Когда вызывается функция-конструктор, она, в первую очередь, проверяет, существует ли объект instance. Если соответствующая переменная не инициализирована, в instance записывают this. Если же в переменной уже есть ссылка на объект, конструктор просто возвращает instance, то есть — ссылку на уже существующий объект.

Паттерн «Синглтон» можно реализовать с использованием паттерна «Модуль». Например:

const singleton = (function() {
  let instance;
  
  function User(name, age) {
    this.name = name;
    this.age = age;
  }
  return {
    getInstance: function(name, age) {
      if(!instance) {
        instance = new User(name, age);
      }
      return instance;
    }
  }
})();
const user1 = singleton.getInstance('Peter', 24);
const user2 = singleton.getInstance('Mark', 26);
// prints true
console.log(user1 === user2);

Здесь мы создаём новый экземпляр user, вызывая метод singleton.getInstance(). Если экземпляр объекта уже существует, то этот метод просто возвратит его. Если же такого объекта пока нет, метод создаёт его новый экземпляр, вызывая функцию-конструктор User.

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


Паттерн «Фабрика» (Factory) использует для создания объектов так называемые «фабричные методы». При этом не требуется указывать классы или функции-конструкторы, которые применяются для создания объектов.

Этот паттерн используется для создания объектов в случаях, когда не нужно делать общедоступной логику их создания. Паттерн «Фабрика» может быть использован в том случае, если нужно создавать различные объекты в зависимости от специфических условий. Например:

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'
});
// Выводит Car {doors: 4, state: "Brand New", color: "silver"}
console.log(car);
// Выводит Truck {doors: 2, state: "used", color: "white"}
console.log(truck);

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

Паттерн «Декоратор»


Паттерн «Декоратор» (Decorator) используется для расширения функционала объектов без модификации существующих классов или функций-конструкторов. Этот паттерн можно использовать для добавления к объектам неких возможностей без модификации кода, который ответственен за их создание.

Вот простой пример использования этого паттерна:

function Car(name) {
  this.name = name;
  // Значение по умолчанию
  this.color = 'White';
}
// Создание нового объекта, который планируется декорировать
const tesla= new Car('Tesla Model 3');
// Декорирование объекта - добавление нового функционала
tesla.setColor = function(color) {
  this.color = color;
}
tesla.setPrice = function(price) {
  this.price = price;
}
tesla.setColor('black');
tesla.setPrice(49000);
// Выводит black
console.log(tesla.color);

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

class Car() {
}
class CarWithAC() {
}
class CarWithAutoTransmission {
}
class CarWithPowerLocks {
}
class CarWithACandPowerLocks {
}

Благодаря рассматриваемому паттерну можно создать базовый класс Car, описывающий, скажем, автомобиль в базовой комплектации, стоимость которого выражается некоей фиксированной суммой. После этого стандартный объект, создаваемый на основе этого класса, можно расширять с использованием функций-декораторов. Стандартный «автомобиль», обработанный такой функцией, получает новые возможности, что, кроме того, влияет на его цену. Например, эту схему можно реализовать так:

class Car {
  constructor() {
  // Базовая стоимость
  this.cost = function() {
  return 20000;
  }
}
}
// Функция-декоратор
function carWithAC(car) {
  car.hasAC = true;
  const prevCost = car.cost();
  car.cost = function() {
    return prevCost + 500;
  }
}
// Функция-декоратор
function carWithAutoTransmission(car) {
  car.hasAutoTransmission = true;
   const prevCost = car.cost();
  car.cost = function() {
    return prevCost + 2000;
  }
}
// Функция-декоратор
function carWithPowerLocks(car) {
  car.hasPowerLocks = true;
  const prevCost = car.cost();
  car.cost = function() {
    return prevCost + 500;
  }
}

Здесь мы сначала создаём базовый класс Car, используемый для создания объектов, представляющих автомобили в стандартной комплектации. Затем создаём несколько функций-декораторов, которые позволяют расширять объекты базового класса Car дополнительными свойствами. Эти функции принимают соответствующие объекты в качестве параметров. После этого мы добавляем в объект новое свойство, указывающее на то, какой новой возможностью будет оснащён автомобиль, и переопределяем функцию cost объекта, которая теперь возвращает новую стоимость автомобиля. В результате, для того, чтобы «оснастить» автомобиль стандартной конфигурации чем-то новым, мы можем воспользоваться следующей конструкцией:

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

После этого можно узнать стоимость автомобиля в улучшенной комплектации:

// Нахождение стоимости автомобиля с учётом улучшений
console.log(car.cost());

Итоги


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

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

Уважаемые читатели! Какими паттернами проектирования вы пользуетесь чаще всего?

Комментарии (25)


  1. k12th
    22.10.2018 14:53
    +1

    Всегда поражало, (как function() {}()) называют целым паттерном, да еще и "модулем", хотя никакой модульностью там, как правило, не пахло. Непонятно, зачем вообще в 2018 рассказывать про это, тем более что автор знает про ES6.
    Про синглтон стоило бы упомянуть что это спорный паттерн, и немного разъяснить, почему это так и какие есть лучшие альтернативы.


    ...


    Непонятно, зачем я каждый раз кликаю на статьи в блоге ru_vds, хотя заранее знаю, что там.


    1. theTeacherOfEnglish
      22.10.2018 18:34

      Автор и не называет самовызывающиеся функции целым паттерном, это лишь один из способов реализовать паттерн «модуль» в JS.

      «зачем вообще в 2018 рассказывать про это, тем более что автор знает про ES6»

      У вас «в 2018» браузеры уже полностью поддерживают export и import?


      1. k12th
        22.10.2018 18:40

        У меня в 2018 import/export поддерживает бандлер, потому что по ряду причин всё равно надо упаковывать статику в один файл. А если полагаться на http 2, так соответсвующие браузеры поддерживают и то, и другое.
        А вот как в 2018 IIFE помогает разруливать зависимости?


        1. theTeacherOfEnglish
          22.10.2018 19:21
          -1

          А вы посмотрите что выдают на выходе "бандлеры" и вопросов не останется по IIFE.


      1. i360u
        23.10.2018 11:30
        +1

        У вас «в 2018» браузеры уже полностью поддерживают export и import?

        У нас — да. А у вас еще нет? Поддержка IE в 2018-м — штука весьма опциональная.


        1. Free_ze
          23.10.2018 11:43

          Опция эта чаще всего лежит вне сферы влияния отдельного программиста.


          1. i360u
            23.10.2018 11:46
            +1

            У меня еще не было случая в практике, когда кто-либо продолжал бы настаивать на поддержке некрофилии после изложения всех "за" и "против" в доступной форме. Я бы очень поспорил с вашим "чаще всего".


            1. Free_ze
              23.10.2018 12:04

              Например, в том же интерпрайзе это будет чуть более, чем всегда. Любой крупный публичный портал будет поддерживать по крайней мере IE11 в 2к18.


              1. i360u
                23.10.2018 12:59

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


                1. Free_ze
                  23.10.2018 13:10

                  Аналитики интерпрайза вам скажут, что можно отказаться от поддержки IE8-9, а 11 все еще имеет хороший кусок, ибо Windows 7 все еще очень популярна.
                  Ваши слова уже не так категоричны.


                  1. i360u
                    23.10.2018 13:14

                    Ок, ок, у меня тут какие-то другие аналитики, не такие как у вас.


                    1. Free_ze
                      25.10.2018 11:34

                      У приложений могут быть разные задачи и различная целевая аудитория, например.


                1. ImKremen
                  23.10.2018 13:21

                  IE 11 второй по популярности десктопный браузер в США


                  1. asheee
                    25.10.2018 06:28

                    У нас в скромном азиатском стартапе доля IE была от силы 1,5% на главной. Настаивали на его поддержке ровно до вопроса о количестве запросов в IE в чекаут, и внезапно оказалось что там просто единицы заказов.

                    Но разве транспайлер и понифилы не решают проблему поддержки ES6/7 браузерами? О чем вы вообще?


                    1. ImKremen
                      25.10.2018 10:43

                      А у нас в суровом американском энерпрайзе у некоторых клиентов до 100% доходит.


                      Увы не всё можно за полифилить.


              1. asheee
                25.10.2018 06:40

                Плюсовик рассказывает фронтендерам о поддержке IE? Я думаю что вы обобщаете какой-то частный случай. Я обычно видел такой паттерн поддержки IE: «Он вроде должен работать, но проверять не буду. Eсли QA заведет баг — посмотрю». Но QA обычно тоже смотрят или одним глазом или никак — и это тоже не с проста.


                1. Free_ze
                  25.10.2018 08:29

                  Плюсовик рассказывает фронтендерам о поддержке IE? Я думаю что вы обобщаете какой-то частный случай.

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

                  «Он вроде должен работать, но проверять не буду. Eсли QA заведет баг — посмотрю».

                  Это личное раздолбайство программиста. У приложения либо есть такое техническое требование, либо его нет.


                  1. asheee
                    25.10.2018 10:46

                    Я не верю в «личное раздолбайство программиста». Важные вещи всегда имеют процессы, которые гарантируют определенное качество. От customer support, до стендов тестирования и фиксированных приемочных требований. Если этих процессов нет — скорее всего это не просто так.

                    Я не берусь делать выводы за весь IT рынок, но в проектах где я работал, в основном всем было наплевать на IE, и максимум, обсуждалась идея выдавать пользователям IE мобильный сайт. Конверсия и другие показатели важные для аналитиков всегда были очень маленькими, для того чтобы хоть как-то весомо пропушить поддержку среди кучи горящих фич и квартальных планов.


                    1. Free_ze
                      25.10.2018 11:26

                      Если QA заводит баг и это требование появилось в ТЗ до начала выполнения фичи, то это косяк программиста. Если косяк допущен сознательно («проверять не буду», потому что бизнес-аналитики дураки, плохо знают рынок и неправильно рисуют ТЗ), то это и есть непрофессионализм, потому что кое-кто «залезает» в чужую зону ответственности.


  1. yermolaev
    22.10.2018 15:54

    es6 +


  1. klimentRu
    22.10.2018 21:11
    +1

    В es2016 реализация декораторов через @ уже (Exploring EcmaScript Decorators).
    Да и модули с введением import и export устарели.

    Зачем одни и те-же устаревшие примеры из статьи в статью копировать?
    А потом ими люди пользуются по незнанию(


    1. defaultvoice
      23.10.2018 10:46

      Декораторы всё ещё Stage 2, а не часть стандарта. А ещё у них успела поменяться семантика, что сломало кучу проектов с использованием старой версии, так что я бы не стал торопиться тащить их в проект – возможно будут ещё допиливать.


  1. ardentum
    22.10.2018 22:16

    Большинство паттернов проектирования нужны для сурового ООП и отсутствия функций как объектов первого класса. Кажется, что любая статья про паттерны проектирования в JS должна начинаться с предупреждения о том, что эти паттерны в JS могут быть и не нужны. Особенно в условиях популяризации функциональной парадигмы.


  1. KoToSveen
    23.10.2018 05:37

    «Я джва года ждал эту статью!» (с)

    Коротко и ясно.
    Раньше пытался гуглить статьи о паттернах, но постоянно натыкался на лютую дичь, которую и читать то не хотелось после первого абзаца.


    1. psFitz
      23.10.2018 11:45

      Серьезно? За 2 года не понять 4 элементарных шаблона?