SOSOLOG

Rollup.js Configuration

January 17, 2021

Translated from the Korean original.

image-thumbnail

While setting up the dev environment for a design system, I ended up digging deep into rollup.js and how to configure it for a library. This isn’t a step-by-step setup tutorial, so it won’t cover everything you might need.

If you just want to see the finished setup, it’s all at @soyoung/design-system-config.

Table Of Contents

Rollup.js

Rollup.js is a bundler, same category as webpack or parcel: it takes a pile of large, interconnected modules (files) and turns them into a library or an application.

Config

rollup.js supports a long list of plugins and options. Here’s a rollup.config.js that outputs both es (ES module) and cjs (Common JS) formats:

rollup.config.js
const inputSrc = [
['./src/index.ts', 'es'],
['./src/index.ts', 'cjs'],
];
export default inputSrc
.map(([input, format]) => {
return {
input,
output: {
dir: 'dist',
format,
},
plugins: [],
};
});

Now add the plugins you actually need.

import babel from '@rollup/plugin-babel';
import commonjs from '@rollup/plugin-commonjs';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
import postcss from 'rollup-plugin-postcss';
import { terser } from 'rollup-plugin-terser';
export default inputSrc
.map(([input, format]) => {
return {
input,
output: {
dir: `dist2/${format}`,
format,
exports: 'auto',
},
external: [/@babel\/runtime/],
// explained later
preserveModules: format === 'cjs',
plugins: [
babel({
babelHelpers: 'runtime',
exclude: 'node_modules/**',
extensions,
}),
nodeResolve({
extensions,
}),
// https://velog.io/@peterkimzz/rollup-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8
// Helps rollup interpret CommonJS modules by converting them to ES6.
commonjs({
extensions: [...extensions, '.js'],
}),
peerDepsExternal(),
postcss({
plugins: [],
}),
terser(),
],
};
});

preserveModules

Set rollup.js’s preserveModules option to true, and your bundle output keeps the same folder structure as your source.

According to the docs, this setting has no effect on Tree Shaking support. The real difference shows up in cjs or amd formats: if you only use one piece of the library, you no longer end up importing all of it.

⚠️ The docs state outright that ‘preserveModule: true also supports tree shaking.‘
That said, setting preserveModules: true at least contains the blast radius, so a tree-shake failure in one file doesn’t take down the rest. I’d still recommend testing it against a real bundle. (Related issue: webpack - tree shaking not working es module library)

// Before
const module = require('@soyoung210/design-system-config')
render(module.Card);
// After
const Card = require('@soyoung210/design-system-config/dist/cjs/react/card/card3D');
render(Card);

cjs has no tree shaking of its own, so if a single file holds everything, your app’s bundle can balloon.

📝 This option exists in the first place to support tree shaking in apps built with “Ember.js”. (related PR)

babel

Here’s how babel is configured in rollup.config.js.

babel({
babelHelpers: 'runtime',
exclude: 'node_modules/**',
extensions,
}),

@rollup/plugin-babel’s babelHelpers option takes one of four values.

Of these four, runtime and bundled are worth walking through in detail.

The example code used for these build tests has two components: Button1, built with just react and css, and Card, which uses react-spring.

babelHelpers: bundled

export default [
// ...
plugins: [
babel({
babelHelpers: 'bundled', // default value
exclude: 'node_modules/**',
extensions,
})
]
]

With this setting, babel’s helper functions end up baked into the bundle output. Here’s what that looks like.

