Привет, Хабр! Довольно известный преподаватель JavaScript Bill Sourour в своё время написал несколько статей по современным паттернам в JS. В рамках этой статьи мы постараемся обозреть идеи, которыми он поделился. Не то чтобы это были какие-то уникальные паттеры, но надеюсь статья найдёт своего читателя. Данная статья не «перевод» с точки зрения политики Хабра т.к. я описываю свои мысли, на которые меня навели статьи Била.
Абревиатура обозначает Receive an object, return an object — получить объект, вернуть объект. Привожу ссылку на оригинал статьи: ссылка
Билл писал, что пришёл к способу написанию функций при котором большинство из них принимают только один параметр — объект с аргументами функций. Возвращают они также объект результатов. На эту идею Билла вдохновила деструктуризация(одна из фич ES6).
Для тех, кто не знает о деструктуризации приведу необходимые пояснения по ходу рассказа.
Представьте, что у нас есть данные пользователей, содержащие его права на те или иные разделы приложения, представленные в объекте данных. Нам нужно показать определённую информацию на основе этих данных. Для этого мы могли бы предложить следующую реализацию:
Используя код выше, мы бы достигли желаемого результата. Однако, есть несколько подводных камней в написании кода таким способом.
Во первых, вызов функции
Во вторых, если мы захотим сделать часть параметров обязательными, то придётся писать что-то вроде этого:
В этом случае наша функция, помимо своих обязанностей по поиску, будет выполнять ещё и валидацию, а мы хотели просто находить пользователей по определённым параметрам. Конечно, функция поиска может принимать в себя функции валидации, но тогда перечень входных параметров разрастётся. Это тоже минус такого паттерна кодирования.
Деструктуризация подразумевает разбивку сложной структуры на простые части. В JavaScript, таковая сложная структура обычно является объектом или массивом. Используя синтаксис деструктуризации, вы можете выделить маленькие фрагменты из массивов или объектов. Данный синтаксис может быть использован для объявления переменных или их назначения. Вы также можете управлять вложенными структурами, используя уже синтаксис вложенной деструктуризации.
Используя деструктуризацию, функция из нашего предыдущего примера будет выглядеть так:
Обратите внимание, что наша функция выглядит практически идентично, за исключением того, что мы поставили скобки вокруг наших параметров. Вместо получения трех различных параметров наша функция теперь ожидает один объект со свойствами:
Это гораздо менее двусмысленно, намного легче читать и понимать. Кроме того, пропуск или иной порядок наших параметров больше не является проблемой, ведь теперь они являются именованными свойствами объекта. Также мы можем безопасно добавить новые параметры в декларацию функции. Кроме того, т.к. деструктуризация копирует переданное значение, то его изменения в функции не повлияют на оригинал.
Проблему с обязательными параметрами также можно решить более элегантным способом.
Если мы не передадим значение rule, то сработает функция переданная по умолчанию, которая выбросит исключение.
Функции в JS могут возвращать только одно значение, поэтому для передачи большего объёма информации можно использовать объект. Разумеется нам не всегда нужно чтобы функция возвращала много информации, в каких-то случаях нас устроит возврат примитива, например
Также данный подход упрощает композицию функций. Ведь при композиции функции должны принимать лишь по одному параметру. Паттерн RORO придерживается этого же контракта.
Bill Sourour: «Как и любой шаблон, RORO следует рассматривать как еще один инструмент в нашем инструментарии. Мы используем его там, где он приносит пользу, делая список параметров более понятным и гибким, а возвращаемое значение — более выразительным.»
Оригинал статьи вы можете найти по этой ссылке.
По замыслу автор, данный шаблон представляет собой функцию, которая создаёт и возвращает замороженный объект.
Бил считает. что в некоторых ситуациях этот паттерн может заменить привычные нам ES6 классы. Например, у нас есть некая продуктовая карзина, в которую мы можем добавлять/удалять продукты.
ES6 класс:
Объекты созданные с помощью ключевого слова
Также следует помнить, что классы в JS реализованы на прототипном делегировании, следовательно мы можем поменять реализацию метода в прототипе класса и эти изменения отразятся на всех уже созданных инстанциях(подробнее об этом я рассказывал в статье про ООП).
Согласитесь, подобные особенности могут доставить нам массу неприятностей.
Также распространённой проблемой является назначение метода экземпляра обработчику событий.
Клик по кнопке не очистит корзину. Метод присваивает нашей кнопке новое свойство с именем db и устанавливает для этого свойства значение [] вместо того, чтобы воздействовать на db объекта cart. Однако, в консоли нет ошибок, и ваш здравый смысл скажет вам, что код должен работать, но это не так.
Чтобы заставить этот код работать придётся написать стрелочную функцию:
Или закрепить контекст bind'ом:
Избежать этих ловушек нам поможет Ледяная фабрика.
Особенности этого паттерна:
Когда мы ведём речь об архитектуре разрабатываемого ПО, то всегда должны идти на удобные компромисы. В этой стезе нет жёстких правил и ограничений, каждая ситуация уникальна, поэтому чем больше паттернов в нашем арсенале, тем больше вероятность того, что мы подберём наилучший вариант архитектуры в конкретной ситуации.
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 происходит медленнее и занимает больше памяти, чем использование класса(Во многих ситуациях нам могут понадобится классы, поэтому советую эту статью)
- это обычная функция, которую можно назначить в качестве колбека
Заключение
Когда мы ведём речь об архитектуре разрабатываемого ПО, то всегда должны идти на удобные компромисы. В этой стезе нет жёстких правил и ограничений, каждая ситуация уникальна, поэтому чем больше паттернов в нашем арсенале, тем больше вероятность того, что мы подберём наилучший вариант архитектуры в конкретной ситуации.