Skip to main content

Using ES modules in browsers with import-maps

 

Introduction

ES modules have been the talking point in the JavaScript community for a long time. The main goal of them is to bring an official standardization of module systems in JavaScript. When something becomes a standard in JavaScript, there are two main steps involved. First, the spec has to be approved and finalized by EcmaScript, which has been done. Second, the browsers should start implementing it. This step is a bit time consuming and comes with all the hassles of backward compatibility.

The good news is there has been great progress on browser support for ES modules. The below chart shows that all major browsers including Edge, Chrome, Safari, and Firefox (+60) do support ES modules:

can i use shoing JavaScript ES modules support across browsers

When it comes to modules, there have been several attempts to bring this functionality into the JavaScript world. For example:

  • Node.js has implemented its own module system
  • Bundlers and build tools such as Webpack, Babel, and Browserify integrated module usage

So with these efforts, few module definitions have been implemented. The two lesser-used ones are:

  • AMD or Asynchronous Module Definition
  • UMD or Universal Module Definition

However, the leading ones are:

  • CommonJS which is the Node.js implementation of module
  • ES modules which is the native JavaScript’s standard for defining modules

There are a few things we will not be covering in this article:

  • We will not focus on CommonJS unless it has a direct feature to ES modules. If you are interested in learning more about this module system, please read this article
  • Even though there is support for ES modules on Node, our main focus for this article is on the usage of ES modules in browsers natively. If you are interested in learning more about ES modules support in Node, I suggest this official documentation, as well as this and this article

Why do we even need ES modules?

To answer this question, we need to go way back to the fundamentals of JavaScript. In JavaScript, like many other programming languages, a large portion of our focus is on building, managing, and using variables and functions. You can consider these as building blocks that will be used together to form logical sequences that deliver an end result to the user. However, as the number of variables, functions, and files that contain them increases so does the importance to maintain them. For example, you cannot have the change of a variable unexpectedly affect other unrelated parts of the code, even if they share the same name.

In a file level, we have solved this problem. You can utilize variables and functions and also cannot access and manipulate variables outside of function scopes. And if you need to have a common variable that is shared among different functions, you will put it on top of the file, so all of them can access it. This is demonstrated in the code below:

// file.js

var foo = "I'm global";
var bar = "So am I";

function () {
    var foo = "I'm local, the previous 'foo' didn't notice a thing";
    var baz = "I'm local, too";

    function () {
        var foo = "I'm even more local, all three 'foos' have different values";
        baz = "I just changed 'baz' one scope higher, but it's still not global";
        bar = "I just changed the global 'bar' variable";
        xyz = "I just created a new global variable";
    }
}

But what about having such a mechanism between different files?

Well, as a first attempt, you might want to do something similar. Imagine several files in your codebase need access to a certain type of library. That library, like jQuery, could be a selection of helper functions to help your development workflow. In such a scenario, you need to put the library instance somewhere that can be accessible to all the files that might need it. One of the initial steps of handling this was to put the library on a global script. Now you might think since these global scripts are instantiated in the entry file where all the other files have access, then the issue of sharing access to certain functionalities or libraries will become easier, right? Well, not really.

This approach comes with certain problems. The dependency between different files and shared libraries will become important. This becomes a headache if the number of files and libraries increases because you always have to pay attention to the order of script files, which is an implicit way of handling dependency management. Take the below code for instance:

<script src="index1.js"></script>
<script src="index2.js"></script>
<script src="main.js"></script>

In the code shown above, if you add some functionalities in index1.js file that references something from index2.js, those functionalities will not work because the code execution flow has still not reached index.2 at that point in time. Besides this dependency management, there are other types of issues when it comes to using script tags as a way of sharing functionalities like:

  • Slower processing time as each request blocks the thread
  • Performance issue as each script initiates a new HTTP request

You can probably imagine refactoring and maintaining code that relies on such design is problematic. Every time you want to make a change, you have to worry about not breaking any other previous functionalities. That is where modules come to the rescue.

ES modules or, in general, modules are defined as a group of variables and functions that are grouped together and are bound to a module scope. It means that it is possible to reference variables in the same module, but you can also explicitly export and import other modules. With such an architecture, if a certain module is removed and parts of the code break as a result, you will be able to understand what caused the issue.

As mentioned before, there have been several attempts to bring the module design to JavaScript. But so far the closest concept of a native module design has been ES modules which we are going to examine in this article.

