Автор материала, перевод которого мы публикуем, говорит, что, начиная проект, к написанию кода приступают не сразу. В первую очередь определяют цель и границы проекта, затем — выявляют те возможности, которыми он должен обладать. Уже после этого либо сразу пишут код, либо, если речь идёт о достаточно сложном проекте, подбирают подходящие паттерны проектирования, которые ложатся в его основу. Этот материал посвящён паттернам проектирования в 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, но, на самом деле, за рамками нашего разговора осталось ещё очень много паттернов, которые могут применяться для решения широкого круга задач.
В то время как знание различных паттернов проектирования важно для программиста, не менее важно и их уместное использование. Зная о паттернах и о сфере их применения, программист, анализируя стоящую перед ним задачу, может понять, какой именно паттерн способен помочь её решить.
Уважаемые читатели! Какими паттернами проектирования вы пользуетесь чаще всего?
Автор: ru_vds