Handling 3rd-party JavaScript with Rollup

How and when to leave JS out of the bundle

Sunday, Dec 3rd, 2017

Mixmax is a communications platform that brings professional communication & email into the 21st century.

This blog post is part of the Mixmax 2017 Advent Calendar. The previous post on December 2nd was about Mixmax’s new Sequence Picker SDK.

As of this writing, Mixmax has open-sourced dozens of packages in our primary language, JavaScript. The linchpin of our open-source strategy is npm, where we publish packages for both Node.js and, increasingly, the browser (hello React components!). Our bundler of choice, Rollup, makes it super easy to consume those packages client-side. But if you don't take care when configuring Rollup, you can end up publishing hundreds of kilobytes of unnecessary JavaScript to your users.

How Rollup Works

Rollup is a tool that lets you write your application using ES6 modules, even though you can't publish those modules directly to your users, because native support is only just starting to land in browsers. Rollup compiles your modules into a format that all browsers do understand—a single script file—by, essentially, concatenating files together (while reordering and renaming declarations to preserve scope).

ES6 modules go in…

// main.js
import { cube } from './maths.js';
console.log( cube( 5 ) );

// maths.js
export function cube ( x ) {
  return x * x * x;
}

And using a Rollup configuration like

export default {
  input: 'main.js',
  output: {
    file: 'bundle.js',
    format: 'iife'
  }
};

A single script (the "bundle") comes out:

(function () {
'use strict';

function cube ( x ) {
  return x * x * x;
}

console.log( cube( 5 ) );

}());

The only module names that Rollup understands out of the box are relative or absolute file paths, like ./maths.js in the example. This works just fine for your own code—but what about 3rd-party dependencies?

External Dependencies

Let's say you're getting started with React. Dropping the official "hello world" example into your project

import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.render(
  <h1>Hello, world!</h1>,
  document.getElementById('root')
);

