Атомы и реактивность

У атомов в Clojure есть ещё одна особенность, которая очень полезна при разработке Web­приложений: вы можете подписаться на изменения атома и, к примеру, своевременно обновлять пользовательский интерфейс.

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

(ns ...)

;; атомы часто располагают в начале модуля
(defonce counter (atom 0))

...

Здесь используется форма defonce, которая похожа на обычное объявление с помощью def. Отличается defonce от def тем, что при горячей перезагрузке кода (которую производит Shadow ClJS в "режиме разработки") значение такого объявления не изменяется.

Это достаточно удобно — не терять состояние приложения при перезагрузке кода. Однако нужно понимать, что иногда состояние приходится таки сбрасывать, например если поменялась сама структура того, как данные должны в этом конкретном атоме храниться. В таких случаях вы можете перезагрузить страницу целиком или же в REPL вызвать reset!, перенаправив таким образом атом на новое, корректное значение.

Подписка на изменения

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

(defn init []
  (draw @counter)  ;; первый раз draw вызывается принудительно
  (add-watch counter  ;; атом, на который оформляется подписка
             :draw    ;; "ключ", идентифицирующий подписку
             (fn [_ _ _ value]  ;; функция-обработчик изменений
               (draw value))))

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

Сейчас в роли обработчика выступает анонимная функция. И как любой обработчик, функция эта получает при вызове четыре аргумента:

  1. сам атом,
  2. упомянутый выше ключ,
  3. значение, на которое атом указывал ранее,
  4. значение, на которое атом указывает сейчас

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

В данный момент обработчик вызывает функцию draw с текущим значением счётчика в качестве аргумента. И принудительный вызов (draw @counter) в начале работы программы тоже передаёт значение счётчика в функцию draw Нужно изменить функцию так, чтобы она имела соответствующий параметр и отображала передаваемые ей значения:

(defn draw [value]
  (-> (.querySelector js/document "#root")
      .-innerHTML
      (set! (str "<h1>" value "</h1>"))))

Функция str конкатенирует все свои аргументы в одну строку, приводя к строке значения других типов, если таковые встречаются.

Если теперь обновить страницу, или вызвать (init) в REPL, то страница должна показать начальное значение счётчика — "0". И если в REPL увеличить значение счётчика с помощью вызова (swap! counter inc), то текст на странице также изменится и произойдёт это автоматически — благодаря подписке.

Реакция на перезагрузку кода

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

(defn ^:dev/after-load redraw []
  (draw @counter))

Здесь ^:dev/after-load означает "дописать к метаданным функции redraw значение true по данному ключу". Дело в том, что метаданные хранятся в виде отображения. Но часто важно именно наличие ключа, а само значение не существенно. Поэтому для такого случая разрешён укороченный вариант записи. Если вдруг вам будет интересно, можете запросить в REPL метаданные для функции redraw:

com.frontend.app> (meta #'redraw)
;; => {:ns com.frontend.app,
 :name redraw,
 :file "com/frontend/app.cljs",
 ...
 :dev/after-load true,  ; вот и флаг!
 ... }

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

В Clojure метаданные можно добавлять практически к любому значению за исключением примитивных типов вроде строк или чисел. Для этого используется функция (with-meta значение {:ключ :значение ...}). И даже defn в реальности представляет собой макрос, который связывает символ имени с анонимной функцией, попутно с помощью with-meta добавляя к ссылке на функцию порцию метаданных.

Интерактивность

На внешние воздействия счётчик уже реагирует, теперь нужно добавить пару кнопок, чтобы и пользователь приложения мог менять значение счётчика. Пока что мы описываем теги в виде строковых литералов, поэтому <button> пока будут получать обработчики нажатия в атрибуте onclick. А это значит, что в качестве значения атрибута будет выступать код на JavaScript, пусть даже мы и хотим вызывать функции, которые напишем на Clojure. Впрочем, так мы сможем увидеть ещё один вид взаимодействия ClJS с платформой: вызов функций из мира ClojureScript в коде на JavaScript.

Так как проект сейчас имеет всего один модуль, описывающий пространство имён com.frontend.app, то все определения, описанные в этом модуле, будут видны со стороны JavaScript как атрибуты объекта app, являющегося атрибутом объекта frontend, являющегося атрибутом объекта com, который, наконец, является атрибутом глобального объекта document. Другими словами уже имеющаяся у нас функция init может быть вызвана в коде на JavaScript по полному имени: com.frontend.app.init() — всё настолько просто! Разве что имена, которые по­лисповски названы в стиле kebab-case, со стороны JS будут выглядеть как snake_case.

Добавим сразу обе функции, которые будут обрабатывать нажатия кнопок, и модифицируем функцию draw так, чтобы кнопки встраивались в вёрстку:

(defn inc-counter [] (swap! counter inc))
(defn dec-counter [] (swap! counter dec))

(defn button [text action]
  (str "<button onclick=\""
       (-> action
           .-name
           (clojure.string/replace "$" "."))
       "()\">"
       text
       "</button>"))

(defn draw [value]
  (-> (.querySelector js/document "#root")
      .-innerHTML
      (set! (str "<h1>"
                 value
                 (button "+" inc-counter) ; вот и кнопки
                 (button "-" dec-counter)
                 "</h1>"))))

Сами обработчики устроены просто: они всего лишь обновляют атом с помощью функций inc и dec (увеличение и уменьшение на единицу, соответственно).

В draw тоже добавились только два вызова функции button. Каждый раз в функцию передавались строка и функция, тоже с виду всё просто.

А вот функция button интересна тем, как она работает со значениями своего параметра action. В качестве значений этот параметр получает функции на ClojureScript, но в HTML­то нужно вставить правильные имена JavaScript­объектов, в которые эти функции будут скомпилированы!

К счастью, каждая скомпилированная функция получает имя, составленное из полного имени пространства имён плюс имени функции, только в этом имени все точки заменяются на "$", а все дефисы — на символ "_". И сохраняется это имя в атрибуте .name объекта. Нам остаётся только извлечь значение атрибута, заменить в нём $ обратно на ., и мы получим "внешнее имя": "com$frontend$app$inc_counter" превратится в "com.frontend.app.counter". Такое преобразование и выполняется выше в коде функции button.

Подведём итоги

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

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

Вот только и с точки зрения JS работать с DOM напрямую утомительно. Поэтому большинство разработчиков использует библиотеки, скрывающие всю низкоуровневую работу с документом. Точно так же поступают и в мире ClojureScript, зачастую используя те же библиотеки из мира JS, только в более удобной для программирования именно на ClojureScript обёртке.

Таким образом, текущая версия приложения выглядит сложнее, чем могла бы быть. Но зато и никакой "магии" сейчас в проекте нет — видно, как ClojureScript работает с платформой. И в будущем вы сможете себе представить, как та или иная библиотека работает "под капотом".