How to Create JavaScript Libraries in 2018 (Part 1 - Basic)

Mateusz Burzyński in Programming, Tutorials, on February 20, 2018, Comments

How to Create JavaScript Library

This article is inspired by a great piece by Anton Kosykh, How to write and build JS libraries in 2018. It has some great visual examples which are definitely worth to check out.

As a frontend engineer, I have a slightly different perspective on the topic of libraries building. I’ll try to explain how to squeeze the most out of the modern build toolchain, that is:

  1. how to configure the tools,

  2. how to create an isomorphic library & more (in part 2),

  3. how to prevent the unused parts of the application from landing in clients’ applications (soon).

CJS & ESM explained

CJS (a.k.a CommonJS) is a module format popularized by node.js ecosystem, where ESM (ECMAScript Modules) is a module format already standardized as part of the JavaScript language (since ES2015 specification). While you might not notice much of a difference in most scenarios besides other syntax used to write them, their design is different.

ESM modules have static structure (while CJS is dynamic), this means that you cannot in example:

  • import/export names based on runtime values
  • import/export conditionally
  • import/export things after module initialization

This gives us important traits - module’s shape cannot change, it is known statically (without running the code) and initialization order can be determined statically too. Those facts are leveraged by bundlers to produce more optimized code structures.

An unknown land

Setting up the tools for a library seems to be such a basic task that you may be surprised that it has numerous nuances. In fact, even the authors of well-known libraries often don’t do this right, and there is no single exhaustive go-to source.

In this piece, we’ll get practical and create a dummy project step-by-step to discuss the best library building practices. Let’s go!

Getting started

Let’s start our project with creating src/index.js, src/cube.js & src/square.js files in a sample repo:

// index.js
export { default as cube } from './cube.js';
export { default as square } from './square.js';

// cube.js
export default x => x * x * x

// square.js
export default x => x * x

So far so good. What now?

The most popular opinion is that it’s best to publish the code with ES5 syntax, so we’ll need to transpile our library. We’ll use Babel for this — there are other solutions available (e.g. Bublé), but Babel has the most features and the whole plugin ecosystem available.

We could use babel-cli (babel src --out-dir dist), but generally speaking, Babel works with single files, not with whole projects. Of course, we could use it to transpile a catalog of files, but this would result in creating a copy of the catalog tree.

If a library consists of more than one file, a good practice is to use a bundler to create a so-called flat bundle (all project files merged into one). The obvious choice here is Rollup. Why? Because whenever possible, this tool doesn’t add any extra code, runtime wrappers, etc. Thanks to this approach:

  • the final files are lightweight (so that less code is shipped with an application which uses this library),

  • each file creates just one scope, so that static analysis is much easier (which matters for tools such as UglifyJS).

Rollup and Babel have different objectives, but they can be freely used together one project.

Installation

To install Rollup and Babel, run the following command:

npm install --save-dev rollup babel-core babel-preset-env rollup-plugin-babel

Note that this installs also babel-preset-env. Why would we need it? It enables the necessary Babel plugins on the basis of the environment definition that we may pass to it (by default it transpiles to ES5). Moreover, if we configure it properly, we can use only selected transforms, which is useful when, for example, our project doesn’t have to work in older browsers and we don’t have to transpile everything.

Other useful plugins are babel-plugin-transform-object-rest-spread and babel-plugin-transform-class-properties, so let’s install them right away:

npm install --save-dev babel-plugin-transform-object-rest-spread babel-plugin-transform-class-properties

Configuration

Now when we have everything installed, we can start the configuration process. For full control, we will use a pure JavaScript configuration file. Babel 6 doesn’t support .babelrc.js files out of the box (upcoming Babel 7 does, though), but here’s a workaround - just add these two files:

.babelrc

{ "presets": ["./.babelrc.js"] }

.babelrc.js

const { BABEL_ENV, NODE_ENV } = process.env

cosnt cjs = BABEL_ENV === 'cjs' || NODE_ENV === 'test'

