How to write and build JS libraries in 2018

Extended guide about building JavaScript libraries and how to stay in size-limit

Before start, I recommend you to read my previous article about creating extra small JavaScript libraries.

Today’s questions:

  • 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?

Introduction

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:

Interesting fact — they are named as chemical element

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:

Don’t forget to add the name field

And… Here’s what we have:

Looks like there are a little more than 9 rows

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 window. Overheaded.
  • 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.

Solution:

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 require and 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:

Building it:

Result:

Ba-dum tss

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.

Result:

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.

Possible solutions:

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 import or require “as is”

Example from rollup official docs

But it makes you add every imported files to externals manually. If you have a lot of imports and files it may be boring.

Don’t see on good/bad names. It was for previous examples

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:

Great! (But it could also export without variable, but 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.

It was OK in green file, but red one has another root

Alternative solution

If you still read this, you can use babel instead of rollup. It will just replace your import/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)

Create .babelrc

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 simple)

Replace our .babelrc:

And see the result:

My congratulations!

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:

  1. This is an obvious fact
  2. 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.

Also Mateusz Burzyński created repository with demonstration of nanoclone bundles difference for CJS and ES build with Webpack hoisting.

Thank you, Mateusz

In conclusion

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

Just use 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

  1. For modern frontend: write a library with ES 6 modules.
  2. For Node.js: follow my instructions to configure rollup or use babel with babel-plugin-transform-es2015-modules-simple-common-js to convert import/export to require/module.exports
  3. 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://unpkg.com/nanotween@0.7.0/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 :)