Modern Web Weekly #4

Modern Web Weekly #4
Staying up to date with the modern web

Episode #4 of Modern Web Weekly is here! I'm still fine-tuning stuff behind the scenes to get you the best update I can so keep the feedback coming in! You can send me a DM on Twitter to let me know your feedback, wishes, complaints, or anything else you want to share.

Going buildless with import maps

Does your web app need a build step? Does it really need a build step?

My project What PWA Can Do Today only needed a build step because I had to import dependencies using bare import specifiers. These specifiers don't point to an imported module file but rather the name of the dependency you want to import.

Something like this:

import { LitElement } from 'lit';

where the import specifier lit doesn't point to an actual file but when you use a build tool like Webpack or Rollup this somehow works.

This is not magic though. It's simply the Node.js module resolution algorithm. Since all these build tools are Node.js applications, they can use this algorithm to find the correct files at build time.

In the browser, we of course don't have that luxury. We can't afford to make multiple HTTP requests to try and find the correct file as this would result in a lot of network overhead. So build tools are basically our only option to rewrite all bare module specifiers to the actual files they point to but these have their limitations as well.

While static imports like import { LitElement } from 'lit' can be statically analyzed, this is not true for dynamic imports like import(specifier) since it's impossible to statically analyze the strings passed to the import() function.

Furthermore, tools like Webpack and Rollup need to inject code into the source code of your web app to make everything work which can result in significant overhead.

Import maps to the rescue

Import maps aim to solve this problem by enabling developers to control the behavior of JavaScript imports. An import map is basically a JSON structure that maps bare import specifiers to the actual files those specifiers need to resolve to. This way, you can continue to use simple specifiers without having to specify lengthy file paths all over your app.

Let's look at the previous example again:

import { LitElement } from 'lit';

To make an import using the bare specifier lit work in the browser you would create an import map that looks like this

<script type="importmap">
  {
    "imports": {
      "lit": "/node_modules/lit/index.js"
    }
  }
</script>

Now, when the browser encounters the above import it will resolve to this:

import { LitElement } from '/node_modules/lit/index.js';

The browser will now fetch the correct file and everything should work.

The import map is simply a <script> tag with type 'importmap' and a key named imports. This key contains a JSON object whose keys are the bare specifiers and the values are the paths to the actual files that need to be imported. So whenever the app encounters the bare specifier lit it will resolve it to '/node_modules/lit/index.js'.

But how do you know which file a bare import specifier should resolve to?

When you use an editor or IDE like Webstorm or VS Code you can point to the bare specifier and CMD+click or CTRL+click on it and you will navigate to the actual file the specifier points to. If this doesn't work for you, simply open the folder of the dependency inside node_modules and inspect it. Usually, the correct file is index.js or a file with the same name as the module you try to import.

For example, something like import { foo } from 'module/foo' usually points to '/node_modules/module/foo.js' or '/node_modules/module/foo/index.js'.

When in doubt, just check the files to see which one exports the module you try to import. If you didn't specify the correct file to resolve to, the browser will throw an error so you can simply try until all errors are gone. My experience is that import paths are usually quite straightforward and predictable so after you figured out a few it won't be too hard.

In the beginning, it may feel like you're going down a rabbit hole though. Each file that you correctly mapped to a bare specifier may recursively contain other bare specifiers so when you have a fairly large app it may take a while until all specifiers are correctly resolved to files.

Packages via trailing slashes

When you have a package that contains multiple modules you can map a specifier prefix to a file path prefix. For example, when you have imports like this:

import { foo } from 'module/foo.js';
import { bar } from 'module/bar.js';

You could write an import map like this:

<script type="importmap">
  {
    "imports": {
      "module/foo.js": "/node_modules/module/src/foo.js",
      "module/bar.js": "/node_modules/module/src/bar.js"
    }
  }
</script>

But when you have a large number of modules to import, this list can quickly become very long and tedious to manage. Since it always points to the same folder (/node_modules/module/src) you can shorten this by adding a trailing slash to the module specifier:

<script type="importmap">
  {
    "imports": {
      "module/": "/node_modules/module/src/"
    }
  }
</script>

Now, any specifier that starts with module/ will be resolved to /node_modules/module/src/.

Decoupling import specifiers from URLs

In essence, import maps decouple module specifiers in import statements from the URLs to the files that are imported. This is a very powerful feature that for example can also improve the cachability of files.

Let's say you have three modules: main.js, dep.js and sub-dep.js. main.js depends on dep.js and dep.js depends on sub-dep.js. Let's also say these modules have a hash in their filenames (for example main-66ec079b.js, dep-2d70f233.js and sub-dep-eee16afc.js) for caching purposes.

If sub-dep.js is updated, a new hash will be generated for its filename (for example sub-dep-56f723b7.js) and the import statement in dep-2d70f233.js will have to be updated to this new filename. Since the contents of dep-2d70f233.js are now also changed, this file will too get a new hash in its filename. Since main-66ec079b.js depends on dep-2d70f233.js it will have to change its import statement to this new filename as well. This will also change the contents main-66ec079b.js causing it to get a new hash in its filename and so on.

So a simple change in a file can cause all files that depend on it to lose their cachability.

With an import map, you can simply abstract away these hashes from the code by remapping the filenames without hashes to the filenames that do contain a hash:

{
  "imports": {
    "/js/main.js": "/js/main-66ec079b.js",
    "/js/dep.js": "/js/dep-2d70f233.js",
    "/js/sub-dep.js": "/js/sub-dep-eee16afc.js"
  }
}

Now, these files can be imported without the hashes in the filenames and whenever any of these files changes and its hash is updated you only need to update the import map while you can leave the import statements alone.

You can go buildless today!

Import maps are now supported in all modern browsers and a polyfill is available for older browsers. Your web app may have other tasks that are run as part of the build (minifying code, image processing, etc.) so maybe you still need a build step for this.

But if your build step is only needed to import modules using specifiers that don't work in the browser by default, import maps offer a solid solution to get rid of it.

To learn more about what else you can do with import maps, read the import maps explainer in the WICG repo.

Polyfill: https://github.com/guybedford/es-module-shims#import-maps

🔗
Got an interesting link for Modern Web Weekly?Send me a DM on Twitter to let me know!