Ты сегодня лучше всех! 😎
Андрей Михайлов

Андрей Михайлов @lolmaus

фронтенд‑разработчик, EmberJS‑энтузиаст
← В блог

Возвращайте RSVP.hash() из model-хуков ваших маршрутов!

8-го декабря 2016

Принято считать, что возврат хэша из model-хука — это плохая практика. Я убежден, что это не так! Я всегда возвращаю хэш по определенной схеме, и нахожу это чрезвычайно удобным.

Сам я никогда так не считал, поэтому давайте посмотрим, что об этом думает Ember-разработчик поумнее меня.

Sam Selikoff, героический автор Mirage, в этом постевысказался категорически против возврата хэша из model-хуков.

Хотя я определенно следую примеру Сэма в вопросах паттернов Ember, по данному конкретному вопросу смею решительно не согласиться.

Вот некоторые из его аргументов в моем кратком изложении. Обязательно прочтите оригинальный пост!

  • Само имя хука — model — предполагает возврат одной сущности.
  • Необходиость возврата более одной сущности — это признак неудачно спроектированного ERM.
  • Если у вас есть потребность возвращать несколько разных моделей, вам следует отрефакторить приложение, введя связующую модель, имеющую relationships с необходимыми вам моделями. Такая связующая модель будет одной сущностью, представлющей определенную комбинацию свзяанных моделей.
  • Если же модели настолько самостоятельны, что объединять их под одной сущностью не представляется разумным, то вам не следует грузить из все в маршруте. Выберите самую важную из них и грузите ее, а остальные загружайте на уровне контроллера/компонента после первоначального рендеринга страницы.
  • В Rails, контроллеры должны создавать только одну сущность.

Давайте посмотрим.

Само имя хука — model — предполагает возврат одной сущности.

Имя метода не является для меня определяющим фактором. Прежде всего, в вэб-разработке понятие "модель" является широким. В контексте слоя модели из MVC, такого как Ember Data, "модель" означает класс, представляющий ресурс, и использующийся для создания записей-инстансов данного ресурса. Однако вне контекста MVC, "модель" -- это просто ваши данные, и они могут быть любыми, от простой строки до сложной, произвольно организованной JSON-подобной структуры.

Кроме того, Сэм не выступает против возврата массива из model-хука, несмотря на то, что название хука — не models.

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

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

Я нахожу рекомендацию рефакторить ERM так, чтобы каждый маршрут был представлен одной сущностью, идеалистической и наивной.

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

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

Можно было бы ввести связующую модель только на стороне фронтенда. В некоторых сложных случаях это оправдано (пример). Однако цена высока: ERM фронтенда и бэкенда начнут отличаться. Я считаю, что идти этим путем просто ради отказа от возврата хэша -- это бесмысленный расход ресурсов.

Даже если вы можете синхронизировать рефакторинг ERM на фронте и бэке, все равно это ужасно большой объем труда. И всё ради того, чтобы избежать этого простого паттерна.

В Rails, контроллеры должны создавать только одну сущность.

Сравнивать Ember с "Рельсами" -- некорректно. Хотя они оба считаются MVC-фрэймворками, устроены они очень по-разному. У Rails вообще отсутствует класс "маршрут", а на каждый REST-запрос вызывается строго один контроллер, даже если адрес обращения представляет вложенный ресурс. В Ember же маршруты -- это сущности, ответственные за загрузку данных и вызываются по цепочке.

Но самое главное, Сэм не называет ни одного практического недостатка возврата хэша. Это потому, что недостатков нет! Зато есть преимущества.

Давайте я изложу, как я это делаю, после чего посмотрим на преимущества.

model-хук каждого маршрута всегда должен возвращать RSVP.hash(). Даже если маршрут грузит всего лишь одну сущность, из хука возвращается хэш с одним свойством.

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

Мои model-хуки выглядят примерно так:

// posts route
model () {
  const store = this.get('store')

  return RSVP.hash({
    posts: store.findAll('post'),
  })
}
// posts.post route
{
  model ({postId}) {
    const store = this.get('store')
    const model = this.modelFor('posts')

    return RSVP.hash({
      ...model,
      currentPost: store.peekRecord('post', postId),
    })
  }
}

Оператор ... -- это spread, синтаксический сахар для Ember.merge и Object.assign.

Маршрут posts.post в итоге получит такую модель:

{
  posts: [post1, post2, post3],
  currentPost: post2
}

Давайте посмотрим, как это улучшает вашу кодовую базу!

Один из аргументов Сэма, который я не упоминал ранее, -- это что обращение к model.posts в шаблоне — это хуже, чем просто model. Я не согласен.

