Как использовать декораторы с фабричными функциями в JavaScript

Декораторы методов дополняют объектно-ориентированное программирование, инкапсулируя функциональность, которую используют разные объекты.

Рассмотрим следующий код:

function TodoStore(currentUser) {
  let todos = [];
  
  function add(todo) {
    let start = Date.now();
    if (currentUser.isAuthenticated()) {
      todos.push(todo);
    } else {
      throw "Not authorized to perform this operation";
    }
            
    let duration = Date.now() - start;
    console.log("add() duration : " + duration);
  }
    
  return Object.freeze({
    add
  });  
}

Задача метода add() — добавлять новые элементы во внутреннее состояние. Кроме того, метод должен проверить авторизацию пользователя и записать продолжительность выполнения. Эти две задачи второстепенны и могут повторяться в других методах.

Представим, что мы можем поместить решение этих задач в отдельные функции. Например, так:

function TodoStore() {
  let todos = [];
  
  function add(todo) {
    todos.push(todo);
  }
    
  return Object.freeze({
     add: compose(logDuration, authorize)(add)
  }); 
}

Теперь метод add() просто добавляет новый элемент в список. Всё остальное решается декорированием метода.

logDuration() и authorize() — декораторы.

Декоратор функции — это функция высшего порядка, которая принимает в качестве аргумента одну функцию и возвращает другую, которая является вариацией функции-аргумента.

Реджинальд Брейтуэйт, «JavaScript Allongé»

Логирование продолжительности

Декораторы можно использовать, чтобы узнать, сколько выполнялся метод:

function logDuration(fn) {
  return function decorator(...args) {
    let start = Date.now();
    let result = fn.apply(this, args);
    let duration = Date.now() - start;
    console.log(fn.name + "() duration : " + duration);
    return result;
  }
}

Обратите внимание, что декорируемая функция выполняется через передачу текущего значения this и всех аргументов: fn.apply(this, args).

Авторизация

Декоратор authorize() проверяет, есть ли у пользователя необходимые права для выполнения метода. Он сложнее предыдущего, так как зависит от другого объекта currentUser. В этом случае мы можем использовать функцию createAuthorizeDecorator() для создания декоратора. Он выполнит метод, только если пользователь авторизован:

function createAuthorizeDecorator(currentUser) {
  return function authorize(fn) {
    return function decorator(...args) {
      if (currentUser.isAuthenticated()) {
        return fn.apply(this, args);
      } else {
        throw "Not authorized to execute " + fn.name + "()";
      }
    }
  }
}

Теперь можно создать декоратор и передать зависимости:

let authorize = createAuthorizeDecorator(currentUser);

compose()

Часто требуется применить несколько декораторов к одному методу. Простой способ сделать это — вызывать декораторы один за другим:

function add() { }
let addWithAuthorize = authorize(add);
let addWithAuthorizeAndLog = logDuration(addWithAuthorize);
addWithAuthorizeAndLog();

Другой способ — собрать все декораторы в один и применить новый декоратор к исходной функции. Можно использовать функцию compose() из библиотек вроде underscore.js.

При композиции функций одна функция применяется к результату другой.

Применение f() к результату g() означает compose(f, g)(x), что аналогично f(g(x)).

Композиция лучше всего работает с унарными функциями вроде наших декораторов. Унарная функция — это функция, принимающая один аргумент:

let composedDecorator = _.compose(logDuration, authorize);
let addWithComposedDecorator = composedDecorator(add);
addWithComposedDecorator();

Ниже показано, как можно использовать compose() для применения декораторов на методе add():

function TodoStore() {
  function add() { }
    
  return Object.freeze({
     add: _.compose(logDuration, authorize)(add) 
  });  
}
let todoStore = TodoStore();
todoStore.add();

Порядок

В некоторых случаях порядок применения декораторов имеет значение.

В нашем примере они применяются слева направо:

  1. Начинается логирование продолжительности вызова.
  2. Выполняется авторизация.
  3. Вызывается исходный метод.

Фабричные функции

Фабричные функции лучше классов по следующим причинам:

  • Инкапсуляция. Члены приватны по умолчанию, поэтому можно самостоятельно решить, какие методы предоставить публичному API. В классах все члены публичны.
  • Нет проблем с потерей контекста this. Фабричные функции не используют this, поэтому отсутствуют связанные с ним проблемы.
  • Безопасность объектов лучше. Внутреннее состояние инкапсулируется, и API неизменно. А, например, глобальный объект, объявленный с помощью class, можно изменить в консоли разработчика.

Уберём самовыполняющуюся часть из паттерна Revealing Module и получим фабричную функцию. Ниже показано определение фабричной функции TodoStore():

function TodoStore() {  
  function get() { }
  function add(todo) { }
  function edit(id, todo) { }
  function remove(id) { }
    
  return Object.freeze({
      get,
      add,
      edit,
      remove
  });  
}
let todoStore = TodoStore();

Декорирование фабричной функции

Зачастую требуется применить декораторы на всех публичных методах объекта. Для этого можно создать функцию decorateMethods():

function decorateMethods(obj, ...decorators) {
  function decorateMethod(fnName) {
    if (typeof(obj[fnName]) === "function") {
      obj[fnName] = _.compose(...decorators)(obj[fnName]);
    }
  }
  Object.keys(obj).forEach(decorateMethod);
  return obj;
}
function decorateAndFreeze(obj, ...args) {
  decorateMethods(obj, ...args);
  return Object.freeze(obj);
}

Теперь можно использовать функцию decorateAndFreeze() для декорирования всех публичных методов фабричной функции:

function TodoStore() { 
  function get() { }
  function add(todo) { }
  function edit(id, todo) { }
  function remove(id) { }
    
  return decorateAndFreeze({
      get,
      add,
      edit,
      remove
  }, logDuration, authorize);  
}

Декораторы работают как ключевые слова или аннотации — документируют поведение метода, но явно отделяют второстепенную логику от основной логики метода.

Реджинальд Брейтуэйт, «JavaScript Allongé»

Сохранение контекста метода

Если используется не фабричная функция, декоратор всё равно должен сохранять контекст исходного метода, чтобы не потерять значение this. Для этого нужно следовать двум правилам:

  1. Декоратор должен возвращать функцию с помощью ключевого слова function (никаких стрелочных функций).
  2. Исходная функция должна вызываться с помощью call(this, ...) или apply(this, ...).

Если им не следовать, то декоратор потеряет контекст this, однако фабричные функции будут работать, так как они не используют this.

Классы, функции-конструкторы и литералы объектов используют this, поэтому они потеряют контекст.

Взглянем на следующий пример:

let logDuration = fn => (...args) => {
    let start = Date.now();
    let result = fn(...args);
    let duration = Date.now() - start;
    console.log(fn.name + "() duration : " + duration);
    return result;
  };
// Функция-конструктор
function Service() {
  this.url = "http://";
}
Service.prototype.fetch = logDuration(function fetch() {
   console.log(this.url); // undefined
  });
let service = new Service();
service.fetch();
// Литерал объекта
let anotherService =  {
  url: "http://",
  fetch: logDuration(function fetch() {
   console.log(this.url); // undefined
  });
}
anotherService.fetch();

Вывод

Фабричные функции и декораторы — мощные инструменты в арсенале разработчика. Фабричные функции создают ООП-объекты, а декораторы инкапсулируют общую логику, которую эти объекты могут повторно использовать. Эти две концепции дополняют друг друга.

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

Перевод статьи «How to use Decorators with Factory Functions»

Подобрали два теста для вас:
— А здесь можно применить блокчейн?
Серверы для котиков: выберите лучшее решение для проекта и проверьте себя.