function _objectWithoutPropertiesLoose(source, excluded) {
/* omitted */
}
const Button1 = (_ref) => {
let {
children
} = _ref,
props = _objectWithoutPropertiesLoose(_ref, ["children"]);

You can see _objectWithoutPropertiesLoose sitting right there in the file.

babelHelpers: bundled + external: [/@babel/runtime/]

export default [
// ...
external: [/@babel\/runtime/],
plugins: [
babel({
babelHelpers: 'bundled',
exclude: 'node_modules/**',
extensions,
})
]

What happens if you combine the two?

import _extends$1 from '@babel/runtime/helpers/esm/extends';
import _objectWithoutPropertiesLoose$1 from '@babel/runtime/helpers/esm/objectWithoutPropertiesLoose';
function _extends() {
/* omitted */
}
function _objectWithoutPropertiesLoose(source, excluded) {
/* omitted */
}

_objectWithoutPropertiesLoose$1 now points at the @babel/runtime module, while a separate _objectWithoutPropertiesLoose got generated locally.

node_modules/react-spring is what’s referencing _objectWithoutPropertiesLoose$1. So the @babel/runtime reference coming from an external module (react-spring, in this example project) stayed external.

Meanwhile, the @babel/runtime code Button1 needed got generated right inside the bundle.

babelHelpers: runtime + external: [/@babel/runtime/]

export default [
// ...
external: [/@babel\/runtime/],
plugins: [
babel({
babelHelpers: 'runtime',
exclude: 'node_modules/**',
extensions,
})
]
]

Every single reference to @babel/runtime becomes external. Your babel config needs @babel/transform-runtime in it for this to work.

import _objectWithoutPropertiesLoose from '@babel/runtime/helpers/objectWithoutPropertiesLoose';
const Button1 = (_ref) => {
let {
children
} = _ref,
props = _objectWithoutPropertiesLoose(_ref, ["children"]);

_objectWithoutPropertiesLoose no longer gets its implementation generated locally; it just points at the external module instead.

babelHelpers: runtime

export default [
// ...
// external: [/@babel\/runtime/],
plugins: [
babel({
babelHelpers: 'runtime',
exclude: 'node_modules/**',
extensions,
})
]
]

Skip the external: [/@babel\/runtime/] setting the docs tell you to add, and you’ll get the exact same result as babelHelpers: 'bundled'.

function _objectWithoutPropertiesLoose(source, excluded) {
/* omitted */
return target;
}
const Button1 = (_ref) => {
let {
children
} = _ref,
props = _objectWithoutPropertiesLoose(_ref, ["children"]);

So if you’re using the runtime option, setting external isn’t optional.

Tree Shaking Result

Tree Shaking is one of the things that really matters for a library. If a user only touches a fraction of your code but the whole thing lands in their bundle anyway, bloating it for no reason, that’s a hard sell no matter how well the library is built otherwise.

When everything bundles into one file, a Bundle Analyzer alone won’t tell you much. Checking the application’s final bundle output directly is a better way to confirm whether Tree Shaking actually kicked in.

So I installed @soyoung210/design-system-config and checked the result myself.

With the Card component included

include_card.png

The onMouseMove logic and everything else that makes up the Card component (function) ended up in the final bundle.

With the Card component left out

no_card.png

Card’s own code disappeared as expected, but the react-spring code it depends on stuck around. That might just be a react-spring issue, but it’s also a textbook case of what I mentioned under preserveModules: one file’s tree-shake failure dragging the whole bundle down with it.

If you want to go deeper on Tree Shaking, this post is worth a read.

Why not Webpack?

webpack is a JavaScript bundler too, and does basically the same job, so you could reach for it here as well. But there are a few reasons I’d pick rollup over webpack specifically for bundling a library.

webpack can’t bundle into ESM format.

Tree Shaking requires the bundled output to be in ESM format, but Webpack simply can’t produce that. Rollup can. 🔔 Webpack5 has ESM support on its roadmap, but as of 2021.01.10 it’s still experimental.

Bundling the exact same output, Rollup.js comes out to 22KB versus Webpack’s 29KB — webpack’s file size is just bigger. For more on this, see Migrating from Webpack to Rollup.

Why not babel cli?

You could also skip bundling entirely and just run everything through babel as a transpiler. Plenty of open source projects do exactly this, including chakra-ui and react-query (esm).

Situation 1: using an internal dependency

// babel cli
import { animated, useSpring } from 'react-spring';
export function Card() {
return /*#__PURE__*/React.createElement(animated.div, { ... })
}
// rollup
import { useSpring, animated as extendedAnimated } from '../../../node_modules/react-spring/web.js';

If you want react-spring treated as an internal dependency of your library, babel alone can’t get you there. It doesn’t touch the import { .. } from 'react-spring' syntax at all, so every external dependency you use has to be declared as peerDependencies instead.

Situation 2: custom style sheet

Say a component imports its own style sheet. A bundler knows how to parse and transform that style file. babel doesn’t.

// babel cli
import './styles.css';
import React from 'react';
// rollup
import React from 'react';
import './styles.css.js';
// how rollup handles the style sheet, styles.css.js
import styleInject from '../../../node_modules/style-inject/dist/style-inject.es.js';
var css_248z = ".card {\n width: 45ch;}\n";
styleInject(css_248z);
export default css_248z;

package.json

If your bundler outputs multiple formats — cjs (or umd), esm, whatever — you need to wire package.json up to point at the right one.

Declare both, and bundlers like rollup or webpack will read the module field, while environments that can’t handle ES6, Jest being the obvious one, fall back to whatever main points at.

TypeScript

TypeScript code needs one extra step beyond converting to JS: you also have to generate type definition files.

You can handle the JS conversion with @rollup/plugin-babel, the one already configured above, or with rollup-plugin-typescript2. Generating type definitions is simplest through tsc, so that’s where I’ll start.

💡 rollup’s own officially supported TypeScript plugin is @rollup/plugin-typescirpt. This post covers rollup-plugin-typescript2 instead, for a reason I’ll get to below.

tsc

Start with a tsconfig like this. (It doesn’t have to match exactly.)

{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"lib": ["dom", "esnext"],
"strict": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "Node",
"suppressImplicitAnyIndexErrors": true,
"noImplicitAny": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"strictPropertyInitialization": true,
"jsx": "react",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}

Then tweak package.json a bit so type definitions get generated as part of build.

"scripts": {
"build:rollup": "rimraf ./dist2 && rollup -c ./scripts/rollup/rollup.config.js && npm run build:types2",
"build:types2": "tsc --emitDeclarationOnly --declaration --declarationDir dist2/types"
},

rollup-plugin-typescript2

When you’re pairing Rollup with TypeScript, you’ve basically got two options: rollup-plugin-typescript2, or @babel/preset-typescript (alongside @rollup/plugin-babel).

The real difference between them comes down to how each one actually resolves TypeScript.

Either one works fine. The project in this post uses babel purely as a transpiler and leans on tsc only for generating the type definition file, so it only needed @rollup/plugin-babel.

📝: rollup-plugin-typescript2 started as a fork of rollup’s official TypeScript tool, @rollup/plugin-typescript, built specifically to surface TypeScript compile errors. You get all of TypeScript’s power, but there’s a known issue with build speed being very slow.

⚠️ During Tree Shaking, a /*#__PURE__ */ annotation tells the bundler a piece of code has no side effects and can be dropped. babel has supported pure annotations since v7, but TypeScript still doesn’t. It’s worth checking whether pure annotations actually made it into your bundle output.

Wrapping Up

That’s everything I dug into and worked out while putting together this Rollup.js config file. I hope it helps if Rollup.js was still new to you, and if anything here is missing or needs fixing, I’d really appreciate a comment pointing it out.


SO_YOUNG
👋@SO_YOUNG
📝 A small, casual dev log

GitHubX (Twitter)