Привет, Хабр! Довольно известный преподаватель JavaScript Bill Sourour в своё время написал несколько статей по современным паттернам в JS. В рамках этой статьи мы постараемся обозреть идеи, которыми он поделился. Не то чтобы это были какие-то уникальные паттеры, но надеюсь статья найдёт своего читателя. Данная статья не «перевод» с точки зрения политики Хабра т.к. я описываю свои мысли, на которые меня навели статьи Била.

RORO


Абревиатура обозначает Receive an object, return an object — получить объект, вернуть объект. Привожу ссылку на оригинал статьи: ссылка

Билл писал, что пришёл к способу написанию функций при котором большинство из них принимают только один параметр — объект с аргументами функций. Возвращают они также объект результатов. На эту идею Билла вдохновила деструктуризация(одна из фич ES6).

Для тех, кто не знает о деструктуризации приведу необходимые пояснения по ходу рассказа.

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

// пример пользователя
const user = {
  name: 'John Doe',
  login: 'john_doe',
  password: 12345,
  active: true,
  rules: {
    finance: true,
    analitics: true,
    hr: false
  }
};

//Массив с данными
const users = [user];

//функция, которая делает всю работу
function findUsersByRule (  rule,   withContactInfo,   includeInactive) {
  //Получим всех юзеров для заданной роли и флага active 
  const filtredUsers= users.filter(item => includeInactive ? item.rules[rule] : item.active && item.rules[rule]);
//Вернём дерево юзеров(объкт) или массив айдишников(тоже объект) в зависимости от флага withContactInfo
  return withContactInfo ?
    filtredUsers.reduce((acc, curr) => {
      acc[curr.id] = curr; 
      return acc;
    }, {})
    : filtredUsers.map(item => item.id)
}

//обратите внимание на вызов функции
findUsersByRule(  'finance',   true,   true)

Используя код выше, мы бы достигли желаемого результата. Однако, есть несколько подводных камней в написании кода таким способом.

Во первых, вызов функции findUsersByRule очень сомнителен. Обратите внимание, насколько неоднозначны последние два параметра. Что произойдет, если нашему приложению почти никогда не нужны контактные данные(withContactInfo), но почти всегда нужны неактивные пользователи(includeInactive)? Мы вынуждены будем всегда передавать логические значения. Сейчас пока декларация функции находится рядом с её вызовом это не столь страшно, но представьте, что увидите такой вызов где-нибудь совершено в другом модуле. Вам придётся искать модуль с декларацией функцией чтобы понять для чего в неё передаются два логических значения в чистом виде.

Во вторых, если мы захотим сделать часть параметров обязательными, то придётся писать что-то вроде этого:

function findUsersByRule (  role,   withContactInfo,   includeInactive) {  
   if (!role) {      
      throw Error(...) ;
  }
//...дальнейшая реализация
}

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

Деструктуризация подразумевает разбивку сложной структуры на простые части. В JavaScript, таковая сложная структура обычно является объектом или массивом. Используя синтаксис деструктуризации, вы можете выделить маленькие фрагменты из массивов или объектов. Данный синтаксис может быть использован для объявления переменных или их назначения. Вы также можете управлять вложенными структурами, используя уже синтаксис вложенной деструктуризации.

Используя деструктуризацию, функция из нашего предыдущего примера будет выглядеть так:

function findUsersByRule ({  rule,  withContactInfo,   includeInactive}) {
//реализация поиска и возврата
}

findUsersByRule({  rule: 'finance',   withContactInfo: true,   includeInactive: true})

Обратите внимание, что наша функция выглядит практически идентично, за исключением того, что мы поставили скобки вокруг наших параметров. Вместо получения трех различных параметров наша функция теперь ожидает один объект со свойствами: rule, withContactInfo и includeInactive.

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

Проблему с обязательными параметрами также можно решить более элегантным способом.

function requiredParam (param) { 
 const requiredParamError = new Error( `Required parameter, "${param}" is missing.`  )
}

function findUsersByRule ({  rule = requiredParam('rule'),  withContactInfo,   includeInactive} = {}) {...}

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

