Предлагаем вашему вниманию перевод очередного материала Билла Соро, который посвящён шаблонам проектирования в JavaScript. В прошлый раз мы рассказывали о паттерне RORO, а сегодня нашей темой будет шаблон Ice Factory. Если в двух словах, то этот шаблон представляет собой функцию, которая возвращает «замороженный» объект. Это — очень важный и мощный паттерн, и разговор о нём мы начнём с описания одной из проблем JS, на решение которой он направлен.

image

Проблема классов в JavaScript


Связанные по смыслу функции часто имеет смысл группировать в едином объекте. Например, в приложении для интернет-магазина может быть объект cart, который содержит общедоступные методы addProduct и removeProduct. Эти методы можно вызывать с помощью конструкций cart.addProduct() и cart.removeProduct().

Если вы пришли в JavaScript-разработку из языков вроде Java или C#, где во главе угла стоят классы, где всё ориентировано на объекты, то вышеописанная ситуация, вероятно, воспринимается вами как нечто вполне естественное.

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

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

// ShoppingCart.js
export default 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) {
    // удалить товар 
  }
  // другие методы
}
// someOtherModule.js
const db = [] 
const cart = new ShoppingCart({db})
cart.addProduct({ 
  name: 'foo', 
  price: 9.99
})

Обратите внимание на то, что в качестве параметра db я использую массив. Сделано это для упрощения примера. В реальном коде подобная переменная будет представлена чем-то вроде объекта Model или Repo, который взаимодействует с реальной базой данных.

К несчастью, даже хотя всё это и выглядит неплохо, классы в JavaScript ведут себя совсем не так, как можно ожидать. Фигурально выражаясь, если вы не будете соблюдать осторожность при работе с классами в JS, они могут вас укусить.

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

const db = []
const cart = new ShoppingCart({db})
cart.addProduct = () => 'nope!' 
// В строке выше нет ошибки!
cart.addProduct({ 
  name: 'foo', 
  price: 9.99
}) // вывод: "nope!" Почему?

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

Вот пример:

const cart = new ShoppingCart({db: []})
const other = new ShoppingCart({db: []})
ShoppingCart.prototype
  .addProduct = () => ‘nope!’
// В строке выше нет ошибки!
cart.addProduct({ 
  name: 'foo', 
  price: 9.99
}) // вывод: "nope!"
other.addProduct({ 
  name: 'bar', 
  price: 8.88
}) // вывод: "nope!"

Далее, вспомним о динамической привязке ключевого слова this в JavaScript. Если мы передадим куда-нибудь методы объекта cart, мы можем потерять исходную ссылку на this. Подобное поведение не отличается интуитивной понятностью, оно может стать источником множества проблем.

Обычная неприятность, которую this устраивает программистам, проявляется при назначении метода объекта обработчику событий. Рассмотрим метод cart.empty, предназначенный для очистки корзины:

empty () {
    this.db = []
  }

Присвоим этот метод обработчику события click некоей кнопки на веб-странице:

<button id="empty">
  Empty cart
</button>
---
document
  .querySelector('#empty')
  .addEventListener(
    'click', 
    cart.empty
  )

Если пользователь щёлкнет по этой кнопке, то ничего не изменится. Его корзина, представленная объектом cart, останется полной.

При этом всё это происходит без каких-либо сообщений об ошибках, так как this теперь имеет отношение не к корзине, а к кнопке. В результате вызов cart.empty приводит к созданию у кнопки нового свойства с именем db, и к присваиванию этому свойству пустого массива, а не к воздействию на свойство db объекта cart.

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

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

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

Или так:

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

Полагаю, в этом видео можно найти отличное описание всего этого, а вот цитата из этого видео: «new и this [в JavaScript] представляют собой нелогичные, странные, таинственные ловушки».

Паттерн Ice Factory как решение проблем JS-классов


Как уже было сказано, паттерн Ice Factory представляет собой функцию, которая создаёт и возвращает «замороженные» объекты. При использовании этого шаблона проектирования наш пример с корзиной покупателя будет выглядеть так:

// makeShoppingCart.js
export default function makeShoppingCart({
  db
}) {
  return Object.freeze({
    addProduct,
    empty,
    getProducts,
    removeProduct,
    // другие
  })
  function addProduct (product) {
    db.push(product)
  }
  
  function empty () {
    db = []
  }
  function getProducts () {
    return Object
      .freeze(db)
  }
  function removeProduct (id) {
    // удалить товар
  }
  // другие функции
}
// someOtherModule.js
const db = []
const cart = makeShoppingCart({ db })
cart.addProduct({ 
  name: 'foo', 
  price: 9.99
})

Обратите внимание на то, что наши «странные, таинственные ловушки» исчезли. А именно, проанализировав этот код, можно сделать следующие выводы:

  • Нам больше не нужно ключевое слово new. Тут мы, для создания объекта cart, просто вызываем обычную JS-функцию.
  • Нам больше не нужно ключевое слово this. Теперь можно получить доступ к db непосредственно из методов объекта.
  • Теперь объект cart иммутабелен. Команда Object.freeze() его «замораживает». Это приводит к тому, что к нему нельзя добавлять новые свойства, а существующие свойства нельзя удалять или изменять. Кроме того, и его прототип тоже изменить нельзя. Тут стоит помнить лишь о том, что команда Object.freeze() выполняет так называемую «мелкую заморозку» объекта. То есть, если возвращаемый объект содержит массив или другой объект, то к ним тоже надо применить команду Object.freeze(). Кроме того, если замороженный объект используется за пределами ES-модуля, нужно использовать строгий режим для того, чтобы обеспечить выдачу ошибки при попытке внесения изменений в объект.

Закрытые свойства и методы