module.exports = {
	presets: [
		['preset-env', { loose: true, modules: false }]
		'transform-object-rest-spread',
		'transform-class-properties',
	],
	plugins: [
		cjs && 'transform-es2015-modules-commonjs'
	].filter(Boolean)
}

Comments:

  • For safety reasons, it’s best to **disable module transpiling ** (with modules: false passed to the preset-env) until a script requires it (opt-in with NODEENV or BABELENV environmental variables).

  • Some tools support both ESM and CJS module formats, but work better with ESM. It’s easy to go overboard with transpiling here, but, on the other hand, if we don’t transpile all necessary modules, we will notice it right away. If we, for example, forget to activate CJS transform, the tool that requires CJS modules will crash with a bang.

  • Commonjs transform has loose option (read more here), but it can cause more problems than it solves, especially when we use namespace import (import * as mod from ‘./mod’).

The best approach is publishing different versions of a module for various use cases. A good example is package.json which contains two “entry point” versions: main and module. Main is the standard field used by node.js ecosystem and therefore it should point to a CJS file. Module field, on the other hand, points to an ES module file (transpiled exactly like the main “entry point” minus the modules). This field is mainly used by web app bundlers like webpack or Rollup.

Thanks to the static nature of ES module files, it’s easier for bundlers to analyze their structure and optimize the files using techniques such as tree-shaking (removing unused imports) and scope-hoisting (putting most of the code within a single JavaScript scope).

To configure these two fields, let’s add the following lines to our package.json:

"main": "dist/foo.js",
"module": "dist/foo.es.js",

Note: In some cases for better compatibility it’s best to omit file extensions and let each tool add them by itself. This will be discussed in next part of the article.

Now we will need a script to build everything for us:

"build": "rollup -c"

And a Rollup configuration file (rollup.config.js):

import babel from 'rollup-plugin-babel'
import pkg from './package.json'

const externals = [
  ...Object.keys(pkg.dependencies || {}),
  ...Object.keys(pkg.peerDependencies || {}),
]

const makeExternalPredicate = externalsArr => {
  if (externalsArr.length === 0) {
    return () => false
  }
  const externalPattern = new RegExp(`^(${externalsArr.join('|')})($|/)`)
  return id => externalPattern.test(id)
}

export default {
  input: 'src/index.js',
  external: makeExternalPredicate(externals),
  plugins: [babel({ plugins: ['external-helpers'] })],
  output: [
    { file: pkg.main, format: 'cjs' },
    { file: pkg.module, format: 'es' },
  ],
}

Comments:

  • Rollup is a bundler which merges all files and dependencies into one file, but we want to use it for a different purpose. We want it to build our library and leave our dependencies where they belong — in the dependencies. We do not want Rollup to inline dependencies’ source code into our output bundles — we want it to leave them as they are, referenced to with import statements.

  • makeExternalPredicate _keeps subdirectories and files belonging to our dependencies as external too (i.e. _downshift/preact or lodash/pick)

  • As mentioned above, Babel works with single files. In order do deduplicate some common runtime logic, it may insert a helper function into your files.

Here’s a sample helper function:

// input
const a = { ...b }
const c = { ...a }

// output
function _extends() {
  _extends =
    Object.assign ||
    function(target) {
      for (var i = 1; i < arguments.length; i++) {
        var source = arguments[i]
        for (var key in source) {
          if (Object.prototype.hasOwnProperty.call(source, key)) {
            target[key] = source[key]
          }
        }
      }
      return target
    }
  return _extends.apply(this, arguments)
}

var a = _extends({}, b)

var c = _extends({}, a)

_extends is precisely this: a helper function added by Babel. The problem is that Babel adds it to each file and we bundle our input source files into a single output file. Ideally, we’d like to have those helpers inserted only once into our bundle. That’s why we’ll use babel-plugin-external-helpers in our Rollup configuration file (nice thing is that it will warn us if we forget to do that).

The best thing? In the future, it won’t be necessary to add this file manually because rollup-plugin-babel compatible with Babel 7 will do this for you.

And that’s all! We have the basic use cases covered.

The next part of this article will cover more advanced usages and examples. Stay tuned!