Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Build Tools Implementation Principles: Gulp, Webpack, Rollup, and Vite

Tech May 17 2

Introduction

Modern front-end project development relies heavily on build tools. With numerous options available, selecting the most suitable tool for your specific use case requires understanding not just configuration, but also the evolution of build tools and their underlying mechanisms. This knowledge proves invaluable when troubleshooting build-related isues.

This article covers:

  1. Evolution of front-end build tools
  2. Technical comparison of build tool approaches
  3. Core implementation principles of popular build tools

What is Building?

Building is the process of transforming development environment code into production-ready deployable code. While various build tools exist in the market, they all share the same goal: converting development code into usable production artifacts. Different front-end projects use different technology stacks—various frameworks, different styling solutions—and to generate production-ready JavaScript and CSS, build tools implement features like code transformation, minification, tree shaking, and code splitting.

What Can Front-end Build Tools Do?

Evolution of Front-end Build Tools

Pre-Module Era

YUI Tool + Ant

YUI Tool emerged around 2007 as a build tool capable of minifying and obfuscating front-end code, depending on Java's Ant for execution.

During early web application development, projects primarily used JSP in a mixed development model, unlike modern front-end and back-end separation. Java developers typically wrote JavaScript and CSS code. Build tools at that time depended on other programming languages for implementation.

JavaScript Inline and External

Must front-end code go through a build process to run in browsers? Not necessarily. Consider this example:

<html>
  <head>
    <title>Hello World</title>
  </head>
  <body>
    <div id="root"/>
    <script type="text/javascript">
      document.getElementById('root').innerText = 'Hello World'
    </script>
  </body>
</html>

With just HTML tags and simple JavaScript, opening the browser displays a Hello World page. However, when projects enter real-world development with rapidly expanding codebases, maintaining numerous logic blocks in a single file becomes challenging. Early front-end projects typically organized code like this:

<html>
  <head>
    <title>jQuery</title>
  </head>
  <body>
    <div id="root"/>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <script type="text/javascript">
      $(document).ready(function(){
        $('#root')[0].innerText = 'Hello World'
      })
    </script>
  </body>
</html>

Organizing code through inline and external JavaScript placed different code in separate files, addressing code organization issues but still presenting challenges:

  • Excessive global variables with opaque dependencies—any code could silently modify global state
  • Script loading order dependencies

Solutions like IIFE and namespacing emerged later, but none fundamentally solved the communication-through-global-variables problem. Front-end engineering became the standard approach to address these issues.

Community Module Era

AMD/CMD - Asynchronous Module Loading

To solve JavaScript modularization in browsers, tools and libraries emerged with two widely-adopted specifications: AMD (RequireJS) and CMD (Sea.js). AMD advocates dependency pre-declaration and eager execution, while CMD advocates lazy dependency loading and deferred execution.

Require.js example:

// After jQuery loads, the result $ is passed as a parameter to the callback
define(["jquery"], function ($) {
    $(document).ready(function(){
        $('#root')[0].innerText = 'Hello World';
    })
    return $
})

Sea.js example:

// Preload jQuery
define(function(require, exports, module) {
    // Execute jQuery module and assign result to $
    var $ = require('jquery');
    // Call methods provided by jQuery.js module
    $('#header').hide();
});

Both module specifications share essentially the same implementation principles, differing only in philosophy. Both asynchronously fetch required modules, with AMD executing immediately upon receipt while CMD delays execution until the module is actually needed.

AMD/CMD Solutions:

  1. Manual dependency ordering eliminated - No need to manually adjust script order in HTML; dependency arrays automatically detect module relationships and inject scripts in the correct order.
  2. Global variable pollution resolved - Module content executes within functions, using closures to export variables, preventing global namespace pollution.
Grunt/Gulp

After Google Chrome released the V8 engine, Node.js—a JavaScript runtime based on V8's high-performance, platform-independent characteristics—emerged. JavaScript broke free from browser limitations, gaining file system access capabilities. Node.js not only dominated server-side development but also propelled front-end engineering forward.

Against this backdrop, the first generation of Node.js-based build tools appeared.

Grunt

Grunt helps automate repetitive tasks like minification, compilation, unit testing, and linting.

