Как стать автором
Обновить

Design Patterns: прототип, прокси и обозреватель для фронтенд-разработчика

Уровень сложностиСредний
Время на прочтение9 мин
Количество просмотров5K

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

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

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

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

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

На последок

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

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

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

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

спасибо

Теги:
Хабы:
Всего голосов 2: ↑1 и ↓10
Комментарии2

Публикации

Истории

Работа

Ближайшие события