История возникновения паттернов
Перед тем как ответить на вопрос - зачем нужны паттерны проектирование - сделаем немольшой исторический экскурс.
С появлением первых программ, разработчики начали выделять в них некоторые повторяющиеся куски программного кода, для повторного их использования. Эти части когда, которые решали конкретные задачи в приложении, начали выделяться в самостоятельные сущности, и среди них начали выделять наилучшие варианты их реализации.
Эти самостоятельные сущности в итоге назвали паттернами проектирования, и фундаментальной работе по их стандартизации можно считать широко известную книгу - Паттерны Проектирования “Банды Четырех” (Гамма, Хелм, Джонсон, Влиссидес).
Оригинальная книга была написана в 1994 году и нацелена на объектно-ориентированные языки программирования - все паттерны в ней описаны на объектно-ориентированном языке C++ и определены для решения задач ООП - то есть нацелены на написание десктопного ПО.
Но паттерны - вещь универсальная и не привязана к какому-то конкретному языку программирования. Но перед тем как перенести, описанные в книги паттерны, давайте сначала, все-таки, оговорим разницу между этими двумя языками.
В отличие от объектно-ориентированного с++, функциональный язык веба - джаваскрипт - не так жестко типизирован, поэтому и не так сильно полагается на порождающие паттерны (про типы паттернов чуть дальше). К тому же, он появился позже, поэтому многие структурные и поведенческие паттерны уже включены в язык.
Поэтому веб-разработчику меньше приходиться вникать, скажем, в то, что такое шаблон прототипа, чтобы использовать его - джаваскрипт уже сделал это за него.
Однако, джаваскрипт берет на себя все больше функций - становясь языком полного цикла, с помощью которого создается не только фронтенд, но и бекенд приложения, где паттерны программирования играют более существенную роль.
К тому же в сложных нагруженных приложениях для фронтенда тоже очень важна быстродейственность и оптимизированность кода. И вот тут, как раз, паттерны проектирования становиться крайне полезными. Знание паттернов проектирования поможет решить задачу, при этом, в случае отсутствия готовой реализации, реализовать решение наиболее эффективным способом.
Давайте разберем, какие бывают паттерны, и где какие паттерны применяются.
Типы паттернов
Паттерны проектирования принято делить на: порождающие (Creational), организующие (Structural) и поведенческие (Behaviour).
Порождающие паттерны - это паттерны, которые создают объект. В большинстве они реализуют абстракцию, инкапсуляцию, полиморфизм и наследование классов, а также определяют соответствующие интерфейсы для классов в зависимости от их логической функции.
Организующие паттерны - это в целом множество оберток для классов, дающих или дополнительный интерфейс или оптимизирующих текущий интерфейс класса под клиента. То есть с помощью организующих паттернов можно расширять или изменять интерфейсы уже существующих классов вместо того, чтобы порождать новые расширенные классы наследованием.
Поведенческие паттерны - самые интересные - они работают уже с созданным объектом. С помощью них можно удобно организовать или следить за объектами.
Отдельно от паттернов проектирования выделяют архитектурные паттерны - то есть шаблоны проектирования всего приложения, вроде MVC - и синтаксические паттерны - то есть некоторые шаблоны написания кода, котор��е упрощают его читабельность, вроде паттерна раннего возврата (early return pattern). Их мы касаться не будем.
Давайте для каждого из трех типов паттернов проектирования возьмем по одному паттерну и разберем их назначения в качестве примера.
Прототип (prototype)
Из всех других порождающих паттернов он - наиболее важен во фронтенд-разработке, так как механизм наследования в языке джаваскрипт основан как раз на прототипировании.
В отличии от объектно-ориентированных языков, где наследование реализуется через расширение классов - то есть через расширение свойств некоторых абстрактных сущностей, от которых впоследствии образуется объект - наследование в джаваскрипт реализуется через “клонирование” уже созданного базового объекта с дополнением его свойств.
Эту задачу решает паттерн прототип.
Хотя в процессе проектирования веб-приложения, вы вряд ли столкнетесь с проблемой реализации прототипированного наследования, понимание этой концепции даст вам глубокое понимание, как работает классовое наследование в джаваскрипте, которое на самом деле реализует расширение и клонирование прототипов.
Начем с того, что паттерн прототип уже реализован в языке. В отличие от языков, которые не поддерживают глубокое копирование объектов через операцию присваивания - например C++ - использовать прототипирование в джаваскрипте очень просто.
Каждый обьект в джаваскрите содержить поле __proto__, к которому вы можете обратиться и присвоить ему значение другого объекта. В браузере, если логировать объект, у него будет системное поле [[Prototype]] - это браузерный алиас поля __proto__.
После присвоения __proto__ объекта другим объектом, клон получает доступ ко всем полям оригинала - они содержаться в его поле __proto__.
Таким образом джаваскрипт устроен так, что если на клоне вызвать некоторый метод или поле, которого у него нет, то перед тем, как выбросить undefined, компилятор посмотрит этот метод или поле в прототипе __proto__, и если такой есть - вернет его. Кстати, если искомое поле также не нашлось в прототипе, но у него тоже есть прототип __proto__.__proto__, то он посмотрит и в нем, и так по всей цепочке - если хоть в одном прототипе будет нужное поля - компилятор вернет его.
Таким образом в клонах можно переопределять поля и методы, называя их соответственно такими же именами как те, что уже содержаться в прототипе.
Но это еще не все. Дело в том, что функции в джаваскрипте тоже имеют поле прототипа, которое называется “prototype”, и оно само имеет свойство __proto__.
При создании объекта от функции-конструктора, полю __proto__ объекта неявно присваивается объект prototype функции-конструктора, и они будут равны.
Собственно, разница между добавлением поля в тело функции через свойство this и добавлением этого поля через свойство prototype в том - поле добавленное в prototype функции-конструктора будет содержаться как в объектах образованных от этой функции, так и потенциально может быть передано потомкам.
Также prototype.__proto__ можно присвоить некоторый базовый объект, или можно уже сконструированному объекту установить __proto__ базовым объектом или прототипом функции конструктора. Все эти варианты возможно, и выбирать их стоит в зависимости от того, кого поведения объекта вы хотите добиться.
Для реализации аналога классового наследования - в prototype функции-конструктора записываются поля предполагающие дальнейшее наследование, а в prototype.__proto__ записывается prototype базовой функции-конструктора.
Вот пример реализации прототипированного наследования - конструктор Creator является базовым конструктором. Поля, предназначающиеся только для его сущностей, объявляются через this, а поля для потомков через prototype:
const Creator = function () {
this.nickname = 'Cool guy';
};
Creator.prototype.hasFreeWill = true;Затем создается конструктор Human, который унаследует свойства Creator и добавит поле purpose:
const Human = function () {};
Human.prototype.__proto__ = Creator.prototype;
Human.prototype.purpose = 'Eat cookies';
const ralph = new Human();
console.log(ralph.nickname); // undefined
console.log(ralph.hasFreeWill); // true,Затем создадим еще один конструктор Ai, который перезапишет для своих сущностей поле hasFreeWill и purpose для будущих потомков:
const Ai = function () {
this.purpose = 'Kill humans';
};
Ai.prototype.__proto__ = Human.prototype;
Ai.prototype.hasFreeWill = false;
const bender = new Ai();
console.log(bender.hasFreeWill); // false
console.log(bender.purpose); // Kill humansКак мы видим, чтобы реализовать наследование на чистых прототипах, нужно полю prototype.__proto__ новой функции-конструктору присвоить значение прототипа ее родителя. Затем добавляются или перезаписываются поля в ее прототип.
Прокси (proxy)
Прокси - или прослойка - это структурный паттерн, который работает с объектом. Прокси принимает в себя объект и дает возможность изменять его, не изменяя при этом функционал самого объекта, а расширяя интерфейс его копии.
Паттерн прокси реализован нативно в джаваскрипте версии es6. С его помощью удобно валидировать данные - добавлять функционал на присвоение и чтение полей объекта.
С помощью прокси можно создавать общий объект-валидатор и проверять на нем данные в месте их присвоения, вместо того, чтобы проверять условия проверки в каждом отдельном поле.
Давайте посмотрим на классическом кейсе валидации инпутов, как можно использовать прокси.
Поместим на форму два инпута:
<body>
<input type="text" placeholder="email" name="email" />
<input type="password" placeholder="password" name="password" />
</body>Давайте при вводе в эти инпуты не валидные значение, не будем присваивать их финальному объекту и будем выбрасывать ошибку, которую мы будем обрабатывать в событии, подсвечивая красным границы инпута, если введенные данные некорректны.
Объявим целевой объект и повесим события ввода на инпуты:
const userTarget = {
id: undefined,
email: undefined,
password: undefined,
};
document.querySelectorAll('input').forEach(input =>
input.addEventListener('input', ({target}) => {
try {
} catch (er) {
}
})
);Теперь давайте запроксируем объект userTarget, чтобы при присвоении данные валидировались, а при получении проверялось их наличие, и в случае их отсутствия возвращалась пустая строка а не undefined. Так же при отсутствии id, его значение будет генерироваться:
const validator = {
set(target, prop, value) {
if (value === '') return true;
switch (prop) {
case 'id':
throw new Error('Id read only allowed!');
break;
case 'email':
if (!value.match(/(.+)@(.+){2,}\.(.+){2,}/))
throw new Error('Email doesnt follow the pattern!');
break;
case 'password':
if (value.length < 6) throw new Error('Password must be 6 or more characters!');
target[prop] = btoa(value);
return true;
}
target[prop] = value;
return true;
},
get(target, prop) {
if (target[prop] === undefined) {
if (prop !== 'id') return '';
const id = btoa(Math.floor(Math.random() * 9 * Math.pow(10, 6 - 1) + Math.pow(10, 6 - 1)));
target[prop] = id;
return target[prop];
}
},
};
const user = new Proxy(userTarget, validator);Теперь мы можем работать с объектом через его прокси и менять стиль инпута прямо в обработчике события (например, в addEventListener-е), если при присвоении будет выбрасываться ошибка:
try {
user[target.name] = target.value;
target.style.borderColor = '#ccc';
} catch (er) {
console.error(`${er.message}`);
target.style.borderColor = 'red';
}
Как видно из примера - паттерн прокси позволяет инкапсулировать валидацию в одном месте и использовать ее в тех местах, где происходит мутация или чтение данных, которая требует проверки.
Обозреватель (observer)
Паттерн обозреватель - это поведенческий паттерн.
По своей сути обозреватель предоставляет интерфейс для подписки на выполнение некоторой функции на выполнении, при получении некоторого события.
В зависимости от реализации, выполняемая функция может быть одна и передаваться при инициализации обозревателя, или их может быть много и каждая функция передается в момент подписки.
Также различают обозреватели, которые явно принимают событие, которое вызывает подписанную на это событие функцию, и обозреватели, которые делают это неявно - нужно только подписать функцию, а когда она будет вызвана определено внутри класса обозревателя.
Давайте создадим обозреватель по аналогии с addEventListerer-ом, который будет принимать токен некоторого события и функцию, которую нужно выполнить, при вызове этого события.
Наш класс Observer будет выглядеть так:
class Observer {
events = {};
observe(ev, handler) {}
unobserve(ev, handler) {}
trigger(ev) {}
}В нем создан массив для хранения событий и методы подписания, отзыва и вызова конкретного события.
Теперь реализуем эти методы:
observe(ev, handler) {
if (!this.events[ev]) {
this.events[ev] = [handler];
} else {
this.events[ev].push(handler);
}
}
unobserve(ev, handler) {
if (!this.events[ev]) return;
if (!handler || this.events[ev].length === 1) {
delete this.events[ev];
} else {
this.events[ev] = this.events[ev].filter(fn => fn !== handler);
}
}
trigger(ev) {
if (!this.events[ev]) return;
this.events[ev].forEach(fn => fn());
}
Теперь давайте создадим сущность класса Observer и подпишем на него события.
const observer = new Observer();
observer.observe('echo', () => console.log('Hello'));
observer.observe('echo', () => console.log('Orwell'));
observer.observe('echo', () => console.log('World'));Теперь, давайте отпишем второй обработ��ик события и вызовем все оставшиеся обработчики события echo:
observer.unobserve('echo', () => console.log('Orwell'));
observer.trigger('echo');Давайте посмотрим, что выведет лог:
>> Hello
>> Orwell
>> WorldКак можно заметить второе событие не отписалось. Почему? Потому, что фильтр в методе unobserve чистит обработчики по ссылке, а не по фактическому контенту функции, а в нашем случае, хоть функцию, которую мы подписали и отписали имеет фактически одинаковое содержимое - это на самом деле две разные ссылки.
Чтобы это исправить, давайте преобразуем метод unobserve, добавив в него проверку по фактическому контенту:
unobserve(ev, handler, byHandlerContent = true) {
if (!this.events[ev]) return;
if (!handler || this.events[ev].length === 1) {
delete this.events[ev];
} else {
this.events[ev] = this.events[ev].filter(fn =>
byHandlerContent ? `${fn}` !== `${handler}` : fn !== handler
);
}
}Посмотри лог теперь:
>> Hello
>> WorldНа последок
В общем, паттерны программирования - очень обширная тема и мы разобрали только три паттерна из двадцати трех, описанных в Книге Четырех.
За три года своей профессиональной практики фронтенд-разработки, я не могу вспомнить случай, когда без использования паттернов нельзя было бы обойтись (за исключением замыканий, но они в книге, как раз, не описаны). В целом, понимание паттернов, наверное, сделает вас более сильным программистом, но повсеместное их использование точно превратит ваш код в нечитаемую кашу, которую вашим коллегам, возможно, не очень приятно будет расхлебывать.
Все хорошо в меру.
Так, надеюсь эта статья поможет вам произвести хорошее впечатление на работодателя во время собеседовании и убережет от бездумного применения паттернов программирования.
спасибо

