Through the pipeline: An exploration of front-end bundlers

walpolea

Andrew Walpole

Posted on February 13, 2021

Through the pipeline: An exploration of front-end bundlers

This last week I spent a lot of time buried deep in the documentation of parcel, rollup, and esbuild. I've been on a quest. Mainly one that starts from a place of being fed up with webpack being slow and unwieldy to configure and manage. And I've been hearing and even playing around with these other bundlers a bit, well enough to know a happier path should exist.

My bundling needs

Let me lay out my specific need, because it is specific enough that no bundler was giving me everything I needed out of the box. And this is a good time to preface my forthcoming feedback with the notion that in playing with parcel, rollup, and esbuild, they ALL are wonderful, easy to quick-start bundlers if your use-case fits their default model or even handful of command-line argument options they provide.

  • I'm building and bundling .js and .scss files for Wordpress theme development.
  • I need it to handle multiple main entry points (four that eventually turned into two):
    • js/main.js – is the front-end site entry point for javascript
    • css/style.scss – is the front-end site entry point for css styles
    • js/editor.js – is Gutenberg-specific javascript (not often used, but handy when you need it!)
    • css/editor.scss – you guessed it, another Gutenberg-specific set of css for occasional visual block and block editor tweaks.
  • These four entry files should bundle into to four build files: build/main.min.js, build/main.min.css, build/editor.min.js, and build/editor.min.css.
  • While developing, I want any imported dependency .js and .scss file changes to trigger a rebuild and I want all those files plus .php file changes to trigger livereload in the browser.
  • I need to be able to install, import, and bundle 3rd party libraries via npm
  • I need it to be faster than webpack has been (around 5-10 second development build times)
  • Finally, I need all the usual good stuff: minification, sourcemaps, transpilation, etc.

Ok, so that lays out what I was looking for, and hopefully is priming googlebot for sending you here in the first place. From here on out, I'm going to get a lot terser, and jump directly to my pros, cons, and learning moments with each of the bundlers I checked out.

Parcel 1.x (and a little bit of 2.x)

Pros:

  • I started with parcel, I've used it in other (more modern stack) projects and it really does often just work, with minimal to no configuration.
  • It's pretty fast and has a very pleasant set of command-line and node API configuration options that are well documented.
  • It felt a bit awkward at first, but I ended up being fine with the

