December 8, 2013

DOM Manipulation and Event Handling in ClojureScript

This post was provoked by some back and forth that was had over Twitter at the end of October. David Nolen characteristically rose to the challenge and responded with some excellent, useful posts?if you are just getting into ClojureScript, I highly recommend checking out his articles, especially The Essence of ClojureScript (I used his mies template, introduced in that article, to build these examples). Mimmo Cosenza's Modern ClojureScript tutorial series is also quite a good place to start.

A Breadth-First Introduction

This post aims to give those JavaScript developers, familiar with standard JavaScript libraries' approaches to DOM manipulation and event handling, the "lay of the ClojureScript land." This is not intended to assert that any one of these are superior to the others?although I certainly recommend you avoid simply wrapping browser-default API calls?but merely to help you make the decision as to which one may be best suited to your needs.

Toward the goal of introducing DOM manipulation approaches in ClojureScript, I've tried to write the same (extremely contrived) example?adding an html link inside a list element and attaching an event listener?using six different libraries.

Hopefully by the end you will be convinced that there is a wide variety of robust, easy-to-use DOM-manipulation and event-handling libraries in ClojureScript available, right now, for you to use. And hopefully you may feel a bit more comfortable with ClojureScript as well if you haven't had a chance to investigate it yet in depth.

(If you think I've forgotten any widely used libraries, please let me know!)

A Caveat

I want to emphasize one more thing?the approach to event-handling taken by David Nolen (using core.async) in ClojureScript 101 should be favored over simply chaining together callbacks. It allows for a far more lucid and extendable design.

I will follow up this post with one expanding on the core.async approach at a later date. However, the examples below are meant to be easily recognizable by anyone with experience using similar approaches with JavaScript, which is why I am not including core.async at this point.

"Raw" JavaScript
(a.k.a. built-in Browser APIs)

Of course, you can directly use the APIs that are available within every browser. The basic problem with this is, of course, that it requires you to handle browser idiosyncracies yourself, and you will probably end up re-writing a lot of helper functions provided by other libraries yourself. But, you may occasionally need to use built-in APIs when your DOM library falls short, so it's worth familiarizing yourself with what is available.

(ns cljs-dom-survey.core
  (:require
   [clojure.string :refer [capitalize]]))

(defn add-annoying-alert-listener_rawjs!
  [a]
  (.addEventListener ; you have to support IE8 and lower? Bummer.
   a "click"
   (fn [evt] 
     (let [atxt (-> evt (.-currentTarget) (.-innerHTML))
           msg  (str "You clicked " atxt)]
       (.alert js/window msg)
       (.preventDefault evt)))))

(defn add-menu-link_rawjs!
  [link]
  ;; XPath evaluate is not supported on IE...
  (let [uls      (.evaluate js/document
                            "//div[@id='menu']/ul"
                            js/document nil "XPathResult.ANY_TYPE" nil)
        ul       (.iterateNext uls)
        new-li   (.createElement js/document "li")
        a        (.createElement js/document "a")]
    (set! (.-href a) (last link))
    (set! (.-innerHTML a)  (-> link first name capitalize))
    (.appendChild new-li a)
    (.appendChild ul new-li)
    (add-annoying-alert-listener_rawjs! a)))

(add-menu-link_rawjs! [:link4 "#link4"])

Google Closure

Using Google Closure's JavaScript library via CLJS-interop is the approach taken by David Nolen in his ClojureScript 101 tutorial, and is an excellent place to start. The Google Closure team has provided a comprehensive JavaScript development tool-kit and wrapped up the manifold browser quirks which plague front-end web development so that you can focus on the task at hand. The Google Closure libs also have the benefit, of course, of being immediately available to you in ClojureScript, and work flawlessly (of course) under advanced compilation mode.

(ns cljs-dom-survey.core
  (:import [goog.dom query]) ; super handy, not available by default in goog.dom
  (:require
   [clojure.string :refer [capitalize]]
   [goog.dom :as gdom]
   [goog.events :as gevents]))

(defn add-annoying-alert-listener_goog!
  [a]
  (gevents/listen
   a goog.events.EventType.CLICK
   (fn [evt]
     (let [atxt (-> evt .-currentTarget gdom/getTextContent)
           msg  (str "You clicked " atxt)]
       (.alert js/window msg)
       (.preventDefault evt)))))

(defn add-menu-link_goog!
  [link]
  (let [ul (aget (query "#menu ul") 0)
        li (gdom/createElement "li")
        a  (gdom/createElement "a")]
    (set! (.-href a) (last link))
    (gdom/setTextContent a (-> link first name capitalize))
    (gdom/appendChild li a)
    (gdom/appendChild ul li)
    (add-annoying-alert-listener_goog! a)))

(add-menu-link_goog! [:link4 "#link4"])

Of course, you will still need to spend a bit of time reviewing the approach that the Google Closure developers took?the event handling tutorial in particular is useful.

If you feel ready to jump in right away, the Google Closure API reference documentation is also quite high-quality. You probably want to start with the goog.dom, goog.style and goog.event namespace references.

Within the Google Closure library, there are also a large variety of user-interface widgets and utility libraries. However some of these will require you to invest more time in learning the idiosyncracies of how Google Closure works as a comprehensive package. That said, this is probably the first place you should look if you want a robust, well-written and well-supported set of user-interface libraries which "just work" inside of ClojureScript.

Domina

Domina represents the next step up from Google Closure; it wraps the most commonly used Google Closure libraries (event-handling, DOM/CSS manipulation) in a consistent ClojureScript interface. This makes it easy to use in conjunction with other Google Closure libraries as described above. In fact, you could simply consider this the ClojureScript wrapper for Google Closure. However, it's important to keep in mind that it only provides a subset of what is available in Google Closure, and introduces its own abstractions (for example, DomContent). But as such, it may useful to you if you need to leverage Google Closure in your application but want a ClojureScript interface for most common DOM-manipulation tasks.

(ns cljs-dom-survey.core
  (:require
   [clojure.string :refer [capitalize]]
   [domina :as dom]
   [domina.css :as css]
   [domina.events :as events]))

(defn add-annoying-alert-listener_domina!
  [a]
  (events/listen!
   (css/sel "a") :click
   (fn [evt]
     (let [atxt (-> evt events/current-target dom/text)
           msg  (str "You clicked " atxt)]
       (.alert js/window msg)
       (events/prevent-default evt)))))

(defn add-menu-link_domina!
  [link]
  (let [ul (css/sel "#menu ul")
        li (dom/html-to-dom "<li></li>")
        a  (dom/html-to-dom "<a></a>")]
    (doto a
      (dom/set-text! (-> link first name capitalize))
      (dom/set-attr! :href (last link)))
    (dom/append! li a)
    (dom/append! ul li)
    (add-annoying-alert-listener_domina! a)))

(add-menu-link_domina! [:link4 "#link4"])

That said, Domina has a few quirks and inconsistencies, and development seems to be off-and-on. For the record, this is the library we use in our day-to-day development at DiligenceEngine. We've found it to be quite sufficient, especially in conjunction with Google Closure.

Dommy

Dommy is a pure ClojureScript DOM-manipulation library. The documentation is a bit sparse, unfortunately. For example, while the README does not go into any depth about it, it does provide cross-browser event-handling functionality. So, you'll be well served by digging into the codebase on this one (something you should get used to in any case).

(ns cljs-dom-survey.core
  (:require
   [clojure.string :refer [capitalize]]
   [dommy.core :as dommy])
  (:use-macros
   [dommy.macros :only [node sel sel1]]))

(defn add-annoying-alert-listener_dommy!
  [a]
  (dommy/listen!
   a :click
   (fn [evt]
     (let [atxt (-> evt (.-currentTarget) dommy/text)
           msg  (str "You clicked " atxt)]
       (.alert js/window msg)
       (.preventDefault evt)))))

(defn add-menu-link_dommy!
  [link]
  (let [ul       (sel1 [:#menu :ul])
        link-txt (-> link first name capitalize)
        li       (node [:li [:a {:href (last link)} link-txt]])]
    (dommy/append! ul li)
    (add-annoying-alert-listener_dommy! (sel1 li :a))))

(add-menu-link_dommy! [:link4 "#link4"])

One thing you'll notice right away is how much more "Clojure-esque" this library is, and how much more concise the code example above has become. If you don't need the variety of features that Google Closure provides (not that it is ever hard to include in ClojureScript), and you want something easy to get started with but at the same time idiomatic, then this library would fit the bill.

Dommy also provides some pretty fast templating. More on that at a later date.

Good ol' jQuery: jayq

jayq is an attempt to provide an idiomatic-to-ClojureScript wrapper for jQuery. I'll let Chris Granger explain it. This is the library I've used the least, but it supposedly works even with advanced compilation (but isn't itself compiled in advanced mode, to be clear). It provides some slick AJAX functionality on top of the default jQuery we all know, like the let-ajax macro.

(ns cljs-dom-survey.core
  (:require
   [clojure.string :refer [capitalize]]
   [jayq.core :as jayq :refer [$]]))

(defn add-annoying-alert-listener_jayq!
  [a]
  (jayq/on a :click
      (fn [evt]
        (let [atxt (-> evt (.-currentTarget) $ jayq/text)
              msg  (str "You clicked " atxt)]
          (.alert js/window msg)
          (.preventDefault evt)))))

(defn add-menu-link_jayq!
  [link]
  (let [$ul      ($ "#menu ul")
        link-txt (-> link first name capitalize)
        li-str   (str "<li><a href=" (last link) ">" link-txt "</a></li>")]
    (jayq/append $ul li-str)
    (add-annoying-alert-listener_jayq! (jayq/find $ul :a))))

(add-menu-link_jayq! [:link4 "#link4"])

I could imagine this library being useful if you have a pre-existing codebase which already uses jQuery extensively. However, I would avoid it if possible since it means including another unnecessary JS library, and I find the prevalent usage of the jQuery "$" to be non-idiomatic and distracting (please note that Chris Granger offers counterpoints to both of these arguments).

Perhaps more to the point?the other ClojureScript-based options are already so good, and Google Closure is already readily available "out of the box," so jQuery is simply unnecessary in a ClojureScript app.

Enfocus

On the other end of the spectrum, Enfocus represents a distinct approach to DOM manipulation and is conceptually different from the other libraries introduced above. It is well suited for client-side templating. Its syntax and conceptual basis is largely taken from Enlive, a server-side HTML templating and manipulation library.

(ns cljs-dom-survey.core
  (:require
   [clojure.string :refer [capitalize]]
   [enfocus.core :as ef]
   [enfocus.events :as ef-events])
  (:require-macros [enfocus.macros :as em]))

(em/defaction add-annoying-alert-listener_enfocus!
  [href]
  [(str "a[href=" href "]")]
  (ef-events/listen
   :click
   (fn [evt]
     (let [atxt (-> evt (.-currentTarget) (.-text))
           msg  (str "You clicked " atxt)]
       (.alert js/window msg)
       (.preventDefault evt)))))

(defn add-menu-link_enfocus!
  [link]
  (let [link-str (-> link first name capitalize)
        href     (last link)
        li       (ef/html [:li [:a {:href href} link-str]])]
    (ef/at ["#menu ul"] (ef/append li))
    (add-annoying-alert-listener_enfocus! href)))

(add-menu-link_enfocus! [:link4 "#link4"])

Similarly to Dommy, this code is more "Clojure-esque" and more concise than the other approaches taken above. And while advanced usage of Enfocus (and Enlive) can feel like a bit of a mind-bender if you are used to standard approaches, it is worth investigating this library for a more functional take on DOM manipulation and event handling.


EDIT: Creighton Kirkendall, author of Enfocus, responded to this post via the ClojureScript mailing list with this:

Enfocus is generally about chaining transforms and your add listener could be seen as a custom transform. With this in mind, I might recommend something closer to this.

(defn add-annoying-alert-listener_enfocus! []
  (ef-events/listen
     :click
     (fn [evt]
       (let [atxt (-> evt (.-currentTarget) (.-text))
             msg  (str "You clicked " atxt)]
         (.alert js/window msg)
         (.preventDefault evt)))))

(defn add-menu-link_enfocus!
  [link]
  (let [link-str (-> link first name capitalize)
        href     (last link)
        li       (ef/html [:li [:a {:href href} link-str]])]
    (ef/at 
      "#menu ul" (ef/append li)
      (str "a[href=" href "]") (add-annoying-alert-listener_enfocus!))))

(I have added this code to the github project as well, with the older version remaining above it.)

To Sum Up

  • "Raw" browser API calls if you don't care about browser-compatibility or love re-inventing the wheel.
  • Google Closure if you want something comprehensive, well-documented, but non-Clojure-idiomatic.
  • Domina if you want to wrap some of what Google Closure provides in a more Clojure-idiomatic syntax.
  • Dommy for a "from-the-ground-up," fast, more Clojure-esque library.
  • jayq if you already have jQuery included or if you just can't bear to be without your $.
  • Enfocus you are comfortable with Enlive already, or just prefer manipulating the DOM through the functional style it enables.

All of these examples are available, working, in this github repository.

Any feedback, corrections or other questions are most welcome. Thanks for reading!


Thanks to Alex Hudek, Chris Allen, and Joel Holdbrooks for help with earlier drafts. Thanks to Luke Morton, Walter van der Laan and Creighton Kirkendall for catching some errors and for helpful comments.