While ClojureScript is my preferred means of writing JavaScript, for simple cases like the one-off scripts I occasionally use for blog posts, it produces far more code than the few kilobytes I need. Advanced and minified compilation of (js/console.log "Hello World")
may be "only" 1.4 kilobytes, but making any use of Clojure's persistent collections rapidly inflates build size: console logging an empty vector in place of a string produces a weighty 91 kilobytes of minified code. Persistent collections are one of the best parts of Clojure, but for small scripts I can manage without them. One thing I wanted to see if I could keep, however, was the Lisp syntax, including Clojure's basic macros like ->
, doto
, and if-let
. To do that, I created a kind of poor-man's ClojureScript, what I call "Clojure-flavored JavaScript". So far I've only implemented as much as I need, but here are the rudiments.
After reading a form, ClojureScript performs an analysis phase before compiling the final JavaScript, but for my use, macro expansion is sufficient:
(clojure.walk/macroexpand-all
'(when-let [a Math/PI]
(doto js/document
(.appendChild nil))))
(let* [temp__5804__auto__ Math/PI] (if temp__5804__auto__ (do (let* [a temp__5804__auto__] (let* [G__294864 js/document] (. G__294864 appendChild nil) G__294864)))))
To generate JavaScript code, we can walk a macro-expanded form and handle whatever we find, which beside atoms like symbols can also include Clojure's various special forms (such as let*
, fn*
, and do
), whatever clojure.core functions are used in expanded macros (such as first
and seq
, used by destructuring logic), and whatever functions we choose to use (such as str
for string concatenation). Here we'll only implement enough of a compiler to compile the above form, so we'll only deal with a few atoms and special forms.
ClojureScript compilation uses an emits
function to write compiled JavaScript code to *out*
, so we'll follow a similar pattern:
(defn emits [& ss]
(doseq [s ss]
(cond
(seq? s) (apply emits s)
(string? s) (.write *out* s))))
When generating symbols, we need to account for the Math
namespace and special characters that JavaScript doesn't allow in names. Here I'm only handling #
, which is present in symbols created by macros, and -
, which is conventionally used in Lisp to separate words.
(defn emit-symbol [s]
(let [s (if (= "Math" (namespace s))
(str (namespace s) "." (name s))
(name s))]
(emits
(-> (name s)
(clojure.string/replace "#" "_HASH_")
(clojure.string/replace "-" "_")))))
Keywords we'll emit as strings:
(defn emit-keyword [k]
(emits "'" (name k) "'"))
Here's a function to handle the forms we've defined so far:
(defn emit [form]
(cond
(nil? form) (emits "null")
(number? form) (emits (str form))
(string? form) (emits "'" (clojure.string/replace form "'" "\\'") "'")
(symbol? form) (emit-symbol form)
(keyword? form) (emit-keyword form)))
To compile the example above we need to handle special forms and function invocations. Let's create create an emit-invoke
multimethod that dispatches on the first form in a list:
(defmulti emit-invoke first)
By default, we'll just invoke whatever function name is given:
(defmethod emit-invoke :default [[f & args]]
(emits (name f) "(")
(doseq [arg (butlast args)]
(emit arg)
(emits ", "))
(emit (last args))
(emits ")"))
Add emit-invoke
to the original emit
function:
(defn emit [form]
(cond
(nil? form) (emits "null")
(number? form) (emits (str form))
(string? form) (emits "'" (clojure.string/replace form "'" "\\'") "'")
(symbol? form) (emit-symbol form)
(keyword? form) (emit-keyword form)
(seq? form) (emit-invoke form)))
And here's a function to carry out the whole compilation pipeline:
(defn compile-to-javascript [form]
(with-out-str
(emit (clojure.walk/macroexpand-all form))))
Let's compile with what we have to see how close we are:
(compile-to-javascript
'(when-let [a Math/PI]
(doto js/document
(.appendChild nil))))
let*(, if(temp__5804__auto__, do(let*(, let*(, .(G__294918, appendChild, null), G__294918)))))
We need to define some special forms. Here's if
, do
, let*
, and .
:
(defmethod emit-invoke 'if [[_ pred then else]]
(emits "((")
(emit pred)
(emits ") ? ")
(emit then)
(emits " : ")
(emit else)
(emits ")"))
(defmethod emit-invoke 'do [[_ & body]]
(emits "(() => {\n")
(doseq [form (butlast body)]
(emit form)
(emits ";\n"))
(emits "return ")
(emit (last body))
(emits ";\n})()"))
(defmethod emit-invoke 'let* [[_ bindings & body]]
(emits "(() => {\n")
(doseq [[k v] (partition 2 bindings)]
(if (= '_ k)
(do
(emit v)
(emits ";\n"))
(do
(emits "var ")
(emit k)
(emits " = ")
(emit v)
(emits ";\n"))))
(doseq [b (butlast body)]
(emit b)
(emits ";\n"))
(emits "return ")
(emit (last body))
(emits ";\n})()"))
(defmethod emit-invoke '. [[_ target method & args]]
(emit target)
(emits "." (name method) "(")
(when (seq args)
(doseq [arg (butlast args)]
(emit arg)
(emits ", "))
(emit (last args)))
(emits ")"))
Now compiling gives us:
(() => { var temp__5804__auto__ = Math.PI; return ((temp__5804__auto__) ? (() => { return (() => { var a = temp__5804__auto__; return (() => { var G__294987 = document; G__294987.appendChild(null); return G__294987; })(); })(); })() : null); })()
Generated symbol names and unnecessary parentheses and semicolons can be cleaned up by a minifier such as Google's Closure Compiler:
(()=>{if(Math.PI){var a=document;a.appendChild(null)}else a=null;return a})();
While equivalent hand-written JavaScript could easily be smaller, the goal here is merely to be competitive.
To make a fully fledged Clojure-flavored JavaScript, you'll need to implement few more special forms, as well as handle core functions such as str
and +
.
In my brief experience using such a compiler, I have found it slightly dizzying to write Clojure code that only relies on JavaScript functions and mutative idioms, but so far the dizziness has been tolerable for small experiments.