Alexander Nnakwue Software engineer. React, Node.js, Python, and other developer tools and libraries.

How to transpile ES modules with webpack and Node.js

11 min read 3271

How to transpile ES modules with webpack and Node.js

In the JavaScript ecosystem, webpack stands out as a major tool for bundling our code. But before we dive into how webpack does that, which is the major point of our post, let us first of all understand what ES modules are.

What are ES modules?

ES modules (ESM) are the recommended way for writing code for both Node.js and the browser. Currently, ES modules are supported by all major browsers and they represent an official standard format for packaging and reusing JavaScript code on the web.

In some earlier posts, we have covered how to work with ES modules and also briefly touched on the evolution of ESM in the Node.js ecosystem. This post will mainly focus on extending the first post, where we clarified how the Node.js module ecosystem has evolved over the years — from the CommonJS module system to the current, fully ready, production-level support for ESM, which is the spec compliant, backwards compatible, and interoperable module system recommended for use today.

As we have covered in our earlier post, we noted that we can define and use packages in the module system with the export and import keywords. Also, we pointed out the different ways source code or library authors can instruct the Node.js runtime to treat JavaScript code as ESM, since the Node runtime treats JavaScript code as a CommonJS module by default.

In this post, we will expand on our earlier posts and optimize code written with the ECMAScript modules specification to make them portable and interoperable with older Node.js versions.

Importing and exporting functions

See below an example of how to import and export a function with ES module syntax:

// smallestNumber.js
export function smallestNumber(arr) {
    let smallestNumber = arr[0]
    let smallestIndex = 0
    for(let i = 1; i < arr.length; i++) {
       if(arr[i] < smallestNumber) {
         smallestNumber = arr[i]
         smallestIndex = i 
       }
    }
     return smallestNumber;
}

Note that, as we may know, the export keyword is useful as it allows us to make our smallestNumber function available to other modules who need to call it. Apart from regular functions, we can export constants, classes, and even just simple variables. Also note that we can have default exports with ESM.

// result.js
import { smallestNumber } from './smallestNumber.js';

console.log(smallestNumber([3,2,5,6,0,-1]))

//returns -1

Note that for the import keyword, we can make a reference to the smallestNumber function we exported from the ESM module or package, smallestNumber.js.

Just to recap, more details on these topics — including a rundown of ES modules and how to use them — can be found in our earlier blog post. The documentation for ESM widely covers this topic, including other broader topics that are not covered in our earlier blog post.

Transpiling code for backward compatibility

We’ll eventually focus on the mechanics of the code transpilation process, in the next section, but before that, let us understand the importance of this process in the first place.

As we have earlier mentioned, older JavaScript versions, such as ES5, need to be able to run our new code and also understand its syntax. This means that there needs to be a way for the language to be fully backward compatible.

Backward compatibility is one of the most important aspects to consider and prioritize when adding new features to a programming language. For ESM, the Node.js runtime is able to determine the module system/format it should default to based on the type field, when it is set to module in the package.json file, which is located in the root of the project.

//package.json file 
{
  "type": "module"
}

The setup above makes all files that are at the same level of the folder structure as the package.json file default to ESM. Alternatively, we can decide to set the type as commonjs, and the Node runtime will force all files to conform to the default Common JS Module system. Although this is usually the default behavior if no type field is specified.



We have covered other ways — for example, using the .mjs or .cjs file formats and how these extensions are resolved as either ESM or other module formats — in our earlier blog post as well.

In essence, when modules are flagged as ECMAScript modules, the Node runtime uses a different pattern or method for resolving file imports. For example, imports are now stricter, meaning we must append the full filename with their extensions for relative paths or requests as the case maybe.

What is code transpilation?

With the introduction of a new JavaScript version and changes to the syntax (also known as ES2015), the introduction of TypeScript, the JavaScript superset , and other language advancements like CoffeeScript, for example — writing JavaScript that runs everywhere is no longer as straightforward as it once was.

As we may already be aware, different browsers have different JavaScript engines, and their varying levels of adoption of and support for these new JS features might not be unanimous because they didn’t meet the language specification at equal times. This has resulted in cases where code can work on one browser and not work on another.