// Gruntfile.js
module.exports = function(grunt) {
  // Feature configuration
  grunt.initConfig({
    // Define tasks
    jshint: {
      files: ['Gruntfile.js', 'src/**/*.js', 'test/**/*.js'],
      options: {
        globals: {
          jQuery: true
        }
      }
    },
    // Watch for file changes and execute tasks
    watch: {
      files: ['<%= jshint.files %>'],
      tasks: ['jshint']
    }
  });
  // Load required task plugins
  grunt.loadNpmTasks('grunt-contrib-jshint');
  grunt.loadNpmTasks('grunt-contrib-watch');
  // Default task to execute
  grunt.registerTask('default', ['jshint']);
};

Gulp

Grunt's I/O operations are somewhat rigid—each task writes files to disk, and the next task reads from disk, causing:

  1. Slow execution speed
  2. High hardware pressure

Gulp's defining feature is introducing the stream concept with a suite of common plugins to process streams, which can pass between plugins. Gulp's simple design allows standalone use or integration with other tools.

// gulpfile.js
const { src, dest } = require('gulp');
// APIs provided by gulp
// src reads files
// dest writes files
const babel = require('gulp-babel');

exports.default = function() {
  // Take all JS files from src folder, transform through Babel, output to output folder
  return src('src/*.js')
    .pipe(babel())
    .pipe(dest('output/'));
}

Browserify

With Node.js's rise, the CommonJS module specification became the主流规范. However, CommonJS uses synchronous require syntax—when code reaches a require statement, it must wait for module loading before continuing. This works well on servers where files are read from local disk quickly, but in browsers, network requests for files can cause unresponsiveness based on network conditions and file sizes.

Browserify打包 produces browser-runnable CommonJS JavaScript.

var browserify = require('browserify')
var b = browserify()
var fs = require('fs')

// Add entry file
b.add('./src/browserifyIndex.js')
// Bundle all modules into one file and output
b.bundle().pipe(fs.createWriteStream('./output/bundle.js'))

How does Browserify work?

During execution, Browserify performs AST analysis to determine inter-module dependencies, generating a dependency dictionary. It then wraps each module, passing the dependency dictionary along with custom export and require implementations, ultimately generating a JavaScript file executable in browser environments.

Browserify focuses solely on JavaScript bundling and typically works alongside Gulp.

ESM Specification Emergence

In 2015, JavaScript's official module system arrived, though browsers had minimal support and the specification only defined implementation guidelines.

Webpack

Webpack actually appeared before the ESM standard gained traction but didn't gain popularity initially. Webpack's philosophy leans toward engineering, and with MVC frameworks and ESM emerging, Webpack 2 launched with support for AMD, CommonJS, ESM, CSS/LESS/SASS/Stylus, Babel, TypeScript, JSX, Angular 2 components, and Vue components. No tool had ever supported so many features, addressing nearly all build-related issues. Webpack became the core of front-end engineering.

Webpack is configuration-based.

module.exports = {
    // SPA entry file
    entry: 'src/js/index.js',
    // Output
    output: {
      filename: 'bundle.js'
    }
    // Module matching and processing—mostly compilation
    module: {
        rules: [
            // Babel syntax transformation
            { test: /.js$/, use: 'babel-loader' },
            //...
        ]
    },
    // Plugins
    plugins: [
        // Create HTML file from template
        new HtmlWebpackPlugin({ template: './src/index.html' }),
    ],
}

Webpack's broad feature support exposes certain drawbacks:

  • Complex configuration increases developer cognitive load
  • To support both CJS and ESM, Webpack implements polyfills, resulting in "ugly" output code

Two years after Webpack, Rollup emerged.

Rollup

Rollup is a forward-looking build tool that bundles entirely based on ESM specifications, pioneering Tree-Shaking. With simple, easy-to-understand configuration, it became the most popular JavaScript library bundler.

import resolve from 'rollup-plugin-node-resolve';
import babel from 'rollup-plugin-babel';

export default {
  // Entry file
  input: 'src/main.js',
  output: {
    file: 'bundle.js',
    // Output module format
    format: 'es'
  },
  plugins: [
    // Transform CommonJS modules to ESM
    resolve(),
    // Babel syntax transformation
    babel({
      exclude: 'node_modules/**'
    })
  ]
}

Built on ESM, Rollup implements powerful Tree-Shaking, producing concise, minimal bundle sizes. However, browser compatibility requires additional polyfill libraries or integration with Webpack.

ESM Specification Native Support

Esbuild

As project scale grows, front-end engineering build times increase—some projects take minutes or even tens of minutes. This has drawn increasing attention to bundler performance.

Esbuild is a novel module bundler providing Webpack-like bundling capabilities with exceptional performance.

