Code-Splitting ClojureScript

Code Splitting has been around for a while but I feel it is somewhat underused in most ClojureScript projects and there aren’t many examples showing how you actually use it. Once your project reaches a certain size you should definitely investigate splitting your code into multiple :modules (often called “chunks” elsewhere) and delay loading code until it is actually required.

I’ll show an example using :modules in a shadow-cljs project. This uses a few new things in shadow-cljs so setup previously was a bit more complicated but the basic strategy has worked for 5+ years. It just got a nicer API, that is currently only available in shadow-cljs.

If you’d rather look at some code than read a wall of text: All the code used in this example is available here. You can find the compiled demo app here.

Why?

JavaScript is expensive. I won’t go into too much detail here since there is a much better article that covers that topic. The gist is that you should keep your JavaScript as small as possible since the initial parse/compile phase is quite expensive and can make your “app” start rather slow. It is quite easy to reach a megabyte or more of .js code in bigger projects. After gzip that may not look that bad but the engine still has to process the unzipped code. On slower devices that can take quite a long time.

How?

Typically you’ll end up with one .js file once ClojureScript compilation and optimizations completes. That file will contain all the code your app needs and code that wasn’t used was already removed by the Closure Compiler. It is optimized and minimized to the best extent possible but not all code will be required initially. You may have different sections/pages in your app that the user may not actually visit. There may be a dialog that is rarely used but requires a lof of code since it contains a complex form or so. There are very many things that will be part of your app but used less frequently. You want to delay loading this until you actually need it.

Enter :modules. Unfortunately “module” is such an overused term so whenever you read :modules think .js files. Instead of creating just one .js file we split it into 2 or more .js files which “group” certain functionality so we can load whatever we need on demand and keep the initial payload small. How you organize your :modules is highly dependent on your application and there is no one-size fits all solution. You should spend some time tweaking this setup for your particular use-case. I’ll demonstrate how to do this using shadow-cljs.

Example App

The example will be using reagent since it is very minimal. I’ll also be using the new React.lazy helpers since Components are a very useful abstraction for code-split points already. You don’t have to use either, the concepts apply to pretty much all browser-targeted apps.

Imagine a shop webapp where a user may sign up to create a new account or sign into an existing account. There should probably be some kind of account overview showing past purchases and so on. You’ll also want a product listing of sorts and probably product detail pages.

The example will only show the bare minimum focusing on the code-splitting aspects. If you want to learn how to build actual applications using reagent I recommend learnreagent.com and learnreframe.com. Disclaimer: I’ll get a commission if you pay for the courses.

So lets get into it. We’ll have a main demo.app namespace that will serve as the initial entry point and will always be loaded first. I’ll break it down below but I think its helpful to have the full picture first.

