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.