Notes:
- The following instructions were inspired and updated from this blog post;
- Since these instructions where written, some package might have been deprecated or moved, make sure to check their status, on their website or GitHub/npm pages;
- This setup will use Node.js and npm (node package manager). Follow this link for installation instructions.
Content:
- Goals
- Getting Started
- Rollup
- Babel
- ESLint
- Third-Party and Non-ES Modules Support
- Enabling the ENV Variable
- Terser
- Less and PostCSS
- Automating Builds
- Building Multiple Modules
- Final Notes
Goals
The goal of this setup is to allow the development and build of multiple JavaScript front-end modules and associated stylesheets using Rollup.
The idea is to have a src
folder where scripts and styles can be developed. These sources would then be processed and bundled together in an app
folder, ready to be used in the browser:
-- project/
|-- app/
| |-- dist/
| | | // build modules (scripts and styles) go here
| |-- [main.js]
| |-- [main.css]
| |-- index.html
|-- src/
| |-- scripts/
| | | // JavaScript source code goes here
| |-- styles/
| | | // Less stylesheet sources go here
While setting up this process, we will explore many aspects of the modern JavaScript ecosystem: bundling, transpiling, linting, and minifying.
Getting Started
First, we are going to create a project directory, add the above project structure, and enter the project directory:
mkdir -p project/{app/dist, src/{scripts,styles}}
touch project/app/index.html
cd project
We can then initialise a node project by typing the next command and following the instructions:
npm init
It will create a package.json
file for us, which describes the scope and dependencies of the project.
Rollup
Rollup.js is a module bundler for JavaScript: it gets pieces of code that are dependent on each other to create a larger, self-contained module. It uses the standardized module format introduced in ES6. Rollup also uses a tree-shaking approach to bundling, removing unused pieces of code which could bulk your module unnecessarily.
To add it to the project, we type in the following command:
npm install --save-dev rollup
--save-dev
is an option that tells npm we want this library to be saved as a project dependency, in particular for development purposes (as opposed to dependencies for the code itself). If you check the package.json
file, you will see the following added:
// package.json
"devDependencies": {
"rollup": "^2.36.1"
}
Although the version number might be different.
Next, we are going to create a configuration file for Rollup, rollup.config.js
:
// rollup.config.js
export default {
input: 'src/scripts/foo.js',
output: {
file: 'app/dist/foo.js',
format: 'iife',
name: 'Foo',
sourcemap: 'inline'
}
}
-
input
is the file we want Rollup to process and bundle sources from; -
output
contains the options for our built module:-
file
is where we want the bundle saved; -
format
lets us choose one of the many JavaScript flavor our bundle will have, check the options list there. Here we chooseiife
which will wrap the module in a self-executed function (immediately-invoked function expression), making the module declare itself in its own scope to avoid clashing with other scripts; -
name
is the name we want to use when referring to the module in the front-end app, e.g.const bar = Foo.bar();
, note that it is only useful if the script we build has anexport
statement; -
sourcemap
lets us describe how we want the module sourcemap to be generated, a sourcemap is extremely useful when debugging code. Here choseinline
to have it contained in the generated bundled module file.
-
Testing the Configuration
Let's give a quick test to Rollup and our configuration. Inside src/scripts
we will create a directory utils
and add an operations.js
file in it:
mkdir src/scripts/utils
touch src/scripts/utils/operations.js
operations.js
will contain two functions, sum
and prod
, both returning the sum and the product of two arguments respectively. These two functions are exported by the operations
module:
// src/scripts/operations.js
const sum = (a,b)=>{ return a+b; }
const prod = (a,b)=>{ return a*b; }
export {sum, prod};
Inside src/scripts
we will create the module foo.js
:
touch src/scripts/foo.js
Which will load the functions from operations.js
and log the result of a sum on two variables:
// src/scripts/foo.js
import {sum, prod} from './utils/operations.js';
const A = 4;
const B = 5;
export default function(){
console.log(sum(A,B));
}
We can then run Rollup on src/scripts/foo.js
, note the option -c
which tells Rollup to use the configuration file we have made earlier:
./node_modules/.bin/rollup -c
And then check the resulting module in app/dist/foo.js
:
// app/dist/foo.js
var Foo = (function () {
'use strict';
const sum = (a,b)=>{
return a+b;
};
const A = 4;
const B = 5;
function foo () {
console.log(sum(A, B));
}
return foo;
}());
//# sourceMappingURL= ...
Right then we can note a few things:
- the content of
operations.js
andfoo.js
have been bundled together; - only the function
sum
was extracted from operations, that the tree-shaking from Rollup: becausefoo.js
does not useprod
, there is no need to bundle it; - the sourcemap has been added to the file
Babel
Babel is a JavaScript transpiler, taking code following modern JavaScript standards and producing the corresponding code in earlier versions of JavaScript with more browser support. We are first going to add two packages from Babel:
npm install --save-dev @babel/core @babel/preset-env
And then one Rollup plugin to integrate Babel:
npm install --save-dev @rollup/plugin-babel
Next, we can create the configuration file for Babel, .babelrc
, telling it which preset to use when transpiling:
// .babelrc
{
"presets": [
["@babel/preset-env", {
"modules": false
}]
]
}
The env
preset is a smart preset that uses Browserlist under the hood to determine which syntax is best to transpile to.
The final step is to let Rollup know that it should call babel during the bundling process. To do so we are going to update the Rollup configuration file:
// rollup.config.js
import babel from '@rollup/plugin-babel';
export default {
input: 'src/scripts/foo.js',
output: { ... },
plugins: [
babel({
exclude:'node_modules/**',
babelHelpers: 'bundled'
})
]
}
After importing the babel plugin, we call it in the plugins
list and instruct it to ignore the code from dependencies
Now, when running Rollup:
./node_modules/.bin/rollup -c
We get the following result in app/dist/foo.js
:
// app/dist/foo.js
var Foo = (function () {
'use strict';
var sum = function sum(a, b) {
return a + b;
};
var A = 8;
var B = 9;
function foo () {
console.log(sum(A, B));
}
return foo;
}());
//# sourceMappingURL=
Because we used the defaults
browserslist configuration, the sum
function has been changed from an arrow definition to a normal function
statement, and const
has been replaced with var
.
If we were to enter "browserslist": ["last 2 Chrome versions"]
in our package.json
(meaning we are targeting an environment limited to the last 2 versions of Chrome), there would not be any changes to the bundle, given that the last versions of Chrome are fully compatible with ES6 features.
ESLint
ESLint is a linter, a program that will analyse our code to correct syntax and detect problems (missing brackets/parentheses, unused variables, etc.) during the build process. We are first going to add it to our project:
npm install --save-dev eslint
As well as a Rollup plugin for it:
npm install --save-dev @rollup/plugin-eslint
Next, we need to configure ESLint, using the .eslintrc.json
file, which can be done by using the following command:
./node_modules/.bin/eslint --init
ESLint will then prompt a series of questions to initialise the configuration file:
? How would you like to use ESLint?
> To check syntax and find problems
? What type of modules does your project use?
> JavaScript modules (import/export)
? Which framework does your project use?
> None of these
? Does your project use TypeScript?
> No
? Where does your code run?
> Browser
? What format do you want your config file to be in?
> JSON
Our project should then include a new .eslintrc.json
file, with this content:
// .eslintrc.json
{
"env": {
"browser": true,
"es2021": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"rules": {
}
}
We can then add more the list of rules
, for example, having indents set to 4 spaces, using Unix linebreaks, using single quotes, enforcing semicolons at the end of each statement, and warning us of unused variables:
// .eslintrc.json
{ ...
"rules":{
"indent": ["warn", 4],
"linebreak-style": ["warn", "unix"],
"quotes": ["warn", "single"],
"semi": ["warn", "always"],
"no-unused-vars": ["warn"]
}
}
Next, we can update rollup.config.js
to include ESLint in the process:
// rollup.config.js
import babel from '@rollup/plugin-babel';
import eslint from '@rollup/plugin-eslint';
export default {
input: 'src/scripts/foo.js',
output: { ... },
plugins: [
eslint({
fix: true,
exclude: ['./node_modules/**', './src/styles/**'],
}),
babel({ ... })
]
}
As with Babel, we have first imported it, before including it in the list of plugins. We have instructed it to ignore our styles files, and let fix some of the simpler issues silently (e.g. semicolons, indentation, etc.).
Now, when we run:
./node_modules/.bin/rollup -c
We can notice the following terminal output, informing us that foo.js
defines (or imports) prod
but does not use it.
/.../project/src/scripts/foo.js
1:14 warning 'prod' is defined but never used no-unused-vars
✖ 1 problem (0 errors, 1 warning)
And ESLint has fixed some of the trivial syntax issues for us:
// src/scripts/operations.js before build
const sum = (a,b)=>{
return a+b;
};
const prod = (a,b)=>{
return a*b
}
export {sum, prod};
// src/scripts/operations.js after build
const sum = (a,b)=>{
return a+b;
};
const prod = (a,b)=>{
return a*b;
};
export {sum, prod};
Third-Party and Non-ES Modules Support
By default, Rollup does not load third party libraries from node_modules
properly. To enable that we need to use another Rollup plugin, node-resolve:
npm install --save-dev @rollup/plugin-node-resolve
Then, while we are developing ES modules, some of our code dependencies in node_modules
would have been developed in a non-ES module format: CommonJS. Trying to load these in our bundle will ultimately fail, but Rollup has a plugin to help with that, commonjs:
npm install --save-dev @rollup/plugin-commonjs
Once we have added those plugins to the project, we can add them the Rollup configuration:
// rollup.config.js
import babel from '@rollup/plugin-babel';
import eslint from '@rollup/plugin-eslint';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
export default {
input: 'src/scripts/foo.js',
output: { ... },
plugins: [
resolve(),
commonjs(),
eslint({ ... }),
babel({ ... })
]
}
Enabling the ENV Variable
Using environment variables can be helpful in the development process, for example turning logging on and off depending on the type of build we are doing, for development or production.
Let's add the following to src/scripts/foo.js
:
// src/scripts/foo.js
...
if(ENV != 'production'){
console.log('not in production');
}
...
A piece of code that logs a message when the build is not for production. However, the variable ENV
is undefined there. To fix that we can add the Rollup plugin replace:
npm install --save-dev @rollup/plugin-replace
And use it in the configuration file:
// rollup.config.js
import babel from '@rollup/plugin-babel';
import eslint from '@rollup/plugin-eslint';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import replace from '@rollup/plugin-replace';
export default {
input: 'src/scripts/foo.js',
output: { ... },
plugins: [
resolve(),
commonjs(),
eslint({ ... }),
babel({ ... }),
replace({
exclude: 'node_modules/**',
ENV: JSON.stringify(process.env.NODE_ENV || 'development'),
})
]
}
What it will do is replace, in our bundle (excluding code that comes from third-party libraries in node_modules
), the occurrences of ENV
with the value of NODE_ENV
or development
by default. What we must remember is to update .eslintrc.json
to let ESLint know that ENV
is a global variable and not undeclared:
// .eslintrc.json
{
"env": { ... },
"globals": {
"ENV": true
},
"extends": "eslint:recommended",
"parserOptions": { ... },
"rules": { ... }
}
Then when building normally:
./node_modules/.bin/rollup -c
app/dist/foo.js
will include the following:
// app/dist/foo.js
...
{
console.log('not in production');
}
...
However, building for production:
NODE_ENV=production ./node_modules/.bin/rollup -c
Will remove the code above from app/dist/foo.js
.
Terser
Generating a bundle that has many dependencies, from our code or third-party packages, will result in a large JavaScript file. To optimize the bundle it is useful to integrate Terser into our build process. What Terser does is it removes comments, shorten variables names, cut whitespaces and minify our code to make it the shortest possible.
Again Terser can be integrated with Rollup using a plugin:
npm install --save-dev rollup-plugin-terser
And configure it in rollup.config.js
:
// rollup.config.js
import babel from '@rollup/plugin-babel';
import eslint from '@rollup/plugin-eslint';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import replace from '@rollup/plugin-replace';
import {terser} from 'rollup-plugin-terser';
export default {
input: 'src/scripts/foo.js',
output: {
file: 'app/dist/foo.js',
format: 'iife',
name: 'Foo',
sourcemap: (process.env.NODE_ENV === 'production' ? false : 'inline')
},
plugins: [
resolve(),
commonjs(),
eslint({ ... }),
babel({ ... }),
replace({ ... }),
(process.env.NODE_ENV === 'production' && terser())
]
}
Because it is useful to be able to inspect and see our code during development, we are only letting terser
execute if NODE_ENV
is set to production
. Similarly, we have turned off the sourcemap in production to reduce the bundle size.
If we now build our module for production:
NODE_ENV=production ./node_modules/.bin/rollup -c
Here is how it looks:
// app/dist/foo.js
var Foo=function(){"use strict";return function(){console.log(8+9)}}();
Less and PostCSS
Now that we have addressed our scripts, we can focus on our styles. In this setup, we will look at the CSS preprocessor Less which lets us write CSS simpler, use variables and mixins. We can add it to the project with the following command:
npm install --save-dev less
To process Less files we will use PostCSS, which is a JavaScript build tool for CSS, Less, and other CSS preprocessors. It also comes with a built-in minifier. We can add it to the project with a Rollup plugin:
npm install --save-dev rollup-plugin-postcss
One of the most interesting features of PostCSS is Autoprefixer. Much like Babel, it checks our browserslist
requirement to add prefixes to CSS rules, ensuring cross-browser compatibilities. We can add it to the project with the following command:
npm install --save-dev autoprefixer
We can now set this up with Rollup, in the configuration file:
// rollup.config.js
import babel from '@rollup/plugin-babel';
import eslint from '@rollup/plugin-eslint';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import replace from '@rollup/plugin-replace';
import {terser} from 'rollup-plugin-terser';
import postcss from 'rollup-plugin-postcss';
import autoprefixer from 'autoprefixer';
export default {
input: 'src/scripts/foo.js',
output: { ... },
plugins: [
resolve(),
commonjs(),
eslint({ ... }),
babel({ ... }),
replace({ ... }),
(process.env.NODE_ENV === 'production' && terser()),
postcss({
plugins: [autoprefixer()],
inject: false,
extract: true,
sourceMap: (process.env.NODE_ENV === 'production' ? false : 'inline'),
minimize: (process.env.NODE_ENV === 'production')
})
]
}
As we can see, PostCSS calls Autoprefixer as a plugin, it uses Less in the background automatically when detecting Less files. The inject
option lets us define whether the JavaScript module will inject styles in the <head>
of our page (true
) or not (false
). Similarly, the extract
option lets us define whether a separate stylesheet will be generated next to the JavaScript module (true
) or not (false
). This stylesheet will have the same filename as the JavaScript module, with a .css
extension instead. Then, we set the sourcemap
and minimize
options depending on NODE_ENV
as we did with the JavaScript module.
Processing a stylesheet can then be done by simply importing it in our JavaScript module:
/* src/styles/foo.less */
@clr: red;
h1{
color: @clr;
}
// src/scripts/foo.js
import '../styles/foo.less';
...
NODE_ENV=production ./node_modules/.bin/rollup -c
/* app/dist/foo.css */
h1{color:red}
Automating Builds
The next step of this setup is to make use of node scripts to automate the build process.
First, we are going to install reload
, an HTTP server program that comes with a live-reload functionality:
npm install --save-dev reload
Reload can then serve app/
to localhost
and reload anytime it detects a change.
Meanwhile, Rollup comes with a watch option, -w
, that keeps it listening to any changes in our source file to automatically re-build them. We can therefore combine the two in one Node script in our package.json
:
// package.json
...
"scripts": {
"serve": "./node_modules/.bin/reload -b -d ./app -p 8000 | ./node_modules/.bin/rollup -c -w"
}
...
Then, running:
npm run server
Will launch both Reload and Rollup: Rollup listening to any changes on the source file and re-building them, and Reload detecting changes in the build files and re-serving them on our test webpage localhost:8000
.
We can then add a second script for production build:
// package.json
...
"scripts": {
"serve": "./node_modules/.bin/reload -b -d ./app -p 8000 | ./node_modules/.bin/rollup -c -w",
"build": "NODE_ENV=production ./node_modules/.bin/rollup -c"
}
...
Then, we can run the following to simply build our production application:
npm run build
Building Multiple Modules
Finally, we can setup rollup.config.js
to allow multiple modules to be bundled separatley:
// rollup.config.js
import babel from '@rollup/plugin-babel';
import eslint from '@rollup/plugin-eslint';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import replace from '@rollup/plugin-replace';
import {terser} from 'rollup-plugin-terser';
import postcss from 'rollup-plugin-postcss';
import autoprefixer from 'autoprefixer';
const srcDir = 'src/scripts/',
distDir = 'app/dist/';
const plugins = () => [
resolve(),
commonjs(),
eslint({ ... }),
babel({ ... }),
replace({ ... }),
(process.env.NODE_ENV === 'production' && terser()),
postcss({ ... })
];
function setupBuild(src, dist, name){
return {
input: srcDir+src,
output: {
file: distDir+dist,
format: 'iife',
name,
sourcemap: (process.env.NODE_ENV === 'production' ? false : 'inline')
},
plugins:plugins()
}
}
export default [
setupBuild('foo.js', 'foo.js', 'Foo'),
setupBuild('bar.js', 'bar.js', 'Bar')
]
Additional modules can be added using setupBuild
. Note that we use a function to return plugins to "clean" their buffers.
Final Notes
Using Builds
The built modules can be simply loaded into an HTML page:
<!-- app.index.html -->
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="dist/foo.css">
<script src="dist/foo.js"></script>
</head>
<body>
<h1>Hello World</h1>
<script>
Foo();
</script>
</body>
</html>
As any other JavaScript code, it will get executed upon loading. If the module exports a value (object or function), it can be accessed using the name given in the Rollup configuration (third parameter of setupBuild
). For example: Foo()
, Foo[...]
or Foo.bar
.
Managing Warnings
Some third-party libraries like d3.js will have circular dependencies within them, which Rollup will warn us about when building the module. To avoid getting many warning messages, we can add a warning filter to in the Rollup configuration:
// rollup.config.js
...
function setupBuild(src, dist, name){
return {
input: srcDir+src,
output: { ... },
plugins:plugins(),
onwarn: function(warning, warner){
// if circular dependency warning
if (warning.code === 'CIRCULAR_DEPENDENCY'){
// if comming from a third-party
if(warning.importer && warning.importer.startsWith('node_modules/')){
// ignore warning
return;
}
}
// Use default for everything else
warner(warning);
}
}
}
...
Final Project Structure
This how the project directory should look like now:
-- project/
|-- app/
| |-- dist/
| | |-- foo.js
| | |-- foo.css
| |-- index.html
|-- src/
| |-- scripts/
| | |-- utils/
| | | |-- operations.js
| | |-- foo.js
| |-- styles/
| | |-- foo.less
|-- .babelrc
|-- .eslintrc.json
|-- package-lock.json
|-- package.json
|-- rollup.config.js
The package.json
file should contain the following:
// package.json
{
...
"scripts": {
"serve": "./node_modules/.bin/reload -b -d ./app -p 8000 | ./node_modules/.bin/rollup -c -w",
"build": "NODE_ENV=production ./node_modules/.bin/rollup -c"
},
...
"devDependencies": {
"@babel/core": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"@rollup/plugin-babel": "^5.2.2",
"@rollup/plugin-commonjs": "^17.0.0",
"@rollup/plugin-eslint": "^8.0.1",
"@rollup/plugin-node-resolve": "^11.1.0",
"@rollup/plugin-replace": "^2.3.4",
"autoprefixer": "^10.2.1",
"eslint": "^7.17.0",
"less": "^4.1.0",
"reload": "^3.1.1",
"rollup": "^2.36.1",
"rollup-plugin-postcss": "^4.0.0",
"rollup-plugin-terser": "^7.0.2"
},
"browserslist": [
"defaults"
]
...
}
.babelrc
should look like this:
// .babelrc
{
"presets": [
["@babel/preset-env", {
"modules": false
}]
]
}
.eslintrc.json
should look like this:
// .eslintrc.json
{
"env": {
"browser": true,
"es2021": true
},
"globals": {
"ENV": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"rules": {
"indent": ["warn", 4],
"linebreak-style": ["warn", "unix"],
"quotes": ["warn", "single"],
"semi": ["warn", "always"],
"no-unused-vars": ["warn"]
}
}
And finally, rollup.config.js
should have the following:
// rollup.config.js
import babel from '@rollup/plugin-babel';
import eslint from '@rollup/plugin-eslint';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import replace from '@rollup/plugin-replace';
import {terser} from 'rollup-plugin-terser';
import postcss from 'rollup-plugin-postcss';
import autoprefixer from 'autoprefixer';
const srcDir = 'src/scripts/',
distDir = 'app/dist/';
const plugins = () => [
resolve(),
commonjs(),
eslint({
fix: true,
exclude: ['./node_modules/**', './src/styles/**']
}),
babel({
exclude: 'node_modules/**',
babelHelpers: 'bundled'
}),
replace({
exclude: 'node_modules/**',
ENV: JSON.stringify(process.env.NODE_ENV || 'development')
}),
(process.env.NODE_ENV === 'production' && terser()),
postcss({
plugins: [autoprefixer()],
inject: false,
extract: true,
sourceMap: (process.env.NODE_ENV === 'production' ? false : 'inline'),
minimize: (process.env.NODE_ENV === 'production')
})
]
function setupBuild(src, dist, name){
return {
input: srcDir+src,
output: {
file: distDir+dist,
format: 'iife',
name,
sourcemap: (process.env.NODE_ENV === 'production' ? false : 'inline')
},
plugins:plugins(),
onwarn: function(warning, warner){
if (warning.code === 'CIRCULAR_DEPENDENCY'){
if(warning.importer && warning.importer.startsWith('node_modules/')){
return;
}
}
warner(warning);
}
}
}
export default[
setupBuild('foo.js', 'foo.js', 'Foo')
]
Top comments (1)
Thank you, this was really well explained i have been searching for something like this for a while. 👍🏻