Esbuild supports ES6/CommonJS specifications, Tree Shaking, TypeScript, JSX, and more, offering JavaScript API, Go API, and CLI interfaces.

// JavaScript API usage
require('esbuild').build({
    entryPoints: ['app.jsx'],
    bundle: true,
    outfile: 'out.js',
  }).catch(() => process.exit(1))

Performance benchmarks show improvements of over 100x. Why so fast?

Language advantages:

  • Esbuild uses Go, whereas previous build tools used JavaScript via Node. JavaScript is an interpreted language—despite V8's Just-In-Time compilation optimizations, performance bottlenecks remain. Go is a compiled language where source translates to machine code at compile time, requiring only direct execution at startup.
  • Go inherently supports multi-threading, while JavaScript is single-threaded. Esbuild carefully implements complete parallel processing for parsing, code generation, and other phases.

Performance-first principle:

  • Esbuild provides only the minimal feature set for modern web applications, resulting in lower architectural complexity and easier performance optimization
  • With Webpack and Rollup, developers use various third-party tools like Babel, ESLint, and LESS. Code passing through multiple tools creates performance waste through repeated AST transformations. Esbuild rewrites these tools with custom implementations, sacrificing some maintainability for extreme compilation performance

Despite high performance, Esbuild's features are basic—unsuitable for direct production use. It serves better as a foundational module bundler for secondary encapsulation.

Vite

Vite is the next-generation front-end development and build tool, providing no-bundle development services with rich built-in features and minimal configuration.

Vite handles development and production environments differently: development uses Esbuild for speed, production uses Rollup for bundling.

Why is Vite's development server so fast?

Traditional bundle-based services:

  • Both Webpack and Rollup provide developer services based on build results
  • Serving based on build results requires completion before serving, with wait times increasing as projects grow

NoBundle services:

  • Tools like Vite and Snowpack provide no-bundle services—start serving immediately without waiting for builds
  • Third-party dependencies undergo rebuilding only on initial startup or dependency changes—a dependency pre-bundling process using Esbuild for fast builds
  • Project code leverages browser ESM support for on-demand access without full builds

Why use Rollup for production builds?

  • Browser compatibility issues and potential RTT overhead from ESM in real networks still require bundling
  • Esbuild lacks a stable 1.0 release and has weak support for code splitting and CSS processing, so production still uses Rollup

Vite 3.0 is now released, fixing over 400 issues from 2.0 and becoming stable enough for production. Vite plans annual major releases.

vite.config.js:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import {resolve} from 'path';

// defineConfig primarily provides syntax highlighting; it has no runtime effect
export default defineConfig({
  resolve:{
    alias:{
     '@':resolve('src')
    }
  },
  plugins: [vue()]
})

Technical Comparison

Front-end build tools vary significantly. Here's a comparative overview of popular tools from an engineering perspective, excluding specialized single-purpose tools.

2021 Front-end Build Tool Rankings

Notable Questions

Why Does Webpack Output Look "Ugly"?

After building with Webpack, output code appears "ugly" because Webpack supports multiple module specifications but ultimately converts everything to CommonJS (Webpack 5 optimizes pure ESM). Since browsers don't support CommonJS natively, Webpack implements its own require and module.exports, injecting significant polyfill code.

Analyzing output for common.js requiring common.js.

Source code:

// src/index.js
let title = require('./title.js')
console.log(title);

// src/title.js
module.exports = 'bu';

Output code:

(() => {
    // Store all module definitions in modules object
    // Key is module ID—relative path from root with file extension
    // Value is module definition function containing original module code
   var modules = ({
     "./src/title.js": ((module) => {
       module.exports = 'bu';
     })
   });
   // Cache object
   var cache = {};

   // Webpack implements a require method based on CommonJS specification
   function require(moduleId) {
     var cachedModule = cache[moduleId];
     if (cachedModule !== undefined) {
       return cachedModule.exports;
     }
     // Create and cache module object
     var module = cache[moduleId] = {
       exports: {}
     };
     // Execute module code
     modules[moduleId](module, module.exports, require);
     return module.exports;
   }
   var exports = {};
   (() => {
     // Entry-related code
     let title = require("./src/title.js")
     console.log(title);
   })();
})();

How Do Webpack Lazy-Loaded Modules Run in Browsers?

In real projects, as code grows, bundles become large. Code splitting loads specific sections on-demand after user triggers. How does Webpack implement this at runtime?

The principle is simple: load lazy-loaded scripts via JSONP. The interesting part is how asynchronous modules are utilized.

