Building NPM package in 2021
It is pretty standard for those in the JavaScript world that dynamic and revolutionary changes in this area are regular. Changes are often, but the support of the major browsers and Node engine was not so active till now.
We can agree that this is quite normal because the work on all the engines in a very dynamic environment requires time and many iterations. And all that backward compatibility is a sort of pain. Still, I guess it's ok, and it is good in the process.
In the JS world, we had at least two significant milestones. The first one was EcmaScript5, and the next was EcmaScript6, also known as ES2015, which is IMO a better naming convention, especially since we have pretty regular updates from this version. So I will use that naming convention. It is much more descriptive and will help when describing how important was ES2015 and how much time had to pass to support built-in modules and all other cool features fully.
At the time of writing this, it is 2021. Not so long ago, the Ecma General Assembly accepted the new ES2021 standard and features, but let's focus on ES2020 in general and the preparation process of the NPM packages for modern browsers and Node.
TLDR
The article is not about the history of JavaScript or the history of the modules system. Of course, it was mentioned, but the main goal is to remind and indicate that it is time to start creating JavaScript tools in a little bit different way than it was since 2015. Is it possible now? Let's see.
Modules are the key
JavaScript language has had sort-of-modules support in many different forms for a long time, mainly using third-party libraries. It was essential because it helped to build packages and modules. We still use CommonJS in Node and - maybe less - all variations of AMD/UMD.
Do you use Babel? I bet you do (even if you don't know that). Do you use Rollup for NPM packages? I bet you do. If not, you probably use something similar. That's the point - tooling, not a strict language. Do you even remember Traceur, 6to5 and esnext?
All that stuff was ok, scripts in the browser and CommonJS in Node. So what's the deal, right?
I remember the time couple of months ago I started to write my simple Node CLI tool. Static site generator called Harold. I was aware that I could do this in two ways. I can incorporate some transpilers, bundlers, and all that stuff or use standard and supported CommonJS. I chose the second because it was more natural and straightforward to write the code without any additional configuration. It is simple. Simplicity and obvious patterns are fundamental when it comes to programming language. CommonJS is a transitional solution, and it is perfect, but it is essential that now I can write the same using modern JavaScript language, and I don't have to think about third-party tools.
Let's pause here. Yes, You can write all that code you had written already the same way, without any third-party libraries, but with some exceptions. Let's dig further.
I want to write an NPM package without bundler and transpiler.
I want to use the language and write a tool for browser or Node backends. Simple, right? Just it, let's see some code first. After that, it will be simpler to show some concepts.
In my GitHub repo, I have a simple browser-based package here: Smooth Scroll Top It uses no transpilers, no bundlers, well let's say that TypeScript is there because of other reasons, not used as a compiler. Typescript is a missing puzzle of the JavaScript ecosystem, so I would like to have it in the package. It can transpile your code, but it is not required here. I set up the target to ES2020.
Main assumptions
For this package, I assumed a couple of things:
- I won't use Rollup or any other bundler
- I won't use Babel or any other transpiler (well, Typescript, but not treated as transpiler here)
- I will write the code according to ES2020 standard
- It should work in the browser using module import and should be accessible using Skypack
- It should work in React app scaffolded using Create React App (it required some compromises)
How to configure your package to meet these expectations?
Because I want to use Typescript, I will need to run the build process, which will be based only on tsc.
I also added the terser
minification process in my example project, but this is optional. For this article, I won't write about it.
You can check my tsconfig.json file here. Remember about adding "module": "ES2020"
.
Here is what we need in package.json (example of mine):
"type": "module",
- required for proper interpretation of .js files"exports": "./build/index.js",
- tels which file is our main export, we should use it insteadmain
orbrowser
"browser": "./build/index.js",
I leftbrowser
because of React, but I'll write about it later."engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0"},
- this will alow to check proper versions of Node which supports ES2015 modules
How to use it in the browser
Because browsers support ES2020, we can tell them that we will use a module-based code. I also used an import from Skypack, which is a modern CDN. It serves optimized code, and it is even possible to use the code in Deno land. I don't want to focus on Skypack here. You can find a lot on the net. What is essential - Skypack serves ES2020 code which then we can import in the browser like:
<script type="module">
import SmoothScrollTop from 'https://cdn.skypack.dev/smooth-scroll-top';
(...)
</script>
You can still import files from the local file system using relative paths. It is just an example that when you write modular code with ES2020 standard in mind, you can import it from CDN right after publishing it on the NPM registry.
As simple as writing compliant ES2020 code and run it in the browser using ES2015 modules. No bundlers and transpilers are required. Check out the repo of my example package to see how all is connected there: smooth-scroll-top.
Does it still work with React projects?
I had some problems with React project scaffolded using Create React App tool. It seems that the support for ES2020 isn't ready at 100% yet. So what I had to do was to leave the "browser": "./build/index.js",
entry in my package.json file. There was also a problem with the optional chaining operator when using the package in Node 16.4.0. Babel has some different default configurations. At least, I think this is the case. So I just removed optional chaining operators from the code. So after these changes, the package works well with React-based applications.
What when I want to use some third-party npm packages?
If you are sure that they support ES2015 modules (or better that they meet ES2020 expectations), you should be able to import them. The problem is that most of the modules are not ready yet. For example, in the browsers, they are probably some AMD/UMD. And in Node, they are probably CommonJS modules. So the best solution would be to use some bundler, unfortunately.
It all depends on what you need to do.
Let's assume that you need to prepare an NPM package only for browsers, and you want to reuse some packages already published in the NPM registry. For example, you are building a date picker, and you need some time manipulation tooling for that. Unfortunately, the third-party tools which you want are written using CommonJS and AMD module patterns. What then?
If your tool has to be used in a browser, for example, imported from Skypack, and you still need this third-party module from the NPM registry, the best would be to bundle it with your code using, for example, Rollup. You can do this using just two plugins and Rollup CLI. You need @rollup/plugin-node-resolve to be able to attach previously imported code from node_modules to your output file after building using Rollup. You also need @rollup/plugin-commonjs to transform all CommonJS code to ES standard code.
When you install these Rollup dependencies, then as a build script, you can run a one-liner command like:
tsc && rollup build/index.js --file build/index.js --format es --plugin @rollup/plugin-node-resolve --plugin @rollup/plugin-commonjs
I assumed here that your tsc
would write to the build
directory.
Summary
ES2020 is supported in all modern browsers and with the newest Node versions also on the backend side. I think we should slowly move further and start writing clear and modern JavaScript code without bundlers and transpile processes. Typescript is an exception ;) Of course, it is simpler when you don't have to support legacy code, and you write smaller tools, but I think we should at least try.
I plan to rewrite some of my tools soon too. We will see what will come from that. Follow me on Twitter and Github for more updates.