Разделение иерархий для поддерживаемого кода

Когда именно унаследованный код становится унаследованным? Вы знаете это чувство, когда только начинаете проект. Создавать новые маршруты и компоненты очень просто. Быстрый прогресс впечатляет. Но где-то по ходу дела внесение изменений в ваш код становится рутиной.

В этом посте я расскажу о наборе антишаблонов, которые приводят к неприятному устаревшему коду. Я расскажу о некоторых стратегиях, которые помогли мне дольше сохранять ощущение свежести в проектах. Я сосредоточусь на фронтенд-разработке с Ember, но уроки применимы и дальше.

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

Сейчас я в основном кодирую Ember или React с Redux. Обе эти структуры избегают многих из предыдущих ошибок. Мы не используем глобальные переменные. Мы с радостью используем доброту ES6 +. Наши фреймворки обрабатывают подписки на события и DOM с удивительной гибкостью. Тем не менее, простота повторения проекта редко соответствует простоте его создания. Это почему?

Тем не менее, простота повторения проекта редко соответствует простоте его создания. Это почему?

С зерном или против?

Возможно, вы это почувствовали - есть ощущение, что некоторые изменения находятся «в зерне» и их довольно легко внести. Хотя некоторые изменения внести гораздо сложнее, чем кажется. Они как-то режут зерно. Разница часто не интуитивна. Спросите любого дизайнера, который запросил несколько изменений. Часто те, которые кажутся сложными, оказываются самыми простыми. А самые простые, казалось бы, требуют свернуть горы. Это почему?

Чтобы ответить на этот вопрос, давайте посмотрим, что происходит, когда вы создаете функцию. В этом случае я использую Ember (но применимо к другим фреймворкам). Представим, что вас попросили добавить функцию DonutCounter. Вы запускаете несколько ember generate команд и создаете несколько файлов вручную. В зависимости от того, насколько полно ваша функция, вы получите что-то вроде этого:

app/
  router.js  
  components/DonutCounter.js
  templates/DonutCounter.hbs
  styles/DonutCounter.scss
  tests/integration/DonutCounter
  routes/count-donuts.js
  controller/count-donuts.js
  language/strings_*.js (language support)
  models/DonutCounts
  serializers/DonutCounts (tweak the data from the server)

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

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

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

Ремонтопригодность зависит от степени взаимосвязи иерархий. Копирование одной закодированной вручную иерархии с другой - рецепт разочарования.

Сделайте простое изменение. Переименование DonutCounter в StrudelCounter. Довольно быстро мы узнаем, сколько файлов касается этого единственного существительного. Ничего страшного, немного магии grep и bash должно хватить.

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

CSS против вашей иерархии компонентов

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

<body class="DonutPage">
  <div class="DonutContainer">
    <div class="DonutCounter">
      <div class="DonutButton">…</div>
    </div>
  </div>
</body>

Затем я бы написал немного CSS, например:

.DonutPage {
  …
  .DonutContainer {
    …
    .DonutCounter {
      …
      .DonutButton {
        …
      }
    }
  }
}

Сначала это казалось правильным! Но видите проблему? Я глубоко связал одну иерархию с другой. Я бы поменял одно, а другое сломало бы. Стало еще хуже. Если бы я переместил класс .DonutButton, мне пришлось бы учитывать весь CSS из вышеперечисленных уровней, которые на него повлияли.

Помогли две вещи:

  1. Я перестал вкладывать свой CSS (кроме тех случаев, когда он не объединяет иерархии). Нет веских причин писать CSS, который оценивается как .DonutPage .DonutContainer .DonutCounter .DonutButton {…}.
  2. Я перенес большую часть своего CSS в общие классы. Очень помогли библиотеки, такие как bootstrap.

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

Ваш поток пользователей по сравнению с вашими тестами

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

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

Server Returns Current Count -> 
  User Increments Count ->
    User Saves Count -> 
      A successful message appears.

Заманчиво написать такой тест:

describe('DonutCounter', function() {
  beforeEach(…); // setup what the server provides
 
  describe('User increments count', function() {
    beforeEach(…); // click the button
 
    describe('User saves count', function() {
      beforeEach(…); // click the save button
       
      describe('Count is saved')
        … // and on and on
      })
    })
});

Кажется, что эти beforeEach функции избавляют вас от кучи настройки и сушат ваш код. Вам нужно сделать эту кнопку, нажав код только один раз. Но на самом деле, как и в примере с вложенным CSS, вы связываете одну иерархию с другой. Иерархия пользовательского потока обязательно изменится таким образом, что не соответствует вашей тестовой иерархии. Лучше найти способы их развязать. Один из способов - представить каждый сценарий в объекте состояния. Таким образом, вы можете создавать и компоновать эти сценарии состояния столько, сколько необходимо.

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