Когда я вижу model в шаблоне, это всегда смущает и запутывает. Другое дело model.posts -- сразу очевидно, к какой сущности делается обращение.

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

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

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

  • Мне не потребуется вводить связи previousPost и nextPost на модели post.
  • Мне не нужно вводить новую сущность -- связующую модель, которая представляет пост вместе с двумя смежными постами.
  • Мне не нужно вызывать store.peekRecord на уровне компонента или контроллера.
  • Мне не нужно использовать setupController. Я вообще считаю setupController дурной практикой, которая отказывается от декларативных computed properties в пользу императивщины, без нужды увеличивающей связность приложения. Есть ровно одно оправданное применение setupController: прокидывать ошибку в error-маршрут.
  • Мне не нужно вводить сервис, задача которого — выдавать посты, смежные по отношению к данному.

Все, что мне требуется сделать, это обратиться к model.posts! Мне даже не интересно, в каком именно из родительских маршрутов были загружены эти записи.

Обратите внимание, что я не пропагандирую предзагрузку всех постов в маршруте posts. В простейшем случае, загружать список постов лучше в маршруте posts.index. Таким образром, посестелю не придется выгружать все посты, если он зашел почитать какой-то один из них.

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

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

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

В этом случае достаточно накидать несложную цепочку computed properties:

import {sort}              from 'ember-computed'
import sum                 from 'ember-cpm/macros/sum'
import indexOf             from 'make/your/own/macro'
import getFromArrayByIndex from 'it/is/easy/and/fun'

{
  sortOrder:         'createdAd', // допустим, сортировка может регулироваться пользователем
  sortedPosts:       sort('model.posts', 'sortOrder'),
  currentPostIndex:  indexOf('sortedPosts', 'model.currentPost'),
  nextPostIndex:     sum('currentPostIndex', 1),
  previousPostIndex: sum('currentPostIndex', -1),
  nextPost:          getFromArrayByIndex('sortedPosts', 'nextPostIndex'),
  previousPost:      getFromArrayByIndex('sortedPosts', 'previousPostIndex'),
}
{{#if nextPost}}
  {{link-to (concat '← ' nextPost.title)     'posts.post' nextPost.id}}
{{/if}}

{{#if previousPost}}
  {{link-to (concat previousPost.title ' →') 'posts.post' previousPost.id}}
{{/if}}

Этот код декларативен, защищен от багов, насколько это вообще возможно, и понятен с первого взгляда.

Кроме того, он производителен: значения computed properties кэшируются, и при повторном посещении маршрута контроллеру/компоненту не потребуется вычислять значения заново. Но если состав массива изменится, они сразу же пересчитаются автоматически.

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

В этом примере подгружаются авторы поста и всех комментариев

{
  model ({postId}) {
    const store = this.get('store')
    const model = this.modelFor('posts')

    return RSVP
      .hash({
        ...model,
        currentPost: store.peekRecord('post', postId),
      })

      // Пост загрузился, давая возможнось получить его автора и комменты
      .then(model => RSVP.hash({
        ...model,
        author:   model.currentPost.get('author'),
        comments: model.currentPost.get('comments'),
      }))

      // Комменты загрузились, добываем их авторов:
      .then(model => RSVP.hash({
        ...model,
        commentAuthors: store.query('user', {
          'filter[ids]': this._getCommentAuthorIds(model.comments)
        })
      }))
  },

  // Удобный способ получить список айдишников авторов всех комментариев
  _getCommentAuthorIds (comments) {
    return comments
      .map(comment => comment.belongsTo('author').id())
      .join(',')
  },
}

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

Я полностью согласен с тем, что в большинстве случаев обращаться к записи через цепочку связей, например, model.currentPost.comments[n].author) — лучше, чем, скажем, получать автора каждого коммента через поиск по массиву model.commentAuthors.

Но дело в том, что хоть все авторы доступны в свойстве model.commentAuthors, вы по-прежнему можете обращаться к авторам через цепочку связей. model.commentAuthors -- это просто легкочитаемый способ подгрузить данные, и он вовсе не ограничивает вас в возможности обходить граф моделей так, как вам удобно.

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

Это проблема не самого подхода, а использования морально устаревших инструментов.

ember install ember-eslint решает эту проблему раз и навсегда. Не стоит отказываться от мощного spread-оператора из-за того, что JSHint не умеет в ES2015.

Пожалуйста, поделитесь ниже в комментариях вашим мнением по этому поводу!

Делает ли данный подход вашу программерскую жизнь капельку приятнее?

Какие у него недостатки? Можно ли их решить, или же подход изначально ущербен?