Analyzing a simple case.

Source code:

// index.js
import("./hello").then((result) => {
    console.log(result.default);
});

// hello.js
export default 'hello';

Output code:

main.js

// Simplified and optimized code for readability
// Define modules object
var modules = ({});

// Webpack's require implementation in browser
function require(moduleId) {xxx}

/**
 * chunkIds - array of chunk IDs
 * moreModules - chunk module definitions
*/
function webpackJsonpCallback([chunkIds, moreModules]) {
  const result = [];
  for(let i = 0 ; i < chunkIds.length ; i++){
    const chunkId = chunkIds[i];
    result.push(installedChunks[chunkId][0]);
    installedChunks[chunkId] = 0; // Chunk downloaded
  }

  // Merge chunk into modules object
  for(const moduleId in moreModules){
    modules[moduleId] = moreModules[moduleId];
  }
  // Resolve promises in require.e
  while(result.length){
    result.shift()();
  }
}

// Store chunk loading states; key is chunk name
// At least main chunk produced
// 0 means loaded and ready
var installedChunks = {
  "main": 0
}

require.d = (exports, definition) => {
  for (var key in definition) {
    Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
  }
};
require.r = (exports) => {
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
  Object.defineProperty(exports, '__esModule', { value: true });
};

// Attach modules object to require.m
require.m = modules;

require.f = {};

// JSONP to load lazy-loaded module
require.l = function (url) {
  let script = document.createElement("script");
  script.src = url;
  document.head.appendChild(script);
}

// Load chunk via JSONP
require.f.j = function(chunkId, promises){
  let installedChunkData;
  const promise = new Promise((resolve, reject) => {
    installedChunkData = installedChunks[chunkId] = [resolve, reject];
  });
  promises.push(installedChunkData[2] = promise);
  const url = chunkId + '.main.js';

  require.l(url);
}

require.e = function(chunkId) {
  let promises = [];
  require.f.j(chunkId, promises);
  console.log(promises);
  return Promise.all(promises);
}

var chunkLoadingGlobal = window['webpack'] = [];
// JSONP callback after lazy-loaded module loads
chunkLoadingGlobal.push = webpackJsonpCallback;

/**
 * require.e asynchronously loads hello chunk (hello.main.js)
 * On promise success, hello.main.js definitions merge into require.m (modules)
 * require loads ./src/hello.js and prints exported object
 */
require.e('hello').then(require.bind(require, './src/hello.js')).then(result => console.log(result));

hello.main.js

"use strict";
(self["webpack"] = self["webpack"] || []).push([
  ["hello"], {
    "./src/hello.js": ((module, exports, require) => {
      require.r(exports);
      require.d(exports, {
        "default": () => (_DEFAULT_EXPORT__)
      });
      const _DEFAULT_EXPORT__ = ("hello");
    })
  }
]);

Webpack declares a global variable webpack as an array, then overrides the array's push method. When async code executes after loading, it calls this push method, placing async modules in the global module container for use.

Simplified Webpack Build Process

Today, Webpack's feature set is extensive with massive codebase—learning costs are high. Understanding the build process is necessary to grasp how outputs are generated and troubleshoot issues.

Implementation approach:

class Compilation {
    constructor(options) {
        this.options = options;
        // All modules generated in this compilation
        this.modules = [];
        // All chunks produced in this compilation
        this.chunks = [];
        // Resource files produced in this compilation
        this.assets = {};
    }
    build(callback) {
        // Find all entry points from configuration
        let entry = {xxx: 'xxx'};

        // From entry files, apply all configured loader rules to compile modules
        for(let entryName in entry){
            // Compile entry module using configured loaders
            const entryModule = this.buildModule(entryName, entryFilePath);
            this.modules.push(entryModule);

            // After all modules compile, assemble modules into chunks based on dependencies
            let chunk = {
                name: entryName,
                entryModule,
                modules: this.modules.filter((module) => module.names.includes(entryName))
            };
            this.chunks.push(chunk);
        }

        // Convert chunks into files (assets) for output
        this.chunks.forEach((chunk) => {
            const filename = this.options.output.filename.replace('[name]', chunk.name);
            this.assets[filename] = getSource(chunk);
        });
        callback(null, {
            modules: this.modules,
            chunks: this.chunks,
            assets: this.assets
        }, this.fileDependencies);
    }

