Skip to content

Instantly share code, notes, and snippets.

@rstacruz
Last active August 30, 2022 01:43
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rstacruz/91e0689d02067f2118985861229728d4 to your computer and use it in GitHub Desktop.
Save rstacruz/91e0689d02067f2118985861229728d4 to your computer and use it in GitHub Desktop.

JavaScript without npm?
A dive into Rails 7's importmap approach

Using JavaScript in Rails has a very long history. With Rails 7, the latest approach has been to move away from the tools from the JavaScript community like npm, yarn, and Webpack. Instead, Rails 7 introduces importmap-rails as a solution that “embraces the platform” and uses native JavaScript modules (also known as “ES Modules” or “ESM”).

What’s Importmap-rails?

Importmap-rails is touted as a gem that “let you import JavaScript modules directly from the browser” 1. The documentation claims that “this frees you from needing Webpack, Yarn, npm, or any other part of the JavaScript toolchain.”

At first, I felt like those claims are a bit ambiguous and a bit of marketing hype. After exploring importmap-rails a bit more, I think I have another way of looking at it. importmap-rails is a replacement for npm for app builders. That is: take away npm’s authoring tools (like npm publish), and you might get a tool that has importmap-rails’s feature set. Have a look at a few things importmap does:

  • importmap pin adds a package (like npm install.)
  • importmap.rb is a file that list your packages (like package.json.)
  • importmap audit checks for security bulletins for the packages in importmap.rb (like npm audit.)
  • impotrmap outdated checks for outdated packages (like npm outdated).

Importmap command

Rails 7 apps provide a bin/importmap command, which is an analogue of what npm or yarn would be used for.

› bin/importmap
Commands:
  importmap audit              # Run a security audit
  importmap help [COMMAND]     # Describe available commands or one specific command
  importmap json               # Show the full importmap in json
  importmap outdated           # Check for outdated packages
  importmap pin [*PACKAGES]    # Pin new packages
  importmap unpin [*PACKAGES]  # Unpin existing packages

Adding a package

To add a package, use bin/importmap pin <PACKAGE>, much like you might do with npm install or yarn add.

› bin/importmap pin canvas-confetti
Pinning "canvas-confetti" to https://ga.jspm.io/npm:canvas-confetti@1.5.1/dist/confetti.module.mjs

This adds a line to the config/importmap.rb to take note of that package as a dependency. Rather than put the files in node_modules like npm, it only takes note of a jspm cdn URL.

# Pin npm packages by running ./bin/importmap

pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin_all_from "app/javascript/controllers", under: "controllers"
pin "canvas-confetti", to: "https://ga.jspm.io/npm:canvas-confetti@1.5.1/dist/confetti.module.mjs"

Importmap markup

In development, Rails will render a <script type="importmap"> block. The default Rails app will have <%= javascript_importmap_tags %> in the main layout, which is what renders this block.

The canvas-confetti I added earlier is there, being linked to jspm. This means that anytime I use that package (or any other package I might have pinned), browsers will be downloading it from jspm.io, not my website. That is, it’s using a public CDN not self-hosting the package.

This is the same markup in both development and production.

    <script type="importmap" data-turbo-track="reload">{
  "imports": {
    "application": "/assets/application-37f365cbecf1fa2810a8303f4b6571676fa1f9c56c248528bc14ddb857531b95.js",
    "@hotwired/turbo-rails": "/assets/turbo.min-e5023178542f05fc063cd1dc5865457259cc01f3fba76a28454060d33de6f429.js",
    "@hotwired/stimulus": "/assets/stimulus.min-b8a9738499c7a8362910cd545375417370d72a9776fb4e766df7671484e2beb7.js",
    "@hotwired/stimulus-loading": "/assets/stimulus-loading-1fc59770fb1654500044afd3f5f6d7d00800e5be36746d55b94a2963a7a228aa.js",
    "canvas-confetti": "https://ga.jspm.io/npm:canvas-confetti@1.5.1/dist/confetti.module.mjs",
    "controllers/hello_controller": "/assets/controllers/hello_controller-549135e8e7c683a538c3d6d517339ba470fcfb79d62f738a0a089ba41851a554.js",
    "controllers": "/assets/controllers/index-2db729dddcc5b979110e98de4b6720f83f91a123172e87281d5a58410fc43806.js",
    "controllers/application": "/assets/controllers/application-368d98631bccbf2349e0d4f8269afb3fe9625118341966de054759d96ea86c7e.js"
  }
}</script>

Aside: Importing JavaScript in browsers

Being able to import JavaScript files from other JavaScript files has always been something that had many different approaches. Even Rails had it’s own—sprockets—which continues to be supported to this day in Rails 7.

Is wasn’t long until a new standard was made to be the native way for importing JavaScript files. This new standard way is called JavaScript modules. 3 In the wild, it might be more commonly known as “ES modules” or “ESM” after its name prior to standardisation, but it’s JavaScript modules all the same.

Browsers have supported JavaScript modules since 2017. This import statement will work in any modern browser today, as long as it’s placed in a <script type="module"> block:

