JS Dependencies: In Practice

In my previous posts about JS Dependencies (The Problem, Going forward) I explained why and how shadow-cljs handles JS Dependencies very differently than ClojureScript does by default. To recap:

  • CLJSJS/:foreign-libs do not scale
  • Custom bundles are tedious to work with
  • Closure Compiler can’t yet reliably process a large portion of npm packages
  • shadow-cljs implements a custom JS bundler but removed :foreign-libs support in the process

Installing JS Dependencies

Almost every package on npm will explain how to install it. Those instructions now apply to shadow-cljs as well. So if a library tells you to run:

npm install the-thing

You do exactly that. Nothing more required. You may use yarn if preferred of course. Dependencies will be added to the package.json file and this will be used to manage them. If you don’t have a package.json yet run npm init.

You can use this Quick-Start template to try everything described here.

Using JS Dependencies

Most npm packages will also include some instructions on how to use the actual code. The “old” CommonJS style just has require calls which translates directly.

var react = require("react");
(ns my.app
  (:require ["react" :as react]))

Whatever "string" parameter is used when calling require we transfer to the ns :require as-is. The :as alias is up to you. Once we have that we can use the code like any other CLJS namespace.

(react/createElement "div" nil "hello world")

This is different than what :foreign-libs/CLJSJS did before where you included the thing in the ns but then used js/Thing (or whatever global it exported) to use the code. Always use the ns form and whatever :as alias you provided. You may also use :refer and :rename if you wish.

Some packages just export a single function which you can call directly by using (:require ["thing" :as thing]) and then (thing).

More recently some packages started using ES6 import statements in their examples. Those also translate pretty much 1:1 with one slight difference related to default exports. Translating this list of examples

import defaultExport from "module-name";
import * as name from "module-name";
import { export } from "module-name";
import { export as alias } from "module-name";
import { export1 , export2 } from "module-name";
import { export1 , export2 as alias2 , [...] } from "module-name";
import defaultExport, { export [ , [...] ] } from "module-name";
import defaultExport, * as name from "module-name";
import "module-name";

becomes (all inside ns of course)

(:require ["module-name" :default defaultExport])
(:require ["module-name" :as name])
(:require ["module-name" :refer (export)])
(:require ["module-name" :rename {export alias}])
(:require ["module-name" :refer (export1 export2)])
(:require ["module-name" :refer (export1) :rename {export2 alias2}])
(:require ["module-name" :refer (export) :default defaultExport])
(:require ["module-name" :as name :default defaultExport])
(:require ["module-name"])

The :default option is currently only available in shadow-cljs, you can vote here to hopefully make it standard. You can always use :as alias and then call alias/default if you prefer to stay compatible with standard CLJS in the meantime. IMHO that just gets a bit tedious for some packages.

New Possibilities

Previously we were using bundled code, which may include code we don’t actually need. Some packages also describe ways that you can include only parts of the package leading to much less code included in your final build.

react-virtualized has one those examples:

// You can import any component you want as a named export from 'react-virtualized', eg
import { Column, Table } from 'react-virtualized'

// But if you only use a few react-virtualized components,
// And you're concerned about increasing your application's bundle size,
// You can directly import only the components you need, like so:
import AutoSizer from 'react-virtualized/dist/commonjs/AutoSizer'
import List from 'react-virtualized/dist/commonjs/List'

This we can also translate easily

;; all
(:require ["react-virtualized" :refer (Column Table)])
;; one by one
(:require ["react-virtualized/dist/commonjs/AutoSizer" :default virtual-auto-sizer])
(:require ["react-virtualized/dist/commonjs/List" :default virtual-list])

Resolving JS Dependencies

By default shadow-cljs will resolve all (:require ["thing" :as x]) requires following the npm convention. This means it will look at <project>/node_modules/thing/... and follow the code along there. To customize how this works shadow-cljs exposes a :resolve config option that lets you override how things are resolved.

Using a CDN

Say you already have React included in your page via a CDN. You could just start using js/React again but we stopped doing that for a good reason. Instead you continue to use (:require ["react" :as react]) but configure how "react" resolves like this in your shadow-cljs.edn config for your build

{:builds
 {:app
  {:target :browser
   ...
   :js-options
   {:resolve {"react" {:target :global
                       :global "React"}}}}

  :server
  {:target :node-script
   ...}}}

The :app build will now use the global React instance while the :server build continues using the "react" npm package. No need to fiddle with the code to make this work.

Redirecting “require”

Some packages provide multiple “dist” files and sometimes the default one described doesn’t quite work in shadow-cljs. One good example for this is "d3". Their default "main" points to "build/d3.node.js" but that is not what we want when working with the browser. Their ES6 code runs into a bug in the Closure Compiler, so we can’t use that. Instead we just redirect the require to some other require.

{:resolve {"d3" {:target :npm
                 :require "d3/build/d3.js"}}}

You could just (:require ["d3/build/d3.js" :as d3]) directly as well if you only care about the Browser.

Using local Files

You may also use :resolve to directly map to files in your project.

{:resolve {"my-thing" {:target :file
                       :file "path/to/file.js"}}}

The :file is always relative to the project directory. The included file may use require or import/export and those will be followed and included properly as well.

Note that this method should only be used when you are trying to replace actual npm packages. To include local JS files you wrote you should be using the newer method.

Migrating cljsjs.*

Many CLJS libraries are still using CLJSJS packages and they would break with shadow-cljs since that no longer supports :foreign-libs. I have a clear migration path for this and it just requires one shim file that maps the cljsjs.thing backs to its original npm package and exposes the expected global variable.

For react this requires a file like src/cljsjs/react.cljs:

(ns cljsjs.react
  (:require ["react" :as react]
            ["create-react-class" :as crc]))

(js/goog.object.set react "createClass" crc)
(js/goog.exportSymbol "React" react)

Since this would be tedious for everyone to do manually I created the shadow-cljsjs library which provides just that. It does not include every package but I’ll keep adding them and contributions are very welcome as well.

It only provides the shim files though, you’ll still need to npm install the actual packages yourself.

What to do when things don’t work?

Since the JS world is still evolving rapidly and not everyone is using the same way to write and distribute code there are some things shadow-cljs cannot work around automatically and either requires custom :resolve configs. There may also be bugs, this is all very new after all.

Please report any packages that don’t work as expected. #shadow-cljs is also a good place to find me.

Discuss on :clojureverse.

Add a Comment