Атомы и реактивность
У атомов в 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))))
Ключ здесь нужен для того, чтобы функцияобработчик могла отличать один атом от другого, если эта функция оказывается подписана на несколько атомов.
Сейчас в роли обработчика выступает анонимная функция. И как любой обработчик, функция эта получает при вызове четыре аргумента:
- сам атом,
- упомянутый выше ключ,
- значение, на которое атом указывал ранее,
- значение, на которое атом указывает сейчас
В примере выше нам интересно только новое значение, поэтому остальные параметры функцииобработчика названы именем
_
. Это общепринятый способ называть аргументы, наличия которых требует вызывающая сторона, а самой функции эти параметры не важны.
В данный момент обработчик вызывает функцию 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 работает с платформой. И в будущем вы сможете себе представить, как та или иная библиотека работает "под капотом".