ES Modules are the future of JavaScript, but unlike many other es@next features that developers have raced to take advantage of mainly thanks to build tools like babel, working with ES modules alongside of existing NPM modules is a lot harder to get started with.
The purpose of this tutorial is to provide a thorough set of examples for different ways you can approach writing ES modules, without losing interop with the vast library of mostly commonjs modules that exist on NPM today.
Before getting started, it’s important that you have a solid understanding of the differences between the ES module and CommonJS formats. If you haven’t already, please check out Gil Tayar’s excellent blog post before continuing.
Native ES Modules in NodeJS: Status And Future Directions, Part I_ES Modules are coming to NodeJS. They’re actually already here, behind a --experimental-modules flag. I recently gave a…_medium.com
Goals
Every approach in this tutorial must satisfy the following requirements:
- Generate a valid npm module
- Support the same consistent functionality
- Be usable from both Node.js and browser environments
- Import at least one existing commonjs module from NPM
- Import at least one es module source file locally
- Include at least one unit test
Functionality
The functionality of our example NPM module is a bit contrived, but it should touch on all the major pain points, and trust me, there are a lot of them…
Every approach will define an NPM module with a single default export,
async getImageDimensions(input)
, that takes in an image and returns its{ width, height }
.
To show how you can bundle modules with slightly different semantics for Node.js and the browser:
- The node version supports
input
as astring
that can either be a local path, http url, or data url. - The browser version supports
input
as astring
URL or anHTMLImageElement
.
Both versions return a Promise
for { width: number, height: number }
.
Approaches
We’ll start with a naive ES module and work our way through a series of increasingly complex example approaches, all of which are intended to define the same, basic module. You can follow along with the source code for each of the 7 approaches on GitHub here!
1. Naive Approach
Our first approach is the most naive possible use of ES modules supporting our functionality. This approach is broken and provided only as an example starting point. Here is core of the Node.js code:
Approach #1 index.js
Approach #1 load-image.js
The functionality is meant to be pretty straightforward, but it’s important to understand because all the following examples will be using the exact same code. Here is the corresponding browser code:
Approach #1 browser.js
Approach #1 browser-load-image.js
In this approach, we use normal .js
file extensions for es modules and no transpilation.
It is relatively simple and straightforward but breaks several of our module goals:
- Not usable from node.js because its
main
field is an es module whereas it should be a commonjs file. - Not usable from the browser via webpack or rollup because its
browser
field is an es module whereas it should be a commonjs file. - The only advantage of this approach is its simplicity, and this may be good enough if you are just working on private modules.
- Warning: unless you are using strictly local or private modules, we strongly encourage you not to use this approach in practice. It is meant as an example of what not to do when transitioning from commonjs to ES modules, and if you publish a module publicly using this approach, the JavaScript Gods will find and shame you.
- Unfortunately AFAIK, there is really nothing built into the npm ecosystem which prevents you from publishing broken modules like this, although as ES modules gain popularity over the coming years, I believe this will be addressed.
2. Pure Babel Approach
This approach uses babel with babel-preset-env to transpile all Node.js and browser source files into dist/
.
The core of this approach is the build script in package.json:
Approach #2 excerpt from package.json
- Source files end inÂ
.mjs
- Relatively simple to setup
- Babel transpiles all source files to ES5 and commonjs
- Tests are run on the transpiled source, which make debugging slightly harder
- Currently, our
main
andbrowser
are commonjs exports that supportnode >= 4
(or whatever we specify in our babel-preset-env config), whereas themodule
export is an es module that supportsnode >=8
due to its usage ofasync await
. - Unfortunately AFAIK, package.json
engines
doesn't support specifying thatmain
supports a certain node version whereasmodule
supports a different module version, and I'd go so far as to say this is a bad practice. - To get around this, we could specify the minimum node version to be
node >= 8
like we did here or add a second babel step which transpiles the node version to an esm folder, although I find this somewhat clunky.
3. ESM+Rollup Approach
This approach uses esm for Node.js and babel+rollup to compile browser source files.
esm is amazing!
Approach #3 main.js node commonjs entrypoint which loads the esm module.mjs via esm.
Approach #3 ollup.config.js for compiling the browser version to bundled commonjs.
- Source files end inÂ
.mjs
(only exception is the commonjs entrypointmain.js)
- Supports all three targets
main
module
, andbrowser
- The only target that is compiled is
browser
, making debugging the Node.js version easier - Also note that we’re requiring esm in the ava unit test config
4. ESM+Webpack Approach
This approach uses esm for Node.js and babel+webpack to compile browser source files. It’s the same as the previous approach, except it switches out rollup for webpack.
Approach #4 webpack.config.js for compiling the browser version to bundled commonjs.
- Source files end inÂ
.mjs
(only exception is the commonjs entrypointmain.js)
- Supports all three targets
main
module
, andbrowser
- The only target that is compiled is
browser
, making debugging the Node.js version easier - Also note that we’re requiring esm in the ava unit test config
5. Pure Rollup Approach
This approach uses babel+rollup to compile all Node.js and browser source files. This approach takes the rollup config from approach #3, and takes it one step further by having separate rollup configs for the browser and Node.js targets.
Instead of including the redundant configs, check out the source directly here.
- Source files end inÂ
.mjs
- Supports all three targets
main
module
, andbrowser
- All three targets are compiled via rollup, with Node.js and the browser having two separate configs
- This is our first module to support
node >= 4
(or whatever we specify in our babel-preset-env config) instead ofnode >= 8
- Source maps are generated along with the compiled targets
6. Pure Webpack Approach
This approach uses babel+webpack to compile all Node.js and browser source files. This approach takes the webpack config from approach #4, and takes it one step further by having separate webpack configs for the browser and Node.js targets.
WARNING: this approach is currently a broken WIP, and its exports do not behave correctly. All other approaches work as intended.
Instead of including the redundant configs, check out the source directly here.
- Source files end inÂ
.mjs
- Supports all three targets
main
,module
, andbrowser
- Unfortunately, webpack does not support outputting ES module targets (issue).
- The
main
andbrowser
targets are compiled, but themodule
target is unable to be compiled due to this issue. - Supports
node >= 8
, whereas the rollup version is capable of supportingnode >= 4
by compiling themodule
target as well. - Unless you have a good project-specific reason to use webpack over rollup, I would strongly recommend using rollup to bundle ES6 module libraries (at least until this webpack issue is addressed).
7. TypeScript Approach
This approach uses typescript to transpile all Node.js and browser source files.
TypeScript Approach #7 index.ts
TypeScript Approach #7 load-image.ts
- Source files end inÂ
.ts
- Supports all three targets
main
module
, andbrowser
- Two compilation passes are necessary, one for targeting
commonjs
and one targetingesm
- Currently, commonjs users have to add default to require statements:
require('npm-es-modules-7-typescript').default
. If you know how to prevent this, please let me know. - Resulting module supports
node >= 4
(or whatever we specify in ourtsconfig.json
) instead ofnode >= 8
- Note that I’m fairly new to TypeScript, so please let me know if I’m doing anything awkward.
Overall, using TypeScript feels like the cleanest and most future-proof approach if you want to maximize compatibility.
Recommendations
Whew, that was a lot of JavaScripts…
Now to summarize what I’ve learned from creating this breakdown and my practical suggestions for writing your own next-gen NPM modules:
- If you only care about Node.js compatibility and not browser usage and want something extremely straightforward, then use either use standard commonjs for now or use esm as in Approach #3 without the rollup stuff.
- If you only care about Node.js compatibility and not browser usage but also want to support older versions of Node.js, then use the Pure Babel Approach #2.
- If you only care about browser usage, you can likely get by writing ES modules without complicating things with transpilation, because any modern frontend toolchain will take care of building things for you.
- If you care about both Node.js and browser compatibility, then use either the ESM+Rollup Approach #3 or the ESM+Webpack Approach #4, depending on your preference between rollup and webpack. IMHO, Rollup is a strictly better choice for libraries over webpack because it has a more narrow focus on handling libraries whereas webpack tries to do everything.
- If you care about both Node.js and browser compatibility and want to support older versions of Node.js, then use the Pure Rollup Approach #5.
- Finally, if you want to be fully future-proof and increase the quality of your code via static analysis, I’d highly suggest using the TypeScript Approach #7, which handles all the previous use cases and then some.
Conclusion
I hope you’ve found this guide helpful! You can find all the source code, including usable modules for the 7 approaches on GitHub here.
For more info on this topic, check out these great resources:
- Native ES Modules in NodeJS: Status And Future Directions by Gil Tayar
- ES modules: A cartoon deep-dive by Lin Clark
- A Minimal Setup for Babel-based npm packages by Axel Rauschmayer
- How to Write and build JS libraries in 2018 by Anton Kosykh
Have any approaches or suggestions that I left out? Let me know by sharing them in the comments! ❤️
Before you go…
If you liked this article, click the 👏 below, and share it with others so they can enjoy it as well.