Функции в JS могут возвращать только одно значение, поэтому для передачи большего объёма информации можно использовать объект. Разумеется нам не всегда нужно чтобы функция возвращала много информации, в каких-то случаях нас устроит возврат примитива, например findUserId вполне закономерно вернёт один айдишник по какому-то условию.

Также данный подход упрощает композицию функций. Ведь при композиции функции должны принимать лишь по одному параметру. Паттерн RORO придерживается этого же контракта.

Bill Sourour: «Как и любой шаблон, RORO следует рассматривать как еще один инструмент в нашем инструментарии. Мы используем его там, где он приносит пользу, делая список параметров более понятным и гибким, а возвращаемое значение — более выразительным.»

Ледяная фабрика


Оригинал статьи вы можете найти по этой ссылке.

По замыслу автор, данный шаблон представляет собой функцию, которая создаёт и возвращает замороженный объект.

Бил считает. что в некоторых ситуациях этот паттерн может заменить привычные нам ES6 классы. Например, у нас есть некая продуктовая карзина, в которую мы можем добавлять/удалять продукты.

ES6 класс:

// ShoppingCart.js
class ShoppingCart {
  constructor({db}) {
    this.db = db
  }
  
  addProduct (product) {
    this.db.push(product)
  }
  
  empty () {
    this.db = []
  }
  get products () {
    return Object
      .freeze([...this.db])
  }
  removeProduct (id) {
    // remove a product 
  }
  // other methods
}
// someOtherModule.js
const db = [] 
const cart = new ShoppingCart({db})
cart.addProduct({ 
  name: 'foo', 
  price: 9.99
})

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

const db = []
const cart = new ShoppingCart({db})
cart.addProduct = () => 'nope!' //это валидная для JS опперация
 
cart.addProduct({ 
  name: 'foo', 
  price: 9.99
}) // output: "nope!" Мы получили неожиданный результат

Также следует помнить, что классы в JS реализованы на прототипном делегировании, следовательно мы можем поменять реализацию метода в прототипе класса и эти изменения отразятся на всех уже созданных инстанциях(подробнее об этом я рассказывал в статье про ООП).

const cart = new ShoppingCart({db: []})
const other = new ShoppingCart({db: []})
ShoppingCart.prototype
  .addProduct = () => ‘nope!’
// Абсолютно валидная операция в JS

cart.addProduct({ 
  name: 'foo', 
  price: 9.99
}) // output: "nope!"

other.addProduct({ 
  name: 'bar', 
  price: 8.88
}) // output: "nope!"

Согласитесь, подобные особенности могут доставить нам массу неприятностей.

Также распространённой проблемой является назначение метода экземпляра обработчику событий.

document
  .querySelector('#empty')
  .addEventListener(
    'click', 
    cart.empty
  )

Клик по кнопке не очистит корзину. Метод присваивает нашей кнопке новое свойство с именем db и устанавливает для этого свойства значение [] вместо того, чтобы воздействовать на db объекта cart. Однако, в консоли нет ошибок, и ваш здравый смысл скажет вам, что код должен работать, но это не так.

Чтобы заставить этот код работать придётся написать стрелочную функцию:

document
  .querySelector("#empty")
  .addEventListener(
    "click", 
    () => cart.empty()
  )

Или закрепить контекст bind'ом:

document
  .querySelector("#empty")
  .addEventListener(
    "click", 
    cart.empty.bind(cart)
  )

Избежать этих ловушек нам поможет Ледяная фабрика.

function makeShoppingCart({
  db
}) {
  return Object.freeze({
    addProduct,
    empty,
    getProducts,
    removeProduct,
    // others
  })
  function addProduct (product) {
    db.push(product)
  }
  
  function empty () {
    db = []
  }
  function getProducts () {
    return Object
      .freeze([...db])
  }
  function removeProduct (id) {
    // remove a product
  }
  // other functions
}
// someOtherModule.js
const db = []
const cart = makeShoppingCart({ db })
cart.addProduct({ 
  name: 'foo', 
  price: 9.99
})

Особенности этого паттерна:

  • не нужно использовать ключевое слово new
  • нет необходимости привязывать this
  • объект cart полностью имутабелен
  • можно объявлять локальные переменные, которые не будут видны снаружи