Cons:

  • 1.x is no longer being actively developed, in favor of 2.x (in beta as of this writing), and while there's a good ecosystem here, a handful of paths end in finding an old Github issue with something like, "this is not currently possible in parcel 1.x, but will be available in parcel 2.0"
  • Assets, like fonts and images referenced in your css get bundled into your build directory automatically. I did not find a solution to stop this from happening.
  • I ended my journey with 1.x when I found that changing css files was causing js files to stop working until I resaved them, still a big mystery as to what was going on.
  • Also, in using multiple entry points, the editor build files always got a hash in the filename even when trying to turn it off, while the front-end files worked perfectly.
  • I tried out 2.x for a bit in hopes to find solutions, but while promising to be more capable and extendable, I found many of the new features to be tough to use, and the documentation to be hard to follow.
  • My 2.x journey ended when I took to tweeting at the creator for some guidance, only to find the thing I was trying to do was not possible. (I was trying to create a plugin to remove the hashing issue from the editor filenames, but it turns out you can't use a plugin without publishing it to something like npm, which I had no interest in doing.)

Learnings:

  • I'm certain that I will reach for parcel for other projects, especially with the parcel-bundler node API knowledge I got, I think it's a great API to tap into.
  • It felt a bit awkward at first, but I did eventually give in to importing my scss into my respective js files, as that feature seems to be common across bundlers. This cut my entry points down from four to two, and now I was only dealing with javascript entry points.
  • Here's my parcel build script before I moved on:
const Bundler = require("parcel-bundler");

//entry files
const entries = ["./js/main.js", "./js/editor.js"];

const baseOptions = {
  outDir: "./build",
  minify: true,
  sourceMaps: true,
  watch: process.argv.includes("--watch"),
  publicUrl: "./",
  logLevel: 4,
  contentHash: false,
};

(async () => {
  const bundler = new Bundler(entries, { ...baseOptions });
  return await bundler.bundle();
})();
Enter fullscreen mode Exit fullscreen mode

Rollup.js

I felt a bit defeated, but remained positive that I might find the answers in rollup!

Pros

  • Similarly to parcel, rollup has a nice command-line and node API interface that I was able to get up and running with quickly.
  • The core features are a bit limited, but it makes up for it with many available plugins that are easy to install and use.
  • I was able to get rollup to do everything I wanted, but the build times started to stack up.

Cons

  • My biggest con is how long it did take me to get to where I had everything I wanted working. I sifted through github issues and google searches for far too long.
  • In the end, I decided to move on from rollup because I wasn't seeing a huge gain in bundling speed over webpack and it was a few seconds slower than I had gotten with parcel.

Learnings

  • I'll let my rollup.config.js do the talking:
// rollup.config.js
import resolve from "rollup-plugin-node-resolve";
import commonJS from "rollup-plugin-commonjs";
import scss from "rollup-plugin-scss";
import { babel } from "@rollup/plugin-babel";
import { terser } from "rollup-plugin-terser";
import livereload from "rollup-plugin-livereload";
import fg from "fast-glob";

//custom watching plugin to get rollup to rebuild when other files get changed
const watcher = {
  name: "watch-external",
  async buildStart() {
    const files = await fg(["assets/js/**/*.js", "assets/css/**/*.scss", "**/*.php"]);
    for (let file of await files) {
      this.addWatchFile(file);
    }
  },
};

//setup the plugins for both builds
const plugins = [
  watcher, //my custom file watcher
  resolve(), //related to resolving node_module references, I think.
  commonJS({ //this allowed things like: import jquery from 'jquery' to work
    include: "node_modules/**",
  }),
  babel({ babelHelpers: "bundled" }), //transpile!
  scss({ outputStyle: "compressed" }), //minified scss
  terser(), //for minifcation
  livereload(), //for livereload
];

//notice, two entry points here, handled like a champ!
export default [
  {
    input: "js/main.js", //the style.scss is imported in this file
    output: {
      file: "build/main.min.js",
      format: "iife", //immediately invoked function expression - browser solution
      inlineDynamicImports: true,
    },
    plugins: plugins,
  },
  {
    input: "js/editor.js", //the editor scss is imported in this file, also
    output: {
      file: "build/editor.min.js",
      format: "iife",
      inlineDynamicImports: true,
    },
    plugins: plugins,
  },
];
Enter fullscreen mode Exit fullscreen mode

esbuild

While rollup worked fine, I've been hearing about the new kid on the block, esbuild, and its ridiculous build times, so it was time to dive in.

Pros

  • Yet again, good documentation, good command-line and node experiences here.
  • HOLY CATS, THIS THING SCREAMS! 150ms folks! From 8-10 seconds to 150ms! I honestly thought it was broken, but there they were, my build files were pristine.

Cons

  • With multiple entry points, I had to concede being able to name the build files specifically. I had found workarounds in rollup for this, but I seem to be out of luck with esbuild. So instead of build/main.min.js I'm left with just build/main.js. I can deal.
  • esbuild is young, and while it's working well, the ecosystem around it is a bit scrappy feeling. I have a hunch that had I not just done prior battle with parcel and rollup, I may have been scared off by esbuild at first glance.

Learnings

  • esbuild just (like 12 days ago) got a watchFiles flag, and it works, but it doesn't seem to let you watch files outside of the entry points
  • I was able to cobble together chokidar and browserSync to create the watching and livereload solution I wanted.
  • Here's my final esbuild build script:
//esbuilder.js
const esbuild = require("esbuild");
const sassPlugin = require("esbuild-plugin-sass");
const chokidar = require("chokidar");
const browserSync = require("browser-sync").create();

const build = async () => {
  console.log("Building...");
  const service = await esbuild.startService();
  try {

    const timerStart = Date.now();

    // Build code
    await service.build({
      entryPoints: ["js/main.js", "js/editor.js"],
      format: "iife",
      bundle: true,
      minify: true,
      outdir: "build/",
      plugins: [sassPlugin()],
      sourcemap: true,
      //this stops esbuild from trying to resolve these things in css, may need to add more types
      external: ["*.svg", "*.woff", "*.css", "*.jpg", "*.png"],
    });

    const timerEnd = Date.now();
    console.log(`Done! Built in ${timerEnd - timerStart}ms.`);

  } catch (error) {
    console.log(error);
  } finally {
    service.stop();
  }
};

//watch it?
if (process.argv.includes("--watch")) {
  //chokidar will watch theme files for changes to trigger rebuild
  const watcher = chokidar.watch(["js/**/*.js", "css/**/*.scss", "**/*.php"]);
  console.log("Watching files... \n");

  //first build
  build();
  //build on changes
  watcher.on("change", () => {
    build();
  });

  //browserSync will trigger livereload when build files are updated
  browserSync.init({
    //TODO: make these values passed in by `npm run dev`
    port: 3334,
    proxy: "localhost:3333",
    files: ["assets/build/*"],
  });
} else {
  //no watch flag, just build it and be done
  build();
}
Enter fullscreen mode Exit fullscreen mode

Bonus! estrella

Estrella is somewhat of a wrapper around esbuild. And while I found esbuild pretty easy to work with, estrella cranks up the simplicity-factor to 11. I liked working with estrella, and I think if you're interested in esbuild but are finding it a bit intimidating, it could be the starting point you need!

Conclusion

The conclusion is that I enjoyed learning about all of these bundlers, they work well, and under much less specific circumstances, all would do pretty well as a bundler choice. In the end, the speed of esbuild is significant enough that it truly changes the development experience in a way that maybe we weren't complaining about before, but might begin to in the future now that we know what's possible.

💖 💪 🙅 🚩
walpolea
Andrew Walpole

Posted on February 13, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related