(ns demo.app
  (:require
    ["react" :as react]
    

[reagent.core :as r]

[demo.env :as env]

[demo.util :refer (lazy-component)])) (defonce root-el (js/document.getElementById “root”)) (def product-detail (lazy-component demo.components.product-detail/root)) (def product-listing (lazy-component demo.components.product-listing/root)) (def sign-in (lazy-component demo.components.sign-in/root)) (def sign-up (lazy-component demo.components.sign-up/root)) (def account-overview (lazy-component demo.components.account-overview/root)) (defn welcome [] [:h1 “Welcome to my Shop!”]) (defn nav [] (let [{:keys [signed-in] :as state} @env/app-state] [:ul [:li [:a {:href “#” :on-click #(swap! env/app-state assoc :page :welcome)} “Home”]] [:li [:a {:href “#” :on-click #(swap! env/app-state assoc :page :product-listing)} “Product Listing”]] (if signed-in [:li [:a {:href “#” :on-click #(swap! env/app-state assoc :page :account-overview)} “My Account”]] [:<> [:li [:a {:href “#” :on-click #(swap! env/app-state assoc :page :sign-in)} “Sign In”]] [:li [:a {:href “#” :on-click #(swap! env/app-state assoc :page :sign-up)} “Sign Up”]] ])])) (defn root [] (let [{:keys [page] :as state} @env/app-state] [:div [:h1 “Shop Example”]

[nav {}]

[:> react/Suspense {:fallback (r/as-element [:div “Loading …”])} (case page :product-listing [:> product-listing] :product-detail [:> product-detail {}] :sign-in [:> sign-in {}] :sign-up [:> sign-up {}] :account-overview [:> account-overview {}] :welcome

[welcome {}]

[:div “Unknown page?”] )]])) (defn ^:dev/after-load start [] (r/render [root] root-el)) (defn init [] (start))

Example Component

(ns demo.components.sign-in
  (:require [demo.env :as env]))

(defn root []
  [:div
   [:h1 "Sign In"]
   [:p "imagine a form ..."]
   [:button {:on-click #(swap! env/app-state assoc :signed-in true :page :account-overview)} "Sign me in already!"]])

On startup the init function will be called, since we don’t need to initialize anything in this example we’ll just proceed with start which renders the reagent root component. The root just renders components based on a the :page setting in the demo.env/app-state atom. Depending on the setting it will render some of the “lazy components” we setup. Usually you would just add a (:require [demo.components.sign-in ...]) in your ns definition and use it directly. Given how ClojureScript works that would always load the required namespace before loading demo.app.

What the lazy-component utility allows is referencing something that will be declared later. We don’t actually care how it is declared just know that it will be at some point. shadow-cljs will fill in the required information on how to load the actual component via the shadow.lazy utility which we wrapped to remove some of the boilerplate code.

Using the React.lazy utility this will only actually start loading the associated code when the component is first rendered and then automatically re-render once the code finishes loading. The react/Suspense wrapper will show the :fallback while any code is loading. You can do several other things here, it really depends on your application. The important thing to remember is that we referenced something that may not be loaded yet and must be loaded asynchronously before it can be rendered.

Since demo.app does not directly require the components we can now define our actual splits using :modules in the build config.

Example Config

How you structure your :modules is completely up to you and your application at this point. I’ll show the most verbose thing first and then discuss an alternative strategy later on. Don’t fear this, the config is pretty simple it just looks a bit long. I wish it was shorter but it is this way for some important reasons which I’ll maybe explain in a later post.

{...
 :builds
 {:app
  {:target :browser
   ...
   :module-loader true
   :modules
   {:main
    {:init-fn demo.app/init}

    :account-overview
    {:entries [demo.components.account-overview]
     :depends-on #{:main}}

    :product-detail
    {:entries [demo.components.product-detail]
     :depends-on #{:main}}

    :product-listing
    {:entries [demo.components.product-listing]
     :depends-on #{:main}}

    :sign-in
    {:entries [demo.components.sign-in]
     :depends-on #{:main}}

    :sign-up
    {:entries [demo.components.sign-up]
     :depends-on #{:main}}}
   }}}

As explained above we will start with the :main module (becoming the main.js output) calling (demo.app/init) on load, it is the only module that can be loaded directly without loading any other. Then we define one module for each component and they all depend on the :main module since that will provide the common code such as reagent, react and of course cljs.core. :module-loader true just tells shadow-cljs that it needs to do a couple extra steps to allow loading the code dynamically.

After running shadow-cljs release app we end up with a bunch of .js files in our default :output-dir "public/js". This also works using watch and compile in development mode but since code-splitting is mostly relevant for release builds I’ll focus on that.

public
└── js
    ├── account-overview.js
    ├── main.js
    ├── product-detail.js
    ├── product-listing.js
    ├── sign-in.js
    └── sign-up.js

In the primary index.html file we just load the js/main.js file and let the code deal with loading the other files when needed. We won’t actually reference them directly ourselves although that would be perfectly fine.

<!doctype html>

<html>
<head>
    <link rel="preload" as="script" href="js/main.js">
    <title>CLJS code-splitting example</title>
</head>
<body>
<div id="root"></div>
<script async src="js/main.js"></script>
</body>
</html>

At this point it would probably be useful to look at a Build Report and inspect the output a little. This is optional and you can leave this as is and it will probably work.

However you know more about your project than shadow-cljs could ever know. You know what the most common paths users take are. Some modules may be tiny in which case it might make sense to combine them in some way. Things commonly used together should probably be grouped in the output. Since loading code requires an async step and may take some time depending the user’s network/computer it may be better to wait a bit longer on startup instead of showing a “Loading …” later on. Once React Concurrent Mode becomes available this will actually matter less but for now it is relevant.

In the example the output files are actually tiny and it doesn’t make sense to split them at all but in a real app the files would be bigger and may contain npm components that are only used in one module but not the others and so on. It wouldn’t make sense to always wait to the code to download.

So given the knowledge we have about our app it is probably save to assume that our users are always going to visit the product-listing page in combination with some visits to the product-detail. So instead of having an extra interruption while waiting for the product-detail to load we can just bundle it together with the product-listing. We can do the same for the user related stuff.

{...
 :builds
 {:app
  {:target :browser
   ...
   :module-loader true
   :modules
   {:main
    {:init-fn demo.app/init}

    :users
    {:entries [demo.components.account-overview
               demo.components.sign-in
               demo.components.sign-up]
     :depends-on #{:main}}

    :products
    {:entries [demo.components.product-detail
               demo.components.product-listing]
     :depends-on #{:main}}
   }}}

We don’t need to change anything in the code since it is already setup and shadow-cljs deals with the rest. It might make sense to just keep everything in the :main module and not split at all, always test for your project.

Conclusion

Code-Splitting is well worth the effort. It does not involve a whole lot of code and can potentially make your app load a lot faster which your users will appreciate. Don’t be intimidated by the initial extra setup and let shadow-cljs help you with the rest.

Don’t blindly split everything as it may actually make things slower. Just generate a build report, tweak the config, re-compile and repeat until you reach your ideal setup. Always remember the re-evaluate as your code may evolve over time and different splits may become more relevant.

Note that these :modules can be nested several levels deep, it doesn’t have to stay “flat” like our example. Aim to keep your :modules below a certain size but remember that it is faster to load one 100KB file instead of one 25KB file that immediately loads another 50 KB and then another 25 KB file before it is able to render anything. Looking at 3 “Loading …” spinners is no fun. Always test on slow network/device configuration. The Chrome Devtools make this incredible easy. Do not assume that everyone is on super high end networks/hardware.

Hopefully this example wasn’t too complex to follow. You can find the in the Clojurians #shadow-cljs channel if you have questions. If this was useful and you want to see more articles like this consider joining my Patreon.

PS: webpack follows a different implementation and only splits code according to the code. Figuring out how your output chunks are organized or optimizing them can be rather difficult. While the initial config may be simpler the output is harder to optimize. The Closure Compiler settled on a static configuration which we adopted. Both systems work and have different trade offs.

Add a Comment