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.
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.
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.
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.
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.
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.
The transpiler loader also comes with a resolve
Hook that tells the runtime how to handle unknown file types.
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.
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.
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.
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.
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.
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.
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.
webpack.config.js
fileNow, 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.
.babelrc
fileNow, 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:
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"], } } } ] }
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.
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.
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.
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. Start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
One Reply to "How to transpile ES modules with webpack and Node.js"
We use clear JavaScript, but it’s possible only for small projects. But using of webpack technique for large apps or websites makes life easier!
Thank you for the article!