Реактивная оптика

Реактивная оптика

В прошлый раз я писал о проектике для дегустации React в связке с clojurescript. В процессе написания возникло и закрепилось стойкое ощущение: UI-компонентами состояние модифицируется слишком "напрямую". Отчёт о борьбе с этой "прямотой" и последует ниже.

React в проекте используется через обёртку quiescent - оная оборачивает только визуальную часть react, оставляя управление состоянием пользователю. Собственно, данный момент и привлекает многих именно к этой библиотеке. React-компоненты, в quiesсent описываются следующим образом:

(q/defcomponent Counter
  [[id val]]
  (html
   [:div
    [:span (str val)]
    (btn "+" (fn [] (update-counter id inc)))]))

(q/defcomponent Root
  [{:keys [counters]}]
  (html
   [:div
    [:h2 "Counters"]
    (btn "+" (fn [] (add-counter)))
    (concat [:div]
            (mapv Counter                 ;; (*)
                  (sort counters)))]))

Компонент напоминает обычную функцию, аргументом получающую некие данные и возвращающую фрагмент HTML, описанный в термах clojure. Компонент может использовать в своем теле другие компоненты в роли обычных функций (*). Описание же HTML в виде встроенных структур данных очень удобно хотя бы тем, что позволяет генерировать куски разметки обычными функциями:

(defn btn
  [text handler]
  ;; возвращаемое значение - обычный вектор:
  [:input {:type "button"
           :value "+"
           :onclick handler}])

Все данные, необходимые для рендеринга компонента, последний получает строго через аргументы, причем компонент даже не пытается исходить из своего прошлого состояния и оптимизировать отрисовку - всё рендерится от начала и до конца. Именно поэтому код компонента выглядит так просто, а эффективностью отрисовки занимается React - красота! При этом идеологи модели компонентов пошли даже чуть дальше - компоненты вообще не имеют внутреннего состояния, даже неявного! Именно поэтому повторно использовать такие кусочки UI проще простого: объемлющий контейнер полностью контролирует данные вложенных в него элементов, т.к. сам передаёт в них данные при вызове, а значит вложенность и взаимное расположение элемента интерфейса не зависят от реализации оных.

Состояние в таких системах обычно кладётся в некое хранилище, за изменением которого можно наблюдать. Например - в atom:

(def world (atom {:counters {}}))

;; функция рендеринга,..
(defn render [data]
  (q/render (Root data)
            (.getElementById js/document "main-area")))

;; .. которая вызывается по изменению атома
(add-watch world ::render
           (fn [_ _ _ data] (render data)))

И все бы ничего, но в какой-то момент нам требуется силами компонента влиять на его собственное состояние. Можно воспользоваться глобальным atom'ом и функциями, модифицирующими его:

(defn add-counter [] (swap! world ...))

(defn update-counter
  [id f]
  (swap! world
    assoc-in [:counters id] f))

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

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

Бороть недостаток решено было введением дополнительного параметра вызова компонента, в котором бы передавалась некая функция - назовём её "updater". Компонент при таком раскладе вместо прямого воздействия на состояние передаёт в updater функцию изменения своего под-состояния, а уж updater сам разберётся, как, а главное - где, это изменение произвести! Компонент будет иметь вид:

(q/defcomponent Counter
  [value app]  ;; app (от слова apply) - собственно, updater
  (html
   [:div
    [:span (str value)]
    (btn "+" #(app inc))
    (btn "-" #(app dec))]))

Контейнер будет выглядеть так:

(q/defcomponent Root
  [{:keys [counters]} app]
  (html
   [:div
    [:h2 "Counters"]
    (btn "+" (fn [] (add-counter)))
    (concat [:div]
            (mapv (fn [k v]
                      (Counter v (at-key k app)))  ;; (*)
                  (sort counters)))]))

Тут интересен updater, передаваемый в дочерние элементы (*). Он получается применением комбинатора at-key к исходному updater'у. Композируемо!

Вот и сам комбинатор:

(defn at-key [k app]
  (fn [f] (app (fn [m]
               (update-in m [k] f)))))

Выглядит страшновато, но работает отлично ;-) Причём применять его можно много раз:

(at-key :a (at-key :b upd))

Машинерия рендеринга теперь будет выглядеть так:

(defn render [data upd]
  (q/render (Root data upd)
            (.getElementById js/document "main-area")))

(add-watch world ::render
           (fn [_ atm _ data]
             (render data (partial swap! atm))))

Несложно, ведь? Теперь компоненты пригодны для повторного использования и не зависят от реализации и структуры состояния. Например, можно на каком-то этапе обернуть updater так, чтобы изменения состояния валидировались, разрешались/запрещались, логировались и т.д.

Напоследок расшифрую название поста: updater здесь похож на недо-"линзу" из Haskell, отсюда "оптика", (реактивная=React). Замечу, что в отличии от настоящих линз это "поделие" модифицирует некое состояние in place, а также не предоставляет механизм чтения - только изменения.

Комментарии

Comments powered by Disqus
Contents © 2015 astynax - Powered by Nikola Creative Commons License BY-NC-SA
Share