История возникновения паттернов

Перед тем как ответить на вопрос - зачем нужны паттерны проектирование - сделаем немольшой исторический экскурс.

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

Эти самостоятельные сущности в итоге назвали паттернами проектирования, и фундаментальной работе по их стандартизации можно считать широко известную книгу - Паттерны Проектирования “Банды Четырех” (Гамма, Хелм, Джонсон, Влиссидес).

Оригинальная книга была написана в 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

На последок

В общем, паттерны программирования - очень обширная тема и мы разобрали только три паттерна из двадцати трех, описанных в Книге Четырех.

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

Все хорошо в меру.

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

спасибо