    // Compile module; pass chunk name to identify which chunk the module belongs to
    buildModule(name, modulePath) {
         // Read module content
         let sourceCode = fs.readFileSync(modulePath, 'utf8');
         // Create module object
         let module = {
             id: moduleId,
             names: [name],
             dependencies: [],
         }

         // Find matching loaders, apply right-to-left, transform to JavaScript
         sourceCode = loaders.reduceRight((sourceCode, loader) => {
             return require(loader)(sourceCode);
         }, sourceCode);

         // Find dependent modules, recurse until all entry dependencies are processed
         let ast = parser.parse(sourceCode, { sourceType: 'module' });
         traverse(ast, {});
         let { code } = generator(ast);

         // Store transformed source in module._source
         module._source = code;
         // Recurse for dependencies
         module.dependencies.forEach(({ depModuleId, depModulePath }) => {
            const depModule = this.buildModule(name, depModulePath);
            this.modules.push(depModule)
         });

         return module;
    }
}

function getSource(chunk) {
    return `
     (() => {
      var modules = {
        ${chunk.modules.map(
          (module) => `
          "${module.id}": (module) => {
            ${module._source}
          }
        `
      )}
      };
      var cache = {};
      function require(moduleId) {
        var cachedModule = cache[moduleId];
        if (cachedModule !== undefined) {
          return cachedModule.exports;
        }
        var module = (cache[moduleId] = {
          exports: {},
        });
        modules[moduleId](module, module.exports, require);
        return module.exports;
      }
      var exports ={};
      ${chunk.entryModule._source}
    })();
     `;
}

class Compiler {
    constructor(options) {
        this.options = options;
        this.hooks = {
            run: new SyncHook(),
            done: new SyncHook(),
        }
    }

    run() {
        // Trigger run hook at compilation start
        this.hooks.run.call();
        const onCompiled = (err, stats, fileDependencies) => {
            // After determining output content, write files to filesystem based on configuration
            for(let filename in stats.assets) {
                fs.writeFileSync(filePath,stats.assets[filename], 'utf8' );
            }
            // Trigger done hook on successful compilation
            this.hooks.done.call();
        }
        this.compile(onCompiled);
    }

    compile(callback) {
        // Webpack has one Compiler, but each compilation produces a new Compilation
        // Compilation stores files, chunks, and modules from this compilation
        // Watch mode triggers multiple compilations
        let compilation = new Compilation(this.options);
        compilation.build(callback);
    }
}

function webpack(options) {
    // Initialize: read and merge options from config and CLI
    let finalOptions = {...options, ...shellOptions};

    // Initialize Compiler with options; single compiler for entire build process
    const compiler = new Compiler(finalOptions);

    // Load all configured plugins
    const { plugins } = finalOptions;
    for(let plugin of plugins){
        plugin.apply(compiler);
    }

    return compiler;
}

// webpackOptions - Webpack configuration
const compiler = webpack(webpackOptions);
compiler.run();

Why Is Rollup Output Clean?

  • Rollup bundles only ESM modules—CommonJS modules transform to ESM via plugins, avoiding Webpack's code injection
  • Rollup supports multiple output formats (esm, cjs, am), but doesn't guarantee runtime compatibility—relies on environment support. ESM output depends on modern browser native ESM support without injection
  • Rollup implemants powerful tree-shaking

Why Does Vite Run Code Directly in Browsers?

As mentioned, Vite development requires no bundling—browsers' ESM support allows direct access to component code. However, component files aren't pure JavaScript—they're .ts, .tsx, .vue files browsers can't parse. What does Vite do?

Let's analyze accessing a Vue component:

// index.html

<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + Vue</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

// /src/main.js
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app');

// src/App.vue
<template>
  <h1>Hello</h1>
</template>

Opening in browser shows requests for the entry file:

Third-party package paths change—they become Vue files under node_modules/.vite, the pre-bundled dependency cache.

Browser also requests App.vue with JavaScript response:

// Simplified response (removed HMR code)
const _sfc_main = {
    name: 'App'
}
// Vue APIs for creating blocks and virtual DOM
import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/vue.js?v=b618a526"

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
    return (_openBlock(), _createElementBlock("h1", null, "App"))
}
// Component render method
_sfc_main.render = _sfc_render;
export default _sfc_main;

Summary: When users access Vite's development server, for files browsers can't directly recognize, middleware transforms them into browser-compatible formats, ensuring normal access.

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Comprehensive Guide to Hive SQL Syntax and Operations

This article provides a detailed walkthrough of Hive SQL, categorizing its features and syntax for practical use. Hive SQL is segmented into the following categories: DDL Statements: Operations on...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.