Ещё одно преимущество шаблона Ice Factory заключается в том, что объекты, созданные с его помощью, могут иметь закрытые свойства и методы. Рассмотрим пример:

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

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

Об истоках паттерна Ice Factory


Хотя фабричные функции (Factory Functions) были в JavaScript всегда, на разработку паттерна Ice Factory меня серьёзно вдохновил код, который Дуглас Крокфорд показал в этом видео. Вот кадр оттуда, где он демонстрирует создание объекта с помощью функции, которую он называет «конструктором».


Дуглас Крокфорд показывает код, который меня вдохновил

Мой вариант кода, являющийся разновидностью того, что показал Крокфорд, выглядит так:

function makeSomething({ member }) {
  const { other } = makeSomethingElse() 
  
  return Object.freeze({ 
    other,
    method
  }) 
  function method () {
    // код, который использует "member"
  }
}

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

Кроме того, я использовал деструктурирование параметра spec. Так же я дал имя этому шаблону, назвав его Ice Factory. Полагаю, что так его легче будет запомнить и сложнее спутать с функцией constructor из JS-классов. Но, в целом, мой паттерн и конструктор — это одно и то же.
Поэтому, если речь идёт об авторстве этого паттерна, то оно принадлежит Дугласу Крокфорду.

Обратите внимание на то, что Крокфорд считает поднятие функций «слабой стороной» JavaScript, и ему, вероятно, не понравится мой подход. Я говорил о моём отношении к этому в одной из моих предыдущих статей, а конкретно — в этом комментарии.

Наследование и Ice Factory


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

Наряду с корзиной покупок у нас, вероятно, будут объекты каталог (Catalog) и заказ (Order). При этом данные объекты, скорее всего, будут иметь некие варианты открытых методов addProduct и removeProduct.

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

Так как объекты, созданные с помощью шаблона Ice Factory, нельзя расширять, они не могут быть унаследованы от других объектов. Учитывая это, что же нам делать с дублированием кода? Можем ли мы извлечь какую-то выгоду из применения объекта, представляющего собой список товаров?

Конечно, можем!

Паттерн Ice Factory ведёт нас к применению вечно актуального принципа, приведённого в одной из самых влиятельных книг по программированию — «Приёмы объектно-ориентированного программирования. Паттерны проектирования». Вот этот принцип: «Предпочитайте композицию наследованию класса».

Авторы этой книги, известные как «Банда четырёх», продолжают, говоря следующее: «Тем не менее, наш опыт показывает, что проектировщики злоупотребляют наследованием. Нередко дизайн мог бы стать лучше и проще, если бы автор больше полагался на композицию объектов».

Итак, вот наш список товаров:

function makeProductList({ productDb }) {
  return Object.freeze({
    addProduct,
    empty,
    getProducts,
    removeProduct,
    // другие
  )}
  // объявления для 
  // addProduct и так далее...
}
А вот корзина:
function makeShoppingCart({ 
   addProduct,
   empty,
   getProducts,
   removeProduct,
   // другие
  }) {
    return Object.freeze({
      addProduct,
      empty,
      getProducts,
      removeProduct,
      someOtherMethod,
     // другие 
    )}
  function someOtherMethod () {
    // код 
  }
}

Теперь мы можем просто внедрить объект, представляющий список товаров, в объект, представляющий корзину:

const productDb = []
const productList = makeProductList({ productDb })
const cart = makeShoppingCart(productList)

Итоги


Когда мы узнаём о чём-то новом, особенно если речь идёт о чём-то достаточно сложном, вроде архитектурного приёма проектирования приложений, мы склонны ожидать наличия чётких правил, связанных с тем, о чём узнали. Нам хочется услышать что-то вроде следующего: «всегда поступайте так и никогда не поступайте вот так».

Чем дольше я вращаюсь в среде разработки ПО, тем лучше понимаю, что здесь нет таких понятий, как «всегда» и «никогда». У программиста всегда есть выбор, продиктованный комбинацией сильных и слабых сторон того или иного приёма, применённого к конкретной ситуации.

Выше мы говорили о сильных сторонах паттерна Ice Factory. Но у него есть и минусы. Они заключаются в том, что объекты с помощью этого паттерна создаются медленнее, чем с использованием классов, и требуют больше памяти.

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

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

Главное — не забывайте профилировать приложение и не стремитесь к преждевременной оптимизации. Создание объектов редко оказывается узким местом программы.

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

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


Вы можете использовать другие подходы к стилю кода, и это совершенно нормально. Стиль — это не паттерн.

Шаблон проектирования Ice Factory, в общем-то, сводится к тому, чтобы использовать функцию для создания и возврата «замороженных» объектов. А как именно написать такую функцию, вы можете решить сами.

Уважаемые читатели! Пользуетесь ли вы чем-то вроде паттерна Ice Factory в своих проектах?

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


  1. L-N
    22.03.2018 14:28
    +1

    Что общего у методов объекта Java и JavaScript? Пришедший из языка вроде Java скажет "." (точка, способ обращения к методу объекта).


  1. Sckmdg
    23.03.2018 13:26
    +1

    Спасибо за перевод! Правда есть свои комментарии по этому поводу и было бы интересно услышать мнения других.

    cart.addProduct = () => 'nope!' 
    // В строке выше нет ошибки!
    


    Если человек сам переопределил метод класса и потом удивляется, что метод работает не так как надо, ну такое себе.

    Потеря this — можно юзать стрелочные функции, не?


  1. superconductor
    23.03.2018 13:26

    Сначала странные люди из Java приходят в js и говорят:"нам нужны классы". Потом другие странные люди пишут, что this и new ведут себя странным не логичным образом. А на самом деле они не поняли прототипного наследования, что с синтаксисом классов совсем не удивительно.