#Om, милый Om: (высоко-)функциональное проектирование фронтенда с ClojureScript и React

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

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

В частности, (как и для большинства команд создающих программное обеспечение) нашей основной целью стало увеличение производительности и написание кода, который обладает следующими качествами:

Судя по нашему опыту построения веб, iOS и бэкенд-приложений, высокая степень связанности и большинство ошибок возникают в процессе управления состояниями приложения. С ClojureScript и Om(ClojureScript интерфейс для React) мы наконец-то нашли ту архитектуру, которая примет на себя большую часть решения этой задачи. Два месяца назад мы переписали наше веб-приложение с использованием этой архитектуры и получили огромный прирост к эффективности при сохранении высокой производительности приложения.

Скриншоты

Теперь наш код — чуть меньше пяти тысяч строк ClojureScript (не включая библиотеки), что в пять раз меньше, чем было раньше. Конечно, размер это ещё не всё. Каждый бэкенд-разработчик внес свой вклад в код, что многое говорит о его высокой читабельности и доступности.

Читайте дальше, если хотите узнать о том, как мы ускорили процесс разработки с Om, ClojureScript и React.

ClojureScript

Серверная часть Prismatic построена на Clojure — очень продуманном, современном диалекте Lisp, работающем на JVM, который, как нам кажется, идеально соответствует современным требованиям к разработке программ. Clojure имеет превосходную поддержку функционального, ориентированного на работу с данными, программирования с эффективными неизменяемыми структурами данных. Так же он обладает очень выразительной поддержкой атомарных, композируемых абстракций и почти бесконечную расширяемость при помощи макросов.

Учитывая нашу любовь к Clojure, мы были в восторге от введения ClojureScript — диалекта Clojure, компилирующегося в JavaScript и дающее вебу все преимущества Clojure. Так же как и Clojure, ClojureScript сохраняет такую же высокую производительность, благодаря использованию современных движков JavaScript в сочетании с превосходной библиотекой Google Closure.

Функциональное программирование

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

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

Макросы

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

Например, макрос ClojureScript ->> «thread-last» поворачивает вложенные формы наоборот, делая их более читабельными как последовательность процедур. Учитывая полезность и общую применимость, реализация макроса может быть очень короткой:

(defmacro ->>
  ([x form] `(~@(if (seq form) form [form]) ~x))
  ([x form & more] `(->> (->> ~x ~form) ~@more)))

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

(inc (last (take 2 (filter odd? [1 2 3 4 5]))))

Макрос позволяет написать эту последовательность операций в более читабельной форме, более соответствующей тому, как мы это воспринимаем:

(->> [1 2 3 4 5]   ; the initial vector
     (filter odd?) ; after this step we have: (1 3 5)
     (take 2)      ; (1 3)
     last          ; 3
     inc)          ; 4

Другой, более сложный пример — core.async, использующий макросы для привнесения горутин (goroutine) в стиле языка Go от Google в ClojureScript. Это естественный путь выражения асинхронной модели, которая, как правило, часто возникает в веб-разработке, в синхронном стиле (без функций обратного вызова). Обычно поддержка такого стиля программирования должна быть включена в компилятор, но макросы позволяют эффективно расширить язык, так что горутины могут быть представлены как небольшая библиотека. Кроме того, мы создали ряд собственных библиотек, использующих макросы, чтобы добавить новый синтаксис для ClojureScript, в том числе Plumbing, Schema и om-tools.

React

Тогда как Clojure привносит функциональную парадигму программирования в структуры данных веб, React привносит ее же в DOM, предоставляя простой и мощный инструмент для построения компонентных пользовательских интерфейсов. Если вы еще с ним не знакомы, мы очень рекомендуем вам ознакомиться с тем, почему Facebook создали React. В документации по React приведена его главная задача: «Просто выразите то, как приложение должно выглядеть в определенный момент времени, и React будет автоматически управлять всеми изменениями UI, когда вы изменяете данные».

Согласованность данных в DOM

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

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

Схема

Менее подвержена ошибкам «звездообразная» архитектура, которая сохраняет каноническое представление каждого участка состояния, и все компоненты, которые изменяются или представляют это состояние, общаются напрямую с центральным узлом:

Схема