(once you've set up Babel) will result in this warning:

(!) Unresolved dependencies
https://github.com/rollup/rollup/wiki/Troubleshooting#treating-module-as-external-dependency
react (imported by main.js)
react-dom (imported by main.js)

This happens because there's no file called "react"—that's the name of React's npm package. There are two ways of handling this with Rollup, as described by the troubleshooting link from the warning. Unfortunately, both Rollup and React recommend the wrong one.

Resolving modules from npm

If you've followed React's guide, you've installed react from npm. You can teach Rollup how to find this package within your project's node_modules directory using the rollup-plugin-node-resolve plugin. Since React exports a CommonJS module, you'll also need to convert that into an ES6 module using the rollup-plugin-commonjs plugin. Once you've installed these plugins, add them to your Rollup config file:

// rollup.config.js
import resolve from 'rollup-plugin-node-resolve';
import commonJS from 'rollup-plugin-commonjs'

export default {
  input: 'main.js',
  output: {
    file: 'bundle.js',
    format: 'iife'
  },
  plugins: [
    resolve(),
    commonJS({
      include: 'node_modules/**'
    })
  ]
};

There'll be no more warnings. But if you open bundle.js, you'll see something shocking: it contains the entirety of React and React DOM. That's 7000 LoC!

This occurs because, with the configuration above, React is no longer an external dependency: you've directed Rollup to bundle it alongside your application's local JavaScript. But there are critical differences between your application's JS and React's.

Why bundling 3rd-party dependencies can be a very bad idea

First, there's the size. React + React-DOM is over 100kb even when minified.

Second is that React and React-DOM aren't changing—until you upgrade their versions, that JS will remain exactly the same. But if you deploy React as part of your bundle, the browser can't know that. If you deploy several times a day, as Mixmax does, you'll blow your users' caches every time you do—forcing them to unnecessarily redownload hundreds of kilobytes of JS. And you'll be paying for that bandwidth.

So React, and other large third-party dependencies, should not be bundled alongside your application; they should be kept external. But where will they come from then?

Resolving modules from browser globals

Rollup provided a hint back when you were getting the "unresolved dependencies" warning: below that, there was a second warning

(!) Missing global variable names
Use options.globals to specify browser global variable names corresponding to external modules
react (guessing 'React')
react-dom (guessing 'ReactDOM')

By default, Rollup did the right thing: it assumed that your third-party dependencies were available as browser globals! (It's a shame that Rollup doesn't recommend this at its "unresolved dependencies" troubleshooting link.)

Let's look at the bundle Rollup generated (with the warnings):

(function (React,ReactDOM) {
'use strict';

React = React && React.hasOwnProperty('default') ? React['default'] : React;
ReactDOM = ReactDOM && ReactDOM.hasOwnProperty('default') ? ReactDOM['default'] : ReactDOM;

ReactDOM.render(React.createElement(
  'h1',
  null,
  'Hello, world!'
), document.getElementById('root'));

}(React,ReactDOM));

You can see that Rollup mapped browser globals called "React" and "ReactDOM" to variables called "React" and "ReactDOM". The latter are what you imported by writing import React and import ReactDOM. (The variable names don't have to be the same as the browser globals, but it's common.)

This is great! You can import 3rd-party dependencies as if they were part of your bundle, without their JS actually being part of the bundle.

To get Rollup to do this without the warnings, we just need to add the external and globals options to our configuration:

import babel from 'rollup-plugin-babel';

export default {
  external: ['react', 'react-dom'],
  globals: {
    'react': 'React',
    'react-dom': 'ReactDOM'
  },
  input: 'main.js',
  output: {
    file: 'bundle.js',
    format: 'iife'
  },
  plugins: [
    babel({
      presets: ['react']
    })
  ]
};

external tells Rollup "it's ok that you can't resolve these modules, don't try to bundle them but rather leave their import statements in place". globals then tells Rollup "here's how to resolve the import statements after all—to browser globals with these names". (I'm not sure why Rollup makes you specify both globals and external, to be honest. But you'll see in the "bundling your own npm packages" section below why you might want to use external and not globals. More info.)

As for loading the browser globals, you have your pick of CDN.

CDN?! What about npm?

There's a reason that React recommends using npm to manage client packages: it's simple. npm install react --save-dev is easier to remember and more consistent with the rest of a modern JS developer's workflow than wrangling a script tag. And the explicitness of import React from 'react'; is worlds better than magic browser globals.

But the developer experience can be almost as good with a CDN as with npm. Here's how:

First, you've seen how import statements look exactly the same regardless of how Rollup resolves the module. So you won't even know this is a browser global when you're developing; you only have to consider that once, when you configure Rollup.

Second, many CDNs nowadays ultimately source from npm (before caching the JavaScript on their own edge servers). So you'll be guaranteed to have the same library version on the CDN as on npm, with the files even addressed in the way they'd be loaded from your node_modules directory. React, for instance, recommends using https://unpkg.com/:

<script crossorigin src="https://unpkg.com/react@16/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>

At Mixmax, we like to use https://www.jsdelivr.com because it can load multiple npm packages in a single request, for fewer script tags and optimal compression, and even minify them for you!

<script crossorigin src="//cdn.jsdelivr.net/combine/npm/react@16.0.0/umd/react.production.min.js,npm/react-dom@16.0.0/umd/react-dom.production.min.js"></script>

By the way, these CDNs are freely available! So they're paying for your users to download React, not you.

When we do bundle from npm

All the above said, we're not total sticklers about loading third-party dependencies from a CDN. If the package isn't already available on a CDN, or is only a few hundred LoC, then we'll let it slide. The above advice is primarily for your largest dependencies.

We also bundle our own packages from npm! Speaking of which…

Bundling your own npm packages

This section applies to you if your application depends on your own npm packages, and those packages have a large 3rd-party dependency.

For example, say that you share the following React component between services:

// main.js
import React from 'react';

const Loading = () => (
  <div className='soft  flexbox  flexbox--column  loading'></div>
);

export default Loading;

When you publish this module, you do not want to bundle React, for the reasons described above. (It would be even worse to bundle React in a library, because then its copy would duplicate that loaded by the application!) But the fix is slightly different for a library than an application. In this library's Rollup configuration, we only want to specify external, not globals:

import babel from 'rollup-plugin-babel';

export default {
  external: ['react'],
  input: 'main.js',
  output: {
    file: 'bundle.js',
    // Also note 'es' not 'iife', since a library exports something, unlike an application.
    format: 'es'
  },
  plugins: [
    babel({
      presets: ['react']
    })
  ]
};

Now, in the resulting bundle, you'll see that import React from 'react'; remains. This lets the application that consumes this library decide how it would like to resolve this dependency. This will generally be to a browser global, using the the external and globals options, as above; but could be to a local file or even to npm for some reason. The point is that the application gets to choose.

You oftentimes see packages list react as a peer dependency. Since this prevents react from being installed into that package's node_modules, this is another way of preventing Rollup from bundling the module. This is also nice if you want the application to install react from npm, because if an application forgets to install a peer dependency, npm will issue a warning.

But this is only a halfway decent way to clarify that this is an external dependency, because the only way to resolve a peer dependency warning is to install react from npm—there's no way to notify npm that you resolve the dependency to a browser global. So peer dependencies should be avoided in favor of external declarations. Then Rollup will take care of warning about "unresolved dependencies", even if external declarations can't express a particular version range with which your library is compatible like peer dependencies can.

User experience above all

After years of copy-pasted, locally-hosted scripts, maybe Bower if you were lucky, npm has finally made it possible to easily distribute client-side packages. Rollup builds atop Browserify and Webpack's lineage to make it possible to easily consume those packages, while looking to the future of JS modules. But at Mixmax, we take care to not take these achievements for granted. We carefully analyze each additional package to make sure it doesn't compromise our user experience, and if necessary factor it out. Even if loading those dependencies from a CDN is a little more work, it's worth it.

If you’re interested in balancing developer and user experience at the cutting edge of JavaScript development, drop us a line at careers@mixmax.com.