function makeThing(spec) {
  const secret = 'shhh!'
  return Object.freeze({
    doStuff
  })
  function doStuff () {
    // тут можно использовать secret 
  }
}
// secret не видно снаружи
const thing = makeThing()
thing.secret // undefined

  • паттерн поддерживает наследование
  • создание объектов с помощью Ice Factory происходит медленнее и занимает больше памяти, чем использование класса(Во многих ситуациях нам могут понадобится классы, поэтому советую эту статью)
  • это обычная функция, которую можно назначить в качестве колбека

Заключение


Когда мы ведём речь об архитектуре разрабатываемого ПО, то всегда должны идти на удобные компромисы. В этой стезе нет жёстких правил и ограничений, каждая ситуация уникальна, поэтому чем больше паттернов в нашем арсенале, тем больше вероятность того, что мы подберём наилучший вариант архитектуры в конкретной ситуации.

Комментарии (5)


  1. AriesUa
    25.10.2019 11:08

    Спасибо, забрал себе в копилку выброс исключения для аргумента функции. Интересное решение, не знал о нем. Обычно в самом коде проверял.


  1. kahi4
    25.10.2019 15:11
    +1

    //Вернём дерево юзеров(объкт) или массив айдишников(тоже объект) в зависимости от флага withContactInfo

    Спроектируем кривую функцию, а потом героически будем решать несуществующие проблемы!


    А если серьезно, то этому подходу 100 лет в обед, а что кто-то насмотрелся на реакт и решил так лепить везде — сомнительное решение. По большому счету можно согласиться только с простотой расширения функции, остальное — в код так же придется лезть чтобы понять теперь не аргументы по порядку, а по названию. Ну и intellsense уже придумали.


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


    Недостатков, с другой стороны, в таком подходе немного, разве что очень маленький оверхед на создание объекта и все такое, который еще и не в стеке лежать будет, но с мира по нитке и вот у нас уже даже с простенькой веб страничкой не справляется компьютер, который выдает игры с 4к в 60 fps в которых одновременно рисуются миллионы объектов.


    А главное — зачем два подхода объединять в один паттерн? Чтобы красивее звучало? Хорошо если функция принимает много аргументов, их можно передать объектом, но если она возвращает число, все, паттерн больше не подходит?


    Функции в JS могут возвращать только одно значение, поэтому для передачи большего объёма информации можно использовать объект.

    Еще можно использовать массив и возвращать кортеджи таким образом. И вообще это очевидно и истинна для большинства ЯП.


    Также данный подход упрощает композицию функций. Ведь при композиции функции должны принимать лишь по одному параметру. Паттерн RORO придерживается этого же контракта.

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


    Ну а лебединая фабрика...


    Бил считает. что в некоторых ситуациях этот паттерн может заменить привычные нам ES6 классы.

    Классы призваны заменить этой паттерн, который в моем джуниорстве назывался "модуль" (от добавки freeze ничего не меняется, фактически). Неожиданно его оживлять в конце 2019 года и считать будущим.


  1. Sirion
    25.10.2019 15:25
    +2

    Морозильная фабрика — имхо, что-то из серии «хороший программист на Java может написать программу на Java на любом языке». В JS традиционно всё определяется соглашениями. Кто переопределяет методы инстансов, тот сам себе злобный буратино.

    Объект как параметр с деструктуризацией — да, это чертовски удобно. Насчёт такого способа обозначать required-параметры — не знаю, пока не определился для себя, это скорее грязный хак или полезный хак.


    1. ReklatsMasters
      25.10.2019 23:54
      +1

      Лично я считаю, что это костыль. Автор сначала заявляет, что минус просто функции в том, что там нужна валидация, а потом весело прикручивает валидацию к своему варианту. Это не говоря о том, что приводить и проверять типы всё равно придётся.


      А про протоописантия функций вообще комедия. Тот же vscode давным давно подгружает jsdoc функций и модулей. Надо просто навести курсор и посмотреть, что ожидает функция.


      Деструктуризация удобна, да, но она без должного понимания потворствует превращению функции в комбайн, вместо разделения на модули и серию мелких легко тестируемых функций.


  1. ReklatsMasters
    25.10.2019 23:43
    +1

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