Иерархия компонентов против потока данных

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

Вот что я имею в виду. Допустим, компонент DonutCounter содержит DonutButton, увеличивающий пончики. Кажется достаточно простым. Вот структура:

DonutPage
  PageHeader
  DonutCounter
    DonutButton

Пока наша иерархия компонентов соответствует иерархии потока данных. Данные перетекают из нашего DonutPage в DonutCounter. Прохладный. Но затем нас попросили переместить DonutButton с DonutCounter на PageHeader. Ой ой. У нас есть несоответствие. Мы по-прежнему хотим, чтобы DonutCounter отображал счетчик и все, что он делает. Но теперь мы имеем дело с этими связанными иерархиями. Чтобы решить эту проблему, у нас есть несколько стратегий:

  1. Общий родительский элемент: рефакторинг иерархии для маршрутизации данных через общий родительский элемент. В этом случае переместите логику с DonutCounter на PageHeader или DonutPage. Это наиболее распространенный ответ, но он утомителен.
  2. Червоточина: вырвитесь из иерархии компонентов, используя что-то вроде Ember Wormhole. Это позволяет нам отображать DonutButton в PageHeader, делая вид, что это не так. Это довольно умно, но всегда похоже на хакерство.
  3. Мутация модели: обновите модель с DonutButton. Это быстро становится неуправляемым, когда у вас есть несколько компонентов, выполняющих одни и те же действия. Большинство руководств по стилям не одобряют этого.
  4. Служба: представьте службу для сохранения этого состояния.

А вот последний, The Service, довольно интересный. Особенно, если этот сервис представляет собой инструмент управления состоянием, такой как Redux.

И вот наш первый проблеск света. Мы можем разделить эти две тесно связанные иерархии, используя уровень абстракции. Библиотеки управления состоянием снижают сложность, поскольку они отделяют иерархии, которые когда-то были объединены. В этом случае DonutButton может обновлять DonutState для всего приложения независимо от того, где он находится в иерархии компонентов. Это большое, часто негласное преимущество инструментов управления состоянием.

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

Структура каталогов против всего

Вдохновленный Rails, Ember предлагает много волшебства в структуре каталогов по умолчанию:

app
  adapters
  serializers
  models
  components
  templates
  styles
  tests

Итак, прямо из коробки Ember объединяет несколько иерархий. Часто изменения в ваших адаптерах, сериализаторах или моделях требуют изменений в других. То же самое с компонентами, шаблонами и стилями. Это одна из причин, по которой Ember’s Module Unification (ранее называвшаяся Pods) является привлекательной. Например, он размещает ваш шаблон, компоненты и стили вместе. Известно, что React идет еще дальше. Он предпочитает, чтобы вы хранили компоненты шаблонов вместе в одном файле.

Маршрутизация против DOM

В первом созданном мной приложении Ember я начал с того, что знал лучше всего: с пользовательского интерфейса. Я создавал вики со списком страниц слева и выбранной страницей справа. Довольно стандартный материал. Я создал шаблон с колонкой слева и деталями справа. Это сработало! «А теперь я просто добавлю маршруты», - подумал я. Не так быстро. Вскоре я узнал, что маршрутизация тесно связана с пользовательским интерфейсом. Вам нужен один маршрут, контроллер, шаблон и представление для рендеринга левой руки, а затем еще один набор для правой. Той ночью я работал допоздна, перемешивая код.

Я изучал пресловутый Ember Way. Это очень похоже на React Way и Rails Way. Наши фреймворки слишком часто создают тесную связь между иерархией компонентов и маршрутизацией. Весь ваш пользовательский интерфейс, включая иерархию DOM, тесно связан с вашей маршрутизацией. Это здорово, если вы точно знаете, как должен работать ваш пользовательский интерфейс. Но ужасно, если у вас есть бездоказательные предположения. Другими словами, если ваша команда пытается быть гибкой.

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

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

Одноразовый пользовательский интерфейс, многоразовые детали

Разве это не было целью с самого начала?

Допустим, у вас было ведро Лего. Если бы вы собрали скульптуру из Lego, использовали бы вы клеевой пистолет? Конечно, нет. У Legos есть естественный стандартный интерфейс, который собирается в стабильный граф. И если вы их разобрали, вы тоже уничтожите части лего? Вы могли бы, если бы использовали клеевой пистолет!

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

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