The essence of transpilation, therefore, is to be able to convert the new JS ES2015 syntax to the old ES5 syntax, so code can run on older browsers.

For example, template literals or, say, null coalescing and other ES2015 or ES2015+ features, still do not have full browser support and server-side runtime support, so we might need to transpile our code to support these versions.

Majorly, tools called loaders like Babel, Traceur, and so on are used in conjunction with webpack for transpiling code.

On a high level, transpilers work across programming languages by reading source code line by line and producing an equivalent output. For example, we might want to transpile a TypeScript codebase to plain old JavaScript.

In general, transpilers allow us to use new, non-standardized JavaScript features with assurance. Currently, the best approach to working with ES modules on both the Node.js and browser environment is to transpile them to the CommonJS module format with Babel.

Transpiling in Node.js

ESM comes with a transpiler loader by default that converts code from sources the Node runtime does not understand into plain JS using Loader Hooks.

Here, we can load our source code as needed from the disk, but before Node.js executes it for us. This is not usually applicable in a browser environment because fetching each file individually over the wire would be very slow and non-performant.


More great articles from LogRocket:


The transpiler loader also comes with a resolve Hook that tells the runtime how to handle unknown file types.

What is webpack?

Webpack is a build tool that helps bundle our code and its dependencies into a single JavaScript file. We can also say that webpack is a static module bundler of sorts for JavaScript applications. This is because it applies techniques such as tree shaking and compilation (which consists of both transpilation and minification steps) to our source code.

Bundlers like webpack work hand-in-hand with transpilers. This means that they are totally different, but rather complementary toolsets. Therefore, we need to configure webpack to work with a transpiler — say Babel.

As we have mentioned earlier, transpilers either perform the job of compiling one language to another, or to make a language backward compatible. Webpack works pretty well with Babel, and it is also easily configurable. For example, we can configure Babel to work with webpack by creating a webpack config file (webpack.config.js) using the Babel plugin — in fact, the webpack plugin ecosystem is what makes webpack what is.

Babel, on the other hand, can be configured using either a babel.config.js file or a .babelrc file.

Why webpack?

As you may know, webpack supports a couple of module types out of the box, including both CommonJS and ES modules. Webpack also works on both client- and server-side JavaScript, so with webpack, we can also easily handle assets and resources like images, fonts, stylesheets, and so on.

It remains a really powerful tool as it automatically builds and infers the dependency graph based on file imports and exports (since, under the hood, every file is a module). Combining this with loaders and plugins makes webpack a great tool in our arsenal. More details on how this works under the hood can be found in the documentation.

In terms of plugins, webpack also has a rich plugin ecosystem. Plugins support webpack in doing some dirty work like optimizing bundles, managing assets, and so on.

In summary, bundling with tools like webpack is the fastest way for working or ensuring backward compatibility with modules these days, since ESM are gradually gaining momentum as the official standard for code reuse in the ecosystem.

Webpack also supports loaders, which help it decide how to handle, bundle, and process non-native modules or files. It is important to point out how webpack treats loaders. Loaders are evaluated and executed from bottom to top. So therefore, the last loader is executed first and so on and so forth, in that order.

In this post, our major focus is on how webpack transpiles or handles ECMAScript modules.

Using loaders for transpilation

Loaders transform files from one programming language to another. For example the ts-loader can transform or transpile TypeScript to JavaScript. Usually, we use loaders as development dependencies. For example let’s see how we can make use of the ts-loader.

To install, we can run the following:

npm install --save-dev ts-loader

We can then use this loader to instruct webpack to properly handle all the TypeScript files in our source code for us. See a sample webpack.config.js file below.

module.exports = {
  module: {
    rules: [
      { test: /.ts$/, use: 'ts-loader' },
    ],
  },
};

Here, we as we have mentioned, we are telling webpack to handle all file paths ending with a .ts extension and transpile them down to JavaScript syntax that the browser and node runtime can understand. This means that loaders can also run in the Node.js environment and, therefore, also follow the module resolution standard.

Loader naming conventions

The general way to name loaders is consistent, as loaders are named with their name and a hyphen — usually as xxx-loader. For example, babel-loader, ts-loader, and so on.