«Звездообразная» архитектура уменьшает количественное ограничение к линейному количеству компонентов. Двухстороннее связывание шаблона с данными, самый современный на сегодняшний день подход, предоставляет абстракции, которые уменьшают шаблон, необходимый для его реализации. Но Пит Хант (Pete Hunt), разработчик React, утверждает, что это не очень хорошо. Поскольку двухстороннее связывание шаблона — это достаточно сложная задача, каждая библиотека создает свою собственную экосистему вокруг шаблонизатора, которую почти невозможно расширять и поддерживать в долгосрочной перспективе.

React предлагает более простое решение «проблемы состояний», что сперва может показаться безумием. Вы просто пишете чистые JavaScript-функции, которые переводят ваши данные в виртуальное представление DOM. React вызывает эти функции один раз для генерации DOM каждого компонента, который загружает приложение. Затем каждый раз, когда данные вызывают изменения компонента, React автоматически вызывает соответствующую функцию для пересоздания затронутой части пользовательского интерфейса. Концептуально — это все что нужно сделать, пользовательский интерфейс становится просто функциональной проекцией состояния приложения.

Это, вероятно, звучит как кошмар, с точки зрения производительности. Но управляя виртуальным DOM, React может эффективно сравнить то, что сейчас отображается на экране, с тем, что должно отображаться, избегая ресурсоемких запросов к настоящему DOM. Он вычисляет и выполняет наименьший набор возможных изменений для трансформации текущего DOM, чтобы соответствовать DOM виртуальному. Так как DOM манипуляции гораздо медленнее, чем JavaScript-вычисления, средняя производительность React часто совпадает (или даже превосходит) с производительностью, свойственной другим общепринятым подходам, в то же время освобождая разработчика от рассуждений об управлении согласованностью между компонентами и обновлениями в результате изменения данных. Вся побочная сложность, естественно возникающая при мутации DOM, улетучивается.

Om

Философия дизайна ядра в React по существу функциональная, и во многом это более соответствует функциональному языку ClojureScript, чем JavaScript. Om построен на React, но продвигает его идеи несколько дальше, используя неизменяемые структуры данных для представления состояния приложения, тем самым увеличивая производительность и архитектурные преимущества.

Единое, нормализованное состояние приложения

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

Есть несколько важных преимуществ данного подхода.

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

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

Наконец, состояние нормализовано: каждый кусочек информации представлен в одном месте. Так как React обеспечивает согласованность между DOM и прикладными данными, программист может сфокусироваться на том, чтобы состояние оставалось точным в ответ на действия пользователя. Если состояние приложения нормализуется, то это обеспечивает согласованность по определению, позволяя полностью избегать возможности общих ошибок.

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

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

Во-вторых, производительность React в некоторой степени полагается на модульность компонентов и их данные: когда данные изменяются, только компоненты отвечающие за эти данные перерисовываются. Казалось бы, сборка всего состояния приложения в одном месте уничтожит это преимущество, требуя повторную (виртуальную) перерисовку всего приложения, независимо от того как мало данных изменилось.

Om предлагает решение этих проблем.

Om восстанавливает инкапсуляцию и модульность при помощи курсоров. Курсоры предоставляют обновляемые окна в особых участках состояния приложения (очень похоже на zippers), что позволяет компонентам получать ссылки только на релевантный участок глобального состояния и обновлять его в контекстно-свободной манере.

Схема

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

Пример

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

В стационарном состоянии это просто реализовать. Бэкенд посылает список тем, за которыми следит пользователь, и они используются для заполнения списков тем в UI, а так же ставит галочку рядом с каждой темой, отмеченной в истории. Например, здесь приведено изображение из ленты Городского Исследования. Слева то, что видит пользователь, если он не следит за темой Городского Исследования. Когда пользователь нажимает на кнопку «Следить» под заголовком ленты, это изменение должно быть передано на сервер, в локальное состояние и в несколько мест в пользовательском интерфейсе (указаны стрелочками).

Пример подписки на тему со стрелочками

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

Заключение

Мы обнаружили, что вместе ClojureScript, React и Om предоставляют простой способ для управления данными, изменяющимися в течение долгого времени — одной из самых сложных проблем UI-разработки. Информация о всех пользовательских взаимодействиях и общении с бэкендом поступает в единое состояние приложения, которое разложено на компоненты, которые Om/React отрисовывают автоматически. Om автоматически управляет сохранением представления данных через независимые компоненты и позволяет нам сосредоточится на основной логике, управляющей нашим приложением.

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

Спасибо: Скоту Рабину (Scott Rabin), Кевину Линьярду (Kevin Lynagh) и Шону Грову (Sean Grove) за вычитку черновика этой статьи.