We are going to see a few basic examples of how ES modules are used and then explore the possibility of using them in production sites. We’ll also look at some tools that can help us achieve this goal.

ES modules in browsers

It is very easy to define a module in browsers as we have access to HTML tags. It would be sufficient to pass a type='module' attribute to the script tag. When the browser reaches any script tag with this attribute, it knows that this script needs to be parsed as a module. It should look something like this:

// External Script
<script type="module" src="./index.js"></script>

// Inline Script
<script type="module">
  import { main } from './index.js';
  // ...
</script>

In this case, the browser will fetch any of the top-level scripts and put it in something called module map with a unique reference. This way, if it encounters another script that points to the same reference, it just moves on to the next script and therefore every module will be parsed only once. Now let’s imagine the content of the index.js looks like this:

// index.js
import { something } from './something.js'

export const main = () => {
  console.log('do something');
}
//..

When we look at this file we see both import and export statements which are ways of using and exposing dependencies. So when the browser is completing its asynchronous journey of fetching and parsing these dependencies, it just starts the process from the entry file which, in this case, was the HTML file above and then continues putting references of all the nested modules from the main scripts in the module map until it reaches the most nested modules.

Keep in mind that fetching and parsing modules in just the first step of loading modules in browsers. If you are interested in reading more in detail about the next steps, give this article a careful read.

But for us, we try to shed a bit of light on an aspect of ES module usage in browsers which is the usage of import-maps to make the process of specifying module specifiers easier.

Why and how to use import-maps?

In the construction phase of loading modules, there are two initial steps to take.

The first one is module resolution which is about figuring out where to download the module from. And the second step is actually downloading the module. This is where one of the biggest differences between modules in a browser context and a context like Node.js comes up. Since Node.js has access to the filesystem, its way of handling module resolution is different from the browser. That is why you can see something like this in a Node.js context:

const _lodash = require('lodash');

Also in a browser context with using a builder tool like Webpack, you would do something like this:

import * as _lodash from 'lodash';

In this example, the 'lodash' module specifier is known to the Node.js process because it has access to filesystem or the packages distributed through npm package manager. But the browser can only accept URLs for the module specifier because the only mechanism for getting modules is to download them over the network. This was the case until a new proposal for ES modules was introduced, called import-maps, to resolve this issue and bringing a more consistent look and feel between module usage in browsers and other tools and bundlers.

So the import-maps define a map of module import names which allows developers to provide bare import specifiers like import "jquery". If you use such an import statement in browsers today, it will throw because they are not treated as relative URLs and are explicitly reserved. Let’s see how it works.

By providing the attribute type="importmap" on a script tag, you can define this map and then define a series of bare import names and a relative or absolute URL. Remember that if you are specifying a relative URL such as the example below, the location of that file should be relative to the file where the import-maps is defined, which is index.html in this instance:

// index.html

<script type="importmap">
{
  "imports": {
    "lodash": "/node_modules/lodash-es/lodash.js"
  }
}
</script>

After defining this map, you can directly import lodash anywhere in your code:

import jQuery from 'jquery';

But if you did not use import-maps, you have to do something like the code shown below, which is cumbersome as well as inconsistent with how modules are defined today with other tools:

import jQuery from "/node_modules/jQuery/index.js";

So it is clear that using import-maps help to bring consistency with how modules are used today. Chances are if you are used to requiring or importing modules in the context of NodeJS or Webpack, some basic groundwork has been done for you already. Let’s explore a few of these scenarios and see how they are handled via import-maps in browsers.

You have probably seen that sometimes the module specifier is used without the extension when used in Node.js. For example:

// requiring something.js file
const something = require('something');

This is because, under the hood, Node.js or other similar tools are able to try different extensions for the module specifier you defined until they find a good match. But such a functionality is also possible via import-maps when using ES modules in browsers. This is how you should define the import-maps to achieve this:

{
  "imports": {
    "lodash/map": "/node_modules/lodash/map.js"
  }
}

As you can see, we are defining the name of module specifier without the .js extension. This way we are able to import the module in two ways:

// Either this
import map from "lodash/map"

// Or
import map from "lodash/map.js"

