JS dependencies: Going forward
This post is split into 2 parts aiming to answer the question:
How do I use library “xyz”?
The first post defined the Problem and described the solutions people use today, this post will go over how shadow-cljs
will handle the issue going forward.
The Goal
I want to automate using JS dependencies as much as possible while eliminating the issues I described in the first post. It should require as little configuration as possible but still allow the most common scenarios that people commonly use in JS projects (eg. using jQuery
or react
from a CDN). Interop should be seamless.
Using JS dependencies
CLJSJS popularized the use of :foreign-libs
which introduced pseudo-namespaces that had no functionality beyond telling the compiler to include that certain lib in the build. You would (:require [cljsjs.react])
in your code but then access it using js/React
. This is problematic for various reason and would require changing the code when importing the code via Closure.
Going forward the recommended solution is:
- CLJS/Closure you
:require
by using asymbol
(eg.(:require [clojure.string :as str])
) - JS dependencies are required using a
"string"
which exactly matches the string you’d use in JS viarequire
orimport
.
The JS version
import { createElement } as react from "react";
import { render } as rdom from "react-dom";
// or
const react = require("react");
...
then becomes
(ns your.app
(:require [clojure.string :as str]
["react" :as react :refer (createElement)]
["react-dom" :as rdom :refer (render)]))
You could always use (def react (js/require "react"))
in CLJS (which most node.js
users do) but the problem here is that the compiler does not understand this information. It does not collect js/require
calls and therefore is not aware of any JS dependencies it might need to deal with.
By moving it into the ns
form it becomes static metadata (just like ES6 import
) so tools can easily work with this data.
A small rant: Don’t use symbols for JS
Other CLJS contributors do not share my opinion that you should use strings for JS requires. They allow using (:require [react])
as a short-hand which is a magical symbol that only becomes available when the ./node_modules/react
package exists (or a :foreign-lib
provides it).
IMHO it is much cleaner to make it immediately obvious whether you are including a CLJS/Closure namespace by using a symbol
or a JS dependency by using a "string"
. Not only does this make it easier for tools to do their work it also deals with all ambiguities you might encounter when using npm packages and trying to convert their name to a valid symbol (eg. -
vs _
, "@scoped/packages"
, "react-dom/server"
).
Just use a "string"
to include JS.
shadow-cljs
will support symbols for compatibility but really doesn’t like it.
Choosing a Provider
shadow-cljs
introduces a new :js-provider
build config option that controls which mechanism will be used to provide the JS support. There are currently 3 options to choose from:
:require
will map directly to JSrequire
calls, which works natively onnode
or when using other build tools to fill them in for us (ie.webpack
,react-native
packager, etc):shadow
is the default for:browser
builds and will package thenpm
code into a format we can consume in the browser.:closure
will attempt to import all JS dependencies using the Closure Compiler and should only be considered for:browser
builds as well.
:shadow vs :closure
Both options attempt to import code from npm
and rewrite it in a browser friendly way.
:closure
is much more aggressive and will rewrite basically the entire file. It is the better option when it works since it will work reliably with :advanced
optimizations. In theory no :externs
would be required and the produced code will fully benefit from everything the Closure Compiler has to offer (dead-code-removal, code motion, etc). The downside is that it does not yet understand every form of npm
package out there. A lot of work is being done to improve this situation but it might not yet work for some npm
packages you want to use.
:shadow
is the default until that changes. It basically only rewrites require
calls since the browser does not natively understand those. It initially used browserify
to bundle JS code for us but that had significant issues and did not work reliably enough. Going forward shadow-cljs
uses its own JS bundler implementation with the goal of supporting every package on npm
.
:shadow
already has pretty good support for most npm
packages I tested. Since there are so many different ways npm
packages are built there might be some it does not yet support. Please let me know if you run into issues.
Using :closure
is the goal and ideal path but it might take some time to get working properly. Definitely try and see if it works for your project.
Either way there are some issues we need to deal with going forward.
Externs
The downside of :shadow
is that :externs
are required since the JS code will not be run through :advanced
compilation. It will minify the code for release
builds which should still produce acceptable bundle sizes similar to what webpack
and others would achieve but far from :closure
.
Writing :externs
is annoying but shadow-cljs check
might be able to help.
We should be able to create a repository of :externs
so the compiler can automatically apply the matching externs depending on the packages you use. Similar to the way CLJSJS
automated most :externs
.
Some :externs
will still be required even for :closure
since code on npm
does not follow the strict coding style Closure expects. Some more “dynamic” styles are not properly detected when compiling and :externs
sort of work around those cases (eg. react
still needs externs with :closure
).
BREAKING CHANGE: Removing :foreign-libs support
Popular libraries like reagent
just include cljsjs.react
but then use js/React
to call the actual code. That is a problem since the compiler doesn’t (and shouldn’t) know that some foreign JS provided that global. reagent
has a newer alpha version that removed the cljsjs.react
require and now uses react
(as a symbol, not a string. :(
) but pretty much all libraries need to be updated to do this.
Pre-bundled :foreign-libs
(ie. CLJSJS) themselves are a problem due to the “scaling” problem mentioned in the first post. The systems simply do not mix well and frequently conflict with each other. One library might use cljsjs.react
while another uses the newer "react
”. This would lead to two versions of React
being included in your page.
shadow-cljs
will not support :foreign-libs going forward (which includes CLJSJS).
This might make it impossible to build your project with shadow-cljs
without changing some code.
Since most CLJSJS packages should have npm
packages as well you can work around a few issues by manually creating the cljsjs
namespaces. Take react
for example, usually you’d use cljsjs.react
and then the global js/React
. To make that available you can create that namespace and manually add the global.
(ns cljsjs.react
(:require ["react" :as react]))
(js/goog.exportSymbol "React" react)
This should only be done if you are using a library that uses a cljsjs
namespace that has not been updated. Your own code should be updated to use string requires.
I’m happy to provide an interim solution to make the transition easier if someone has a good idea how to do so.
:npm-deps
CLJS core supports the :npm-deps
compiler option to control how things are compiled. In addition it also attempts to manage JS dependencies by trying to install packages for you.
I do not think that :npm-deps
is adequate to cover this topic. shadow-cljs
does not support this now and won’t manage JS dependencies for you. You must manually install packages using npm
or yarn
and manage them using the usual package.json
.
Work to be done
All work was focused on npm
packages and using local .js
files is not as convenient as I’d like it to be. It will be properly supported at some point I just didn’t get around to it yet.
Conclusion
I decided to do an entirely different implementation than what is currently available in CLJS core. I do think that it is a tooling concern anyways and necessary to be able to deal with all the complex build scenarios users might have.
Due to the problems described in the first post I never used many JS dependencies in my projects. Going forward I feel much more confident that I can safely use them and not worry about all the problems (well, except :externs
I guess).
I do think that using JS dependencies is easier and more reliable this way. There are some adjustments you might need to make to your code migrating off :foreign-libs
but I do think it is worth it in the end. You gain access to the whole npm
ecosystem and do not rely on third-party re-packaged CLJSJS packages.
Feedback wanted!
I need testers to make it as reliable as possible. Just yarn add shadow-cljs
and get started.
A simple example can be found here.
Please report a Github issue in case something doesn’t work as expected. You can find me on reddit, Twitter or @thheller in the Clojurians Slack if you have questions.