<script type="module">
import confetti from "https://ga.jspm.io/npm:canvas-confetti@1.5.1/dist/confetti.module.mjs";
confetti();
</script>

![[2022-08-30 canvas confetti codepen.mov]]

About importmaps

Being able to import straight from URL’s is great, but it can easily get repetitive. To work around this, an “import map” can be used to alias URL’s into easily-readable names.

The gem importmap-rails got its name because generating these importmap blocks is sole bread-and-butter. It’s the mechanism that allows developers use 3rd-party JavaScripts commonly found in npm in the browser, all without having to modify scripts.

<script type="importmap">{
  "imports": {
    "canvas-confetti": "https://ga.jspm.io/npm:canvas-confetti@1.5.1/dist/confetti.module.mjs"
  }
}</script>

<script type="module">
import confetti from "canvas-confetti";
confetti();
</script>

Browser support for import maps

In 2022, import maps are only supported by the latest Chrome, Edge, and Opera browsers. Even the Webkit-based Safari doesn’t support it natively yet, and Firefox doesn’t either. It’s still considered an unofficial standard.

![[Pasted image 20220830105604.png]]

Source: https://caniuse.com/import-maps

ES module shim

Rails will also load a package called es-module-shims. This seems to be behaviour built into Rails’s importmap-rails.

<script src="/assets/es-module-shims.min-d89e73202ec09dede55fb74115af9c5f9f2bb965433de1c2446e1faa6dac2470.js" async="async" data-turbo-track="reload"></script>

It seems es-module-shims adds a bit of runtime cost. Even for a modern browser like Firefox 103, the console tells me there’s some 2ms being spent in compiling the asm.js code. This is despite es-module-shims claiming that “users with import maps entirely bypass the shim code entirely” 2 — that’s because importmaps aren’t supported in Firefox (yet).

![[Pasted image 20220830103238.png]]

Importing application.js

Finally, Rails will add an import for application.js by ways of a <script type="module">. This is not a single-file bundle, contrary to what would typically happen with bundlers like Webpack. This asks the browser to load application.js, and load its dependencies, then load the dependencies of those dependencies, and so on.

It will download multiple files—up to hundreds in a non-trivial application. While this seems like a step backwards, the claim is that this is fine. With HTTP2, browsers can now download multiple files in parallel with a feature called multiplexing, mimicking the performance of bundled files.

<script type="module">import "application"</script>

Module preload

To speed things along, Rails will also generate <link rel="modulepreload"> tags. Typically, the browser will have to download application.js to find its dependencies, leading to a “waterfall” style download where files are downloaded in stages.

Instead, these modulepreload tags will signal browsers to prefetch scripts that are dependenies of application.js, so they can be downloaded right away.

<link rel="modulepreload" href="/assets/application-37f365cbecf1fa2810a8303f4b6571676fa1f9c56c248528bc14ddb857531b95.js">
<link rel="modulepreload" href="/assets/turbo.min-e5023178542f05fc063cd1dc5865457259cc01f3fba76a28454060d33de6f429.js">
<link rel="modulepreload" href="/assets/stimulus.min-b8a9738499c7a8362910cd545375417370d72a9776fb4e766df7671484e2beb7.js">
<link rel="modulepreload" href="/assets/stimulus-loading-1fc59770fb1654500044afd3f5f6d7d00800e5be36746d55b94a2963a7a228aa.js">

Is un-bundling faster?

I tried looking for benchmarks on whether this approach of loading many small files (“un-bundling”), is better than one big file (“bundling”). I wasn’t able to find any, but anecdotally, there are reports that JavaScript modules and HTTP2 aren’t faster than bundlers at all. In fact, they may even be slower, possibly due to the way compression works.

On the JavaScript community, there’s been progress on being less reliant on bundling. Vite is one of these “un-bundlers” that take advantage of JavaScript module support in the browser. However, it still performs bundling in production because it’s a very efficient way to serve files to browsers.

Limitations of jspm

With importmap-rails, third-party packages will be loaded from a public CDN, jspm. This might be a concern with private apps. If jspm is down, sites that use it will be affected as well.

No React, no TypeScript

With importmap-rails, there is no build step that proccesses JavaScript files. That means it’s not possible to use things like TypeScript, React, Vue, Svelte, Solid, Angular, or any of the common JavaScript tools today used to build frontends.

Rather than rely on tools like React, the Rails community suggests to use Stimulus instead. Stimulus also comes default with Rails 7.

More limitations

No building. No tree-shaking. No CSS modules. No automated upgrade tools like Dependabot (yet). No automated security audits like Snyk (yet).

My thoughts

Pros:

  • Simple, no bundlers to configure
  • Faster deployments, no “npm install” or bundling

Cons:

  • Hard dependency on a public CDN
  • Downtime in public CDN’s will affect your site
  • Runtime cost for Firefox and Safari
  • No support for TypeScript
  • No support for React (and other tools needing a build step)
  • No automated upgrade tools like Dependabot
  • No automated security audits like Snyk

Links

  1. https://github.com/rails/importmap-rails#importmap-for-rails
  2. https://github.com/guybedford/es-module-shims#es-module-shims
  3. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules
  4. https://web.dev/performance-http2/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment