BerandaComputers and TechnologyThe Case for Weak Dependencies in JavaScript

The Case for Weak Dependencies in JavaScript

Earlier today, I was briefly entertaining the idea of writing a library to wrap and enhance querySelectorAll in certain ways. I thought I’d rather not introduce a Parsel dependency out of the box, but only use it to parse selectors properly when it’s available, and use more crude regex when it’s not (which would cover most use cases for what I wanted to do).

In the olden days, where every library introduced a global, I could just do:

if (window.Parsel) {
	let ast = Parsel.parse();
	// rewrite selector properly, with AST
else {
	// crude regex replace

However, with ESM, there doesn’t seem to be a way to detect whether a module is imported, without actually importing it yourself.

I tweeted about this…

One issue with ESM (and other module systems) is that you can’t have optional dependencies.

With globals, you could check if a global existed and branch accordingly. With modules, you can’t check if a module is available/loaded without importing it from somewhere.

— Lea Verou (@LeaVerou) November 19, 2020

I thought this was a common paradigm, and everyone would understand why this was useful. However, I was surprised to find that most people were baffled about my use case. Most of them thought I was either talking about conditional imports, or error recovery after failed imports.

I suspect it might be because my primary perspective for writing JS is that of a library author, where I do not control the host environment, whereas for most developers, their primary perspective is that of writing JS for a specific app or website.

After Kyle Simpson asked me to elaborate about the use case, I figured a blog post was in order.

The use case is essentially progressive enhancement (in fact, I toyed with the idea of titling this blog post “Progressively Enhanced JS”). If library X is loaded already by other code, do a more elaborate thing and cover all the edge cases, otherwise do a more basic thing. It’s for dependencies that are not really dependencies, but more like nice-to-haves.

We often see modules that do things really well, but use a ton of dependencies and add a lot of weight, even to the simplest of projects, because they need to cater to all the edge cases that we may not care about. We also see modules that are dependency free, but that’s because lots of things are implemented more crudely, or certain features are not there.

This paradigm gives you the best of both worlds: Dependency free (or low dependency) modules, that can use what’s available to improve how they do things with zero additional impact.

Using this paradigm, the size of these dependencies is not a concern, because they are optional peer dependencies, so one can pick the best library for the job without being affected by bundle size. Or even use multiple! One does not even need to pick one dependency for each thing, they can support bigger, more complete libraries when they’re available and fall back to micro-libraries when they are not.

Some examples besides the one in the first paragraph:

  • A Markdown to HTML converter that also syntax highlights blocks of code if Prism is present. Or it could even support multiple different highlighters!
  • A code editor that uses Incrementable to make numbers incrementable via arrow keys, if it’s present
  • A templating library that also uses Dragula to make items rearrangable via drag & drop, if present
  • A testing framework that uses Tippy for nice informational popups, when it’s available
  • A code editor that shows code size (in KB) if a library to measure that is included. Same editor can also show gzipped code size if a gzip library is included.
  • A UI library that uses a custom element if it’s available or the closest native one when it’s not (e.g. a fancy date picker vs ) when it isn’t. Or Awesomplete for autocomplete when it’s available, and fall back to a simple when it isn’t.
  • Code that uses a date formatting library when one is already loaded, and falls back to Intl.DateTimeFormat when it’s not.

This pattern can even be combined with conditional loading: e.g. we check for all known syntax highlighters and load Prism if none are present.

To recap, some of the main benefits are:

  • Performance: If you’re loading modules over the network HTTP requests are expensive. If you’re pre-bundling it increases bundle size. Even if code size is not a concern, runtime performance is affected if you take the slow but always correct path when you don’t need it and a more crude approach would satisfice.
  • Choice: Instead of picking one library for the thing you need, you can support multiple. E.g. multiple syntax highlighters, multiple Markdown parsers etc. If a library is always needed to do the thing you want, you can load it conditionally, if none of the ones you support are loaded already.

How could this work with ESM?

Some people (mostly fellow library authors) *didunderstand what I was talking about, and expressed some ideas about how this would work.

Idea 1: A global module loaded cache could be a low-level way to implement this, and something CJS supports out of the box apparently.

CommonJS exposes the cache … maybe ESM could follow, handy also for cache invalidation and code coverage with tests … indeed my tests are all CommonJS and I *can’tchange that 😔

— Andrea Giammarchi 🍥 (@WebReflection) November 19, 2020

Idea 2: A global registry where modules can register themselves on, either with an identifier, or a SHA hash

Idea 3: An import.whenDefined(moduleURL) promise, though that makes it difficult to deal with the module not being present at all, which is the whole point.

an ugly way could be to:

globalThis[Symbol.for(moduleName)] = true;

in the main module export/file

another way I use with uce is to pass through customElements.whenDefined(‘uce-lib’).then(…) so that if nobody imports ‘uce’ nothing happens


— Andrea Giammarchi 🍥 (@WebReflection) November 19, 2020

If it exported a known SHA. a could be solved

— James Campbell (@jcampbell_05) November 19, 2020

Idea 4: Monitoring . The problem is that not all modules are loaded this way.

I had a few things in mind:

– if the tag is present in the page, you could assume it’s either present or will be very soon (already loading), so that might/should be a safe enough signal for your use-case

– you could also attach a load event handler to the tag to observe it

— getify (@getify) November 19, 2020

Idea 5: I was thinking of a function like import() that resolves with the module (same as a regular dynamic import) only when the module is already loaded, or rejects when it’s not (which can be caught). In fact, it could even use the same functional notation, with a second argument, like so:

import("https://cool-library", {weak: true});

Nearly all of these proposals suffer from one of the following problems.

Those that are URL based mean that only modules loaded from the same URL would be recognized. The same library loaded over a CDN vs locally would not be recognized as the same library.

One way around this is to expose a list of URLs, like the first idea, and allow to listen for changes to it. Then these URLs can be inspected and those which might belong to the module we are looking for can be further inspected by dynamically importing and inspecting their exports (importing already imported modules is a pretty cheap operation, the browser does de-duplicate the request).

Those that are identifier based, depend on the module to register itself with an identifier, so only modules that want to be exposed, will be. This is the closest to the old global situation, but would suffer in the transitional period until most modules use it. And of course, there is the potential for clashes. Though the API could take care of that, by essentially using a hashtable and adding all modules that register themselves with the same identifier under the same “bucket”. Code reading the registry would then be responsible for filtering.

Read More



Please enter your comment!
Please enter your name here

Most Popular

Recent Comments