shadow-cljs – Introduction

shadow-cljs is my attempt at improving ClojureScript tooling. I started it a couple years ago (then called shadow-build) since I wanted to do things other tools were not able to do (yet, maybe still can’t).

It does use the default ClojureScript compiler and analyzer but not much beyond that. Everything related to “bundling” is rewritten completely. shadow-cljs works quite differently compared to other CLJS tools when it comes to deciding which code gets compiled, optimized and then bundled together. The focus has always been on ensuring that the final “release/production” version that gets shipped to your user is as optimized as possible. Code-splitting (aka. :modules) was the initial motivation to start this tool 3+ years ago but it has grown far beyond that.

Development is important as well so the usual live-reload workflow we all love works out of the box. The CLJS REPL is usually injected automatically so there is no need to configure it most of the time.

Configuration

A couple months ago I started working on a complete rework of the configuration code since I was unhappy with my own build configs. They had a lot of repetition and often were exact copies of each other with one parameter changed (eg. :none -> :advanced).

Frequently I wasn’t able to express exactly what I wanted in my configuration which resulted in trying to hack something together in code to emulate the final result. Everyone that ever tried to work with module.exports for node code might be able to relate.

So from that the actual shadow-cljs project was born which threw away pretty much all of the configuration code and started from scratch. If you have used other CLJS tools before you might need to forget some stuff you have learned since they might not apply anymore.

:target and :mode

The basic configuration concept starts with :target which defines how your code is bundle together based on the platform you plan on running the code in (eg. the Browser or node). CLJS has a :target option as well but was not specific enough for my goals.

:mode is either :dev during development or :release for production builds. This means that you have ONE configuration that is used for both modes, if the configuration has development-only parts they will not apply when building a release and vice versa. There is no need to copy the configuration, the tool is smart enough to do the correct thing most of the time. Most of the time you’d probably just change the :optimization setting anyways, at least I did.

A whole lot of configuration is based on some reasonable defaults so the configuration you actually need to do is reduced to a minimum. You can still change the defaults if you need to though.

:browser – building for the the Browser

The :browser target is the most complex and deserves its own book probably. Shipping code for the browser has its own set of challenges since you usually want to optimize for size and speed. Depending on the amount of code you want to ship it might be important to split code via :modules to reduce (or delay) the amount of code the user has to download. Caching can also improve performance so the :browser target has some hooks to help with that. Using JS dependencies is also especially complicated because you can’t just load them via require at runtime. Everything must be carefully bundled together and the “best” option is usually very specific to each individual deployment scenario.

:npm-module – building for JS tools or node.js

The goal here is to make it easy to consume ClojureScript from JS. Directly in node.js or by other tools such as webpack and higher level “frameworks” like create-react-app, create-react-native-app, etc.

Since ClojureScript uses Google Closure to do most of the :advanced stuff the code generated by the compiler targets the Closure coding conventions. These are very strict and quite different from what node.js or the other tools expect and understand.

:npm-module enhances the code so non-closure tools can understand and consume it directly without sacrificing what Closure gives us. The result is that you can use require directly to consume the compiled (and even optimized) CLJS code.

A CLJS namespace like this

(ns demo.foo)

(defn hello [who]
  (str "Hello, " who "!"))

Can be directly consumed in node via:

> var x = require("shadow-cljs/demo.foo");
undefined
> x.hello("JS")
'Hello, JS!'

You can use :npm-module to build your own npm packages as well.

:node-script and :node-library

These are slightly more focused versions of :npm-module and basically just optimize the last few bits. :node-script creates a .js file that you can run directly via node. :node-library allows you to compact multiple namespaces down to one map of exports.

Further reading

This post only introduced a few concepts of shadow-cljs with a bit of background of why it exists and why it is different. Please refer to the README on how to use it.

I will go more in-depth about certain aspects of the tool in future posts, for now I just wanted a post I can refer to which answers the “Why/What”.

Add a Comment