Reducing the build size when using lots of JS dependencies


#1

I’m experimenting with a new option in shadow-cljs which can reduce the size of builds that require a lot of JS dependencies. By default I chose to use stable names, meaning that the actual name of the included file will be used as its alias in the code.

Say you include

(:require ["react" ...])

in your code. This will be resolved following the npm rules and ends up including

;; in dev mode
node_modules/react/cjs/react_development.js
;; in release mode
node_modules/react/cjs/react_production.min.js

in your build with the assigned alias

shadow$provide.module$node_modules$react$cjs$react_development = function(...) { ... };

this is quite verbose but very cache friendly since the assigned alias never changes. This only becomes a problem if you include JS libs that themselves are split into hundreds/thousands of files where this verbosity adds up quite quickly. While gzip compression takes care of this quite well it still ends up with about a 5-10% overhead when compared with that for example webpack would generate.

webpack just assigns numeric ids to each source which is obviously much shorter but doesn’t cache very well since the numbers can change when dependencies are added or removed or just happend to be re-ordered when refactoring other code. Whenever the numeric ids change the cache needs to be invalidated and the output will obviously have a different hash as well.

As of [email protected] it is possible to also use numeric ids in shadow-cljs. This is not enabled by default yet but probably will become the default for release builds in the future. It doesn’t actually change any functionality it just potentially makes the build a bit smaller.

You can enable this currently in your build config via

{...
 :your-build
 {:target :browser
  :release {:js-options {:minimize-require true}}}

You probably don’t want to enable it in :dev mode since that will likely lead to lots more cache invalidations which might make development slower. Note that this is only relevant for :browser builds.

I did some tests with a dummy test build that just included semantic-ui-react which ends up including 600+ JS files. I also experimented with some different alias mechanisms where I used a hash (or shortened hash) which is stable and generally shorter than the original filename but much worse with gzip compression.

;; alias mode - JS size        - after gzip
;; full hash    [JS: 825.62 KB] [GZIP: 205.61 KB] (md5 of the filename in hex)
;; full path    [JS: 891.56 KB] [GZIP: 186.04 KB] (munged filename)
;; short hash 8 [JS: 745.50 KB] [GZIP: 184.36 KB] (md5, first 8 chars)
;; short hash 6 [JS: 738.83 KB] [GZIP: 182.84 KB] (md5, first 6 chars)
;; numeric id   [JS: 719.89 KB] [GZIP: 177.36 KB]

It is pretty impressive that an additional 172KB only adds 9KB gzipped but that is still a noticeable difference in the case of the full filename. The full hash is obviously not an option since gzip really doesn’t like those. Shorter hashes increase the likelihood of conflicts which would need to be detected and invalidated so that doesn’t really seem worth it either. numeric ids work out quite well and actually end up slightly smaller than what webpack generates since we require a little less boilerplate code.

If you don’t use many JS deps or those that aren’t split into many files (eg. react, react-dom) this won’t make much of a difference but some of the larger libs this can have quite an impact. Please try it in your builds and let me know.

Best way to experiment with this would be to run

shadow-cljs run shadow.cljs.build-report your-build before.html
;; then change the config and run
shadow-cljs run shadow.cljs.build-report your-build after.html

Then open the before.html and after.html in your browser and compare the results.


#2
before after difference
2.19mb 2.06mb 130kb / 5.9%
gzip 551kb 542kb 9kb / 1.6%

This project uses a lot of modules from @material-ui/core, and that made most of the difference (340kb vs 250kb).