For our use case, we are most especially interested in ESNext or babel-loader, a community built and supported loader currently in version 7.16. More information on loaders can be found in the webpack documentation.

Some webpack core concepts

In order to understand how webpack works, this section covers some high-level concepts readers should be aware of.

Like we mentioned earlier, webpack uses a dependency graph, which means that it recursively builds a relationship that includes every module an application needs or depends on, then bundles all of those modules into an output file that’s ready for use. This means that webpack needs to have an entry point — and indeed it does.

In setting up our webpack configuration, the entry point is the beginning of the filepath webpack checks before it starts building out an internal dependency graph for our application. Note that webpack also supports multiple entry points for our application.

module.exports = {
  entry: ['./path/to/my/entry1/file1.js', './path/to/my/entry2/file2.js']
};

To add multiple entry points, we can use an array instead. In webpack v5, we can now have an empty entry object. This allows us to make use of plugins when adding entries.

After webpack builds out the dependency graph internally and completes the bundling process, it needs to output the bundle to another filepath, which we then need to serve.

This is what the output property does. It tells webpack which path to emit the bundles it has created and how the files are named.

const path = require('path');

module.exports = {
  entry: './path/to/my/entry/file.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'my-first-webpack.bundle.js',
  },
};

Beginning with v4.0.0, webpack does not require a configuration file to bundle your project, although it does assume that you have defined the entry point to your application with the folder and filepath as src/index.js, and that the output is bundled into the dist/main.js folder path, minified, optimized, and ready for production.

Setting up webpack and Babel

There has been full support for using the native ES2015 module syntax. This means that we can use import and export statements without relying on external transpilation tools or dependencies like Babel. However, it is still recommended to have Babel configured, just in case there are other, newer ES2015+ features that webpack has not yet taken into consideration.

Based on the module formats in place, webpack checks the nearest package.json file and enforces the appropriate recommendations. When using webpack to bundle our code it is usually advisable to stick to a single module syntax to allow webpack properly handle the bundled output in a consistent manner and hence prevent unwanted bugs.

To get started, we need to ensure we have the webpack CLI installed on our machines. We can then make use of the init CLI command to quickly spin up a webpack config based on our project requirements. We can do so by simply running npx webpack-cli init and responding to the prompts appropriately.

Webpack prompts, part 1

Webpack prompts, part 2

Now, we need to compile our ES2015 code to ES5 so that we can use it across different browser environments or runtimes. For this, we’ll need to install Babel and all of its webpack-required dependencies.

Let’s go ahead and install the following:

To install, we can run:

npm i webpack webpack-cli webpack-dev-server @babel/core @babel/preset-env babel-loader rimraf  -D

At the end of the installation, our package.json should look like this:

{
  "name": "webpack-demo",
  "version": "1.0.0",
  "description": "A demo of webpack with babel",
  "main": "dist/bundle.js",
  "scripts": {
    "build": "node_modules/.bin/webpack --config webpack.config.js --mode=production",
    "watch": "node_modules/.bin/webpack --config webpack.config.js --mode=development -w",
    "prebuild:dev": "rimraf dist"
  },
  "author": "Alexander Nnakwue",
  "license": "MIT",
  "dependencies": {},
  "devDependencies": {
    "@babel/core": "^7.16.0",
    "@babel/preset-env": "^7.16.4",
    "babel-loader": "^8.2.3",
    "rimraf": "^3.0.2",
    "webpack": "^5.64.3",
    "webpack-cli": "^4.9.1",
    "webpack-dev-server": "^4.5.0"
  },
  "type": "module"
}

Note that we have our dependencies pinned to allow for consistency when we run our application in the future.

The @babel/preset-env package will transform all ES2015–ES2020 code to ES5 or basically any target environment we specify in the target options. We usually need it when we intend to set a particular target, which then allows Babel to target that particular environment. But it is also possible to use without setting a target, but comes at a cost of larger bundle sizes.

This package basically checks the target environment specified against its own internal mapping, and then compiles a list of plugins it passes to Babel for code transpilation. This results in smaller JavaScript bundles.

Therefore, if we do fail to specify a target, the outputted code size would be larger because, by default, Babel plugins groups ECMAScript syntax features into a collection of related features. More details on this can be found in the documentation.

