The code below is bundled with Rollup. Rollup has determined the bare minimum of imports the code needs to execute with a process known as tree shaking.
import React from 'react'; import { useFormikContext, useField } from 'formik'; import isEmpty from 'lodash/isEmpty'; import { DatePicker, Checkbox, CheckboxGroup, Radio, RadioGroup, TransitionComponent, TransitionType } from '@my/component-library';
Tree shaking is a technique used to eliminate dead code. Rollup has determined that I only need two components from formik.
It is no secret that most modern-day JavaScript-heavy applications ship far too much code to the browser.
In the image above, the vast size of react-dom.production.min.js
is there for all to see.
formik has needlessly doubled its size by including nearly all of lodash. Lodash is a colossal problem for so many JavaScript bundles.
It seems unlikely that formik is using all the imported modules that are fattening up the bundle size. What is needed is a way of eliminating dead code or only importing the modules that are used by the importee.
In order for a bundler to identify dead code, it will need to perform static analysis on the codebase.
I use TypeScript for everything I can these days, and up until recently, I had the module
setting of tsconfig.json
set to commonjs
.
CommonJS modules are harder to optimize because dynamic imports and exports are supported:
const a = require(localStorage.getItem('somekey')); const b = 'exportThis'; module.exports[b] = (a) => a;
Because require
is actually a function call, we can use a dynamically computed string to determine at runtime the module to be loaded.
A static analyzer will not even attempt to decipher dynamic imports and exports and instead grab everything.
Both import
and export
statements are part of the language with ESM modules. There is no ambiguity, and the lack of dynamism facilitates static analysis.
Dynamic imports are still possible in ESM modules for code splitting.
I have used Webpack for a long time and Webpack works by wrapping each module in a function that implements a loader and a module cache. At runtime, each of these modules is evaluated in turn to populate the module cache. The Webpack approach makes things like hot module replacement (HMR) possible but incurs overhead with this approach.
Rollup takes a different approach — it puts all code at the same level, which is known as scope hoisting. The resulting bundle is smaller with much less overhead as there is no per-module evaluation.
The trade-off is that rollup relies on ESM module semantics. Step 1 of any journey to a smaller bundle size is to turn any CommonJS packages into 100% ESM packages.
Bundlers such as rollup or Webpack generally have a mechanism to specify which field in the package.json
file is the entry point.
If the consuming package has an import such as:
import * as D3 from 'd3';
The following fields of package.json
will determine what the entry point for the module is:
type
with a value of module
With Webpack, it is possible to set a precedence of which fields will be searched first using the mainFields
option:
resolve: { mainFields: ['module', 'browser', 'main'],
With Rollup you can use the @rollup/plugin-node-resolve rollup plugin:
resolve({ mainFields: ['module', 'browser', 'main'],
The first step in our journey should be to set the type
to module
and supply a module
field:
{ "name": "@ds/util", "version": "6.3.0", "type": "module", "module": "dist/index", "browser": "dist/index", "main": "dist/index", }
I had problems with Webpack if there was a file extension either module
, main
or browser
and Webpack relied on the existence of a browser field when targeting web
builds.
mjs
filesES modules are the target for files with an .mjs
extension. Later versions of Node assume that an import of an mjs
file will be ESM compliant.
In the browser world or more accurately, the bundler world, it is more of a convention, but Webpack and Rollup will treat this file differently and compile to a different target.
I ran into problems with dependencies like React and react-router that are CommonJS dependencies. The solution was to output a file with a .esm.js
file extension.
require
into touchThe main refactoring that I encountered was to remove require
statements that were importing scss
files:
const styles = require('./Start.module.scss');
Which becomes:
import styles from './Start.module.scss';
With TypeScript, I had to instruct the compiler to transpile to ESNext
.
In tsconfig.json
, the module
field changed from :
"module": "CommonJS",
to
"module": "ESNext",
This is the Rollup configuration I settled upon:
const bundle = await rollup({ input: inputFile, external: (id: string) => { return !id.startsWith('.') && !path.isAbsolute(id); }, treeshake: { moduleSideEffects: false, }, plugins: [ resolve({ mainFields: ['module', 'browser', 'main'], extensions: ['.mjs', '.esm.js', 'cjs', '.js', '.ts', '.tsx', '.json', '.jsx'], }), json(), postcss({ extract: false, modules: true, use: ['sass'], }), typescript({ clean: true, typescript: require('typescript'), tsconfig: paths.tsConfig, abortOnError: true, tsconfigDefaults: { compilerOptions: { sourceMap: true, declaration: true, target: 'esnext', jsx: 'react', }, useTsconfigDeclarationDir: true, }, tsconfigOverride: { compilerOptions: { sourceMap: true, target: 'esnext', }, }, }), babel({ exclude: /\/node_modules\/core-js\//, babelHelpers: 'runtime', ...babelConfig, } as RollupBabelInputPluginOptions), injectProcessEnv({ NODE_ENV: 'production', }), sourceMaps(), minify === true && terser({ compress: { keep_infinity: true, pure_getters: true, passes: 10, }, ecma: 2016, toplevel: false, format: { comments: 'all', }, }), ], }); }
The following plugins where used:
Calling rollup’s rollup function with the above configuration returns a bundle object that can write the bundle to disk with the following code:
await bundle.write({ file: path.join(paths.appBuild, 'index.js'), format: 'esm', name: packageName, exports: 'auto', sourcemap: true, esModule: true, interop: 'esModule', });
The package is now ESM compliant.
The world is turning to ESM modules slowly but surely. There is the promise of a smaller bundle size to lure you to the promised land.
I use Rollup to bundle my packages but still use Webpack for hot module replacement in development.
Whatever the bundler, it is making more and more sense to move away from CommonJS.
There’s no doubt that frontends are getting more complex. As you add new JavaScript libraries and other dependencies to your app, you’ll need more visibility to ensure your users don’t run into unknown issues.
LogRocket is a frontend application monitoring solution that lets you replay JavaScript errors as if they happened in your own browser so you can react to bugs more effectively.
LogRocket works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Build confidently — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]