One could argue that the extension-less file import is a bit ambiguous, which is valid. I personally prefer to precisely define the file extension, even when defining module specifiers in Node.js or Webpack context. Additionally, if you want to adopt the extension-less strategy with import-maps, you will be overwhelmed as you have to define the extra extension-less module specifier for each of the modules in a package and not only the top-level file. This could easily get out of hand and bring less consistency to your code.

It is common among libraries and packages distributed through npm to contain several modules that you can import into your code. For example, a package like lodash contains several modules. Sometimes you want to import the top-level module and sometimes you might be interested in a specific module in a package. Here is how you might specify such a functionality using import-maps:

{
  "imports": {
    "lodash": "/node_modules/lodash/lodash.js",
    "lodash/": "/node_modules/lodash/"
  }
}

By specifying a separate module specifier name as lodash/ and mirroring the same thing in the address /node_modules/lodash/, you are allowing for specific modules in the package to be imported with ease which will look something like this:

// You can directly import lodash
import _lodash from "lodash";

// or import a specific moodule
import _shuffle from "lodash/shuffle.js";

Conclusion

Together in this article, we have learned about the ES modules. We covered why modules are essential and how the community is moving towards using the standard way of handling them.

When it comes to using ES modules in browsers today, an array of questions such as old browser compatibility, and fallback handling, as well as the true place of ES modules, next to bundler and build tools, come to mind. I strongly think ES modules are here to stay, but their presence does not eliminate the need for bundlers and builders, because they serve other essential purposes such as dead code elimination, minifying, and tree shaking. As we already know, popular tools like Node.js are also adopting ES modules in newer versions.

ES modules have wide browser support currently. Some of the features around ES modules such as dynamic import (allowing function based imports) as well as the import.meta (supporting Node.js cases) are part of the JavaScript spec now. And as we explored, import-maps is another great feature that would allow us to smooth over the differences between Node.js and browsers.


Comments

Popular posts from this blog

How to use Ngx-Charts in Angular ?

Charts helps us to visualize large amount of data in an easy to understand and interactive way. This helps businesses to grow more by taking important decisions from the data. For example, e-commerce can have charts or reports for product sales, with various categories like product type, year, etc. In angular, we have various charting libraries to create charts.  Ngx-charts  is one of them. Check out the list of  best angular chart libraries .  In this article, we will see data visualization with ngx-charts and how to use ngx-charts in angular application ? We will see, How to install ngx-charts in angular ? Create a vertical bar chart Create a pie chart, advanced pie chart and pie chart grid Introduction ngx-charts  is an open-source and declarative charting framework for angular2+. It is maintained by  Swimlane . It is using Angular to render and animate the SVG elements with all of its binding and speed goodness and uses d3 for the excellent math functio...

Understand Angular’s forRoot and forChild

  forRoot   /   forChild   is a pattern for singleton services that most of us know from routing. Routing is actually the main use case for it and as it is not commonly used outside of it, I wouldn’t be surprised if most Angular developers haven’t given it a second thought. However, as the official Angular documentation puts it: “Understanding how  forRoot()  works to make sure a service is a singleton will inform your development at a deeper level.” So let’s go. Providers & Injectors Angular comes with a dependency injection (DI) mechanism. When a component depends on a service, you don’t manually create an instance of the service. You  inject  the service and the dependency injection system takes care of providing an instance. import { Component, OnInit } from '@angular/core'; import { TestService } from 'src/app/services/test.service'; @Component({ selector: 'app-test', templateUrl: './test.component.html', styleUrls: ['./test.compon...

How to solve Puppeteer TimeoutError: Navigation timeout of 30000 ms exceeded

During the automation of multiple tasks on my job and personal projects, i decided to move on  Puppeteer  instead of the old school PhantomJS. One of the most usual problems with pages that contain a lot of content, because of the ads, images etc. is the load time, an exception is thrown (specifically the TimeoutError) after a page takes more than 30000ms (30 seconds) to load totally. To solve this problem, you will have 2 options, either to increase this timeout in the configuration or remove it at all. Personally, i prefer to remove the limit as i know that the pages that i work with will end up loading someday. In this article, i'll explain you briefly 2 ways to bypass this limitation. A. Globally on the tab The option that i prefer, as i browse multiple pages in the same tab, is to remove the timeout limit on the tab that i use to browse. For example, to remove the limit you should add: await page . setDefaultNavigationTimeout ( 0 ) ;  COPY SNIPPET The setDefaultNav...