How to write and build JS libraries in 2018
- Why using bundlers and transpilers in libraries may turn into a problem?
- How to build libraries correctly?
- Why I said not to use imports/exports? Do you really need it?
I will start from one moment that isn’t quite relevant to the topic of the article but is important —why do we need to build libraries?
If we’re using
require, we don’t have to build library at all (because we’ve read the previous post and don’t use ES6 features in libraries and there are no need to use bundlers/transpilers/etc at all).
Situation changes when we use ES6 modules that are still not supported in Node.js. As I said before, you can use ES6 modules only in Node v9 with
— experimental-modules flag. Does someone of backend developers already uses it? I don’t think so. And we also have these:
It means that we should replace our imports/exports with legacy require/module.exports. And we need some tool for that.
How does typical developer build his code?
- Install rollup
- Install babel, rollup plugins to locate modules, resolve ‘em, some another features
- Build with umd format
- Oh, wait, why so large???
First mistake — using umd build
UMD (Unified Module Definition) means that you can use one bundle in browser (as
<script>) and with
require. But it’s not perfect as you think — it adds more code to your bundle.
Imagine that we have some module (e.g. EventBus). So we want to use it:
And we want to use it in our Node.js application. So, let’s use rollup to make UMD build:
And… Here’s what we have:
There are 2 things that we don’t want to see in nano-library:
- Red: unnecessary code. In Node.js you can just use
require, in loadable
<script>you just pass it to
- Blue: because UMD build also should work in browser, we need to add all dependencies to a bundle. We can’t leave
var EventBus = require('eventbus')like it was in source.
UMD looks nice, but simple nano-libraries is not a case for it.
Create separated IIFE and CJS builds instead of UMD
IIFE build is quite clean and doesn’t contain unnecessary code. Also you can uglify this one and it’ll be much smaller.
CJS build is OK too but we still have blue problem.
Why is it bad? I will show you.
Second mistake — resolving dependencies in CJS build
There is no point to have source code of your dependencies in your library. You can just leave
import, add external dependencies to
dependencies of package.json. And it will be fine.
But what happens when you build your library?
Imagine that we have two separated files in our library (or two different libraries) that have common dependency. I’ll just duplicate my previous example:
Then, let’s build it:
Now, we have two bundles. Both of them have EventBus:
Do you think that they will be merged in our application? So, let’s check it. I created file with both of files imported:
Now our bundle has two
EventBus‘es but they are 100% equal!
Note: if you think that it happens only because of CJS, you can repeat with ES output and check it again.
Now, let’s try to build our file without resolved dependencies. I’ll just replace import files to our non-builded variants.
That’s nice. We don’t have duplicates and it’s fine! Why? Because rollup is smart enough to not paste similar dependencies twice. We see equal imports (or requires) in our code so no need to paste it for each import.
Note 1: it also affects internal library files. Avoid resolving similar requires of internal files of your library, because it you will have duplicates too.
Note 2: if you have question “How it works when two libraries needs different versions of one library”, you can read how NPM solved versions conflicts.
Just don’t use imports
If you haven’t need in it, you can use
require and present your library “as is”.
Set all dependencies as external
Rollup can mark dependencies as external. It means that your dependencies won’t be bundled and rollup leaves
require “as is”
But it makes you add every imported files to externals manually. If you have a lot of imports and files it may be boring.
Bonus trick: if you want to add all package dependencies as external, you can just get ’em from package.json:
So, let’s look at our bundle:
Almost OK, but we have interop there. What is interop? Better read this one instead of my words:
So we can disable it:
And it looks OK:
Note: Also you should be careful with paths. If you will use absolute paths (like above) in your imports and change folders structure, you will fail.
If you still read this, you can use babel instead of rollup. It will just replace your
export to CJS analogue.
Unlike rollup, babel doesn’t make you to add every file and every external dependencies into a config. It just takes files from folder1 and stores the result in folder2.
There is official
babel-plugin-transform-es2015-modules-common-js (if you think it’s longest package, you can see this one)
Then transpile all your source files:
Output is not nice but we already have
require instead of source code:
Two weeks ago I found better solution that strips all potential unnecessary code. It’s
babel-plugin-transform-es2015-modules-simple-commonjs (like previous one, but with word
Replace our .babelrc:
And see the result:
Note: the last one plugin has some caveats:
But I think it’s not so problematic. Just keep it in mind.
The last question is:
Do I really need ES6 modules?
I’ve done a lot of work and read a lot of posts to come to last variant. And I think that profit of using ES6 modules in many cases is much less than work I did.
I won’t write about better dead-code-elimination because:
- This is an obvious fact
- In context of nano-libraries you might not need to use tree-shaking and export several variables
I’ll just say that Webpack wraps every module with its require function. But with
ModuleConcatenationPlugin provides more optimizations for ES 6 modules. You can read more in official docs.
You probably are tired of words so I’ll just post a screenshoot:
As you can see, nanoclone was concatenated with index.js into one module (because it has ES “module” entry in package.json) and it saved some bytes for us.
I think that using ES6 modules makes sense only when the library’s primary target is frontend. If your library is more targeted for backend, just use
require and don’t do unnecessary work. So…
When you create a library only for Node.js
require instead of ES6 modules.
If you want to reduce the size, you can split your library to several files, moving optional parts, utils and something else out of library core.
When you create a library for modern frontend only
Use ES6 modules and don’t care about. Don’t build it. Webpack will do all work for you.
Just besure that all external dependencies are included to your package.json.
When you create cross-environmental library
- For modern frontend: write a library with ES 6 modules.
- For Node.js: follow my instructions to configure rollup or use babel with
babel-plugin-transform-es2015-modules-simple-common-jsto convert import/export to require/module.exports
- For browsers: use rollup and create IIFE build. And more:
Here you should also add rollup-plugin-uglify to minify your bundle.
Also you might need rollup-plugin-commonjs to convert CJS modules to ES6 imports (rollup needs this plugin to
import cjs modules).
Also you might need rollup-plugin-node-resolve to resolve external dependencies.
Also your package.json should have fields:
module— path to variant with ES6 modules (1st item)
main— path to CJS build (2nd item)
And don’t forget to add to
README.md link to your library on CDN (e.g. https://firstname.lastname@example.org/dist/index.js).
P.S. You don’t need to upload your library, just
npm publish and it will be available on unpkg.
I hope it was interesting and very helpful for you. Thanks for reading :)