Handling 3rd-party JavaScript with Rollup

SHARE ON

How and when to leave JS out of the bundle

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(
  

Hello, world!

, 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/:


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!


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 = () => (
  
); 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.

SHARE ON

Written By

Jeff Wear

Jeff Wear

From Your Friends At