Even better, with an option for smaller bundle size and larger performance gains, we can make use of babel/preset-modules, which will be eventually merged into @babel/preset-env core.

Configuring the webpack.config.js file

Now, let us proceed to configure our webpack file. Go ahead and create a new webpack.config.js file in the root of our project.

import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));

export default  {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, './dist'),
        filename: 'bundle.js',
    },
    experiments: {
        outputModule: true,
    },
    plugins: [
       //empty pluggins array
    ],
    module: {
         // https://webpack.js.org/loaders/babel-loader/#root
        rules: [
            {
                test: /.m?js$/,
                loader: 'babel-loader',
                exclude: /node_modules/,
            }
        ],
    },
    devtool: 'source-map'
}

In the config above, we created a polyfill for __dirname variable. Also, we have set the field outputModule to be true, which is necessary if we intend to use webpack to compile a public library meant to be used by others. The babel-loader loads ES2015+ code and transpiles it to ES5 using Babel.

As you can also see in the config file, we have a module property, which has a rule property that contains an array for configuring the individual loaders we may need for our webpack configuration.

Here, we have added the webpack loader and set the needed options as per our project requirements.

Note that the test option is a regular expression matching the absolute path of each file and checks for files extensions. In our above example, we are testing whether our file ends with either a .mjs or .js extension.

Using (and not using) the .babelrc file

Now, we can configure Babel by creating a .babelrc file, also in the root of our project. The file content is shown below:

 {
    "presets": [
      [
        "@babel/preset-env",
        {
          "targets": {
            "esmodules": true
          }
        }
      ]
    ]
  }

Below is the output of running our application locally in development with the npm run watch command:

The output of our npm run watch command

In the case we do not want to make use of the .babelrc file, we can also add the presets in an options object inside the rules array, like so:

module: {
  rules: [
    {
      test: /.m?js$/,
      exclude: /node_modules/,
      use: {
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env', "es2015", "es2016"],
        }
      }
    }
  ]
} 

Setting presets and adding plugins

We need to set the presets so that the ES2015 features in our code can be transformed to ES5. In the options array, we can also add the plugins we want to add to our configuration.

For example, we can add this line: plugins: ['@babel/plugin-transform-runtime'], which installs a Babel runtime that disables automatic per-file runtime injection to prevent bloat.

To include this in our code, we need to install them by running:

npm install -D @babel/plugin-transform-runtime

We must also add @babel/runtime as a dependency by running npm install @babel/runtime.

Also note that in the rules object, we are telling webpack to exclude files in the node_modules folder with the exclude property. This is so that we have a faster bundling process as we do not want to bundle our node_modules folder.

There are also cases where we want webpack to handle ES 2015 modules. To allow this, we need to use a plugin called the ModuleConcatenationPlugin. This plugin enables some form of concatenation behavior in webpack called scope hoisting, which is a feature made possible by the ESM syntax. By default, this plugin is already enabled in production mode and disabled otherwise.

Conclusion

In order to avoid compatibility issues, we need to transpile our code from ES2015 to ES5. This is where webpack comes in. Webpack bundles our code and outputs a transpiled version down to the target as specified in the configuration file.

In our webpack configuration file, module rules allow us to specify different loaders, which is an easy way to display loaders. In this post, we have made use of just the Babel loader, but there are many other loaders we can also use in the ecosystem.

In terms of improvements and changes in the latest webpack release, v5, there is now out-of-the-box support for async modules. As the name implies, async modules are Promise-based and therefore do not resolve synchronously. Importing async modules via require() will now also return a Promise that resolves to their exports.

Also, in the Node.js ecosystem, the export and import fields are now supported in the package.json file. Finally, with the latest version, the minimum-supported Node.js version has been bumped up from v6.0 to v10.13.0, which is equally a LTS version.

The full demo source code can be found on my GitHub.

200’s only Monitor failed and slow network requests in production

Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third party services are successful, try LogRocket. https://logrocket.com/signup/

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.

LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. .
Alexander Nnakwue Software engineer. React, Node.js, Python, and other developer tools and libraries.

One Reply to “How to transpile ES modules with webpack and Node.js”

Leave a Reply