The Pre-Webpack Era: Task Runners and the Dawn of Bundling
In the landscape of modern frontend development, where a single command like `vite` or `npm run dev` can instantaneously spin up a development server with Hot Module Replacement (HMR), it is difficult to recall a time when the process was fundamentally different. Yet, the era preceding Webpack’s ascendance was not one of stagnation, but of fervent experimentation and incremental innovation. This chapter excavates that foundational period, examining the architectural DNA of the tools that defined the pre-Webpack workflow: task runners like Grunt and Gulp, and early module bundlers such as Browserify and RequireJS. Our analysis will not be a nostalgic recap but a forensic dissection of their design patterns, inherent limitations, and the specific, acute pain points they created for developers. By understanding what these tools *could not* do, we illuminate the precise architectural voids that Webpack’s unified dependency graph would later fill, and more importantly, the persistent complexities that would eventually catalyze Vite’s disruptive approach. The narrative is one of fragmentation—of workflows, asset types, and mental models—and the slow, grinding pursuit of unification.
The File-Based Workflow Paradigm: Grunt and the Automation of Repetition
The mid-2010s frontend build process was, at its core, a series of discrete file transformations. You had Sass files that needed compiling to CSS, ES6+ JavaScript that required transpilation via Babel, images that demanded optimization, and concatenated scripts and stylesheets for production. The primary problem was not module resolution per se, but *orchestration*. The seminal solution was Grunt, launched in 2012 by Ben Alman. Grunt’s philosophy was explicit configuration over convention. Its core unit of work was the task, a plugin-defined operation like `grunt-contrib-sass` or `grunt-contrib-uglify`. A `Gruntfile.js` served as a declarative manifest, wiring these tasks into a linear or parallel pipeline.
```javascript // Gruntfile.js: A典型配置示例,展示其基于文件的串联逻辑 module.exports = function(grunt) { grunt.initConfig({ pkg: grunt.file.readJSON('package.json'), sass: { options: { sourceMap: true }, dist: { files: { 'dist/css/main.css': 'src/scss/main.scss' } } }, babel: { options: { sourceMap: true }, dist: { files: { 'dist/js/app.js': 'src/js/app.js' } } }, uglify: { dist: { files: { 'dist/js/app.min.js': ['dist/js/app.js'] } } }, cssmin: { dist: { files: { 'dist/css/main.min.css': ['dist/css/main.css'] } } } }); grunt.loadNpmTasks('grunt-contrib-sass'); grunt.loadNpmTasks('grunt-contrib-babel'); grunt.loadNpmTasks('grunt-contrib-uglify'); grunt.loadNpmTasks('grunt-contrib-cssmin'); grunt.registerTask('default', ['sass', 'babel', 'uglify', 'cssmin']); }; ```
This model was revolutionary in its time, bringing much-needed automation to a previously manual process. However, its architecture was fundamentally file-to-file. Each task operated on a specific set of input files and produced a new set of output files. There was no global awareness of the project's dependency structure. The `sass` task had no knowledge that `main.scss` `@import`ed `_variables.scss`; it simply read the entry file and wrote the output. This led to two critical, interconnected problems.
First, inefficient rebuilds. Grunt’s default behavior was to re-run *all* configured tasks whenever any watched file changed, or to rely on simplistic file modification timestamps. If you changed a single variable in a deeply imported Sass partial, Grunt would still re-run the entire Sass compilation, Babel transpilation, and minification pipeline from scratch. In a medium-sized project, this could mean waiting several seconds for a trivial change to propagate. The lack of a fine-grained dependency graph meant the tool could not determine the minimal set of affected downstream assets. Second, and more subtly, this paradigm enforced a separation of concerns that was at odds with modern development. The developer had to manually manage the relationship between source and output files across multiple tasks. The `babel` task’s output (`dist/js/app.js`) became the `uglify` task’s input. This manual chaining was brittle. If you reordered tasks in the `default` array or forgot to update a file path, the pipeline broke. The mental model was a linear assembly line, not a dynamic graph of interdependent resources.
Gulp, introduced in 2013, sought to address these performance and conceptual issues by embracing Node.js streams. Its mantra was “code over configuration.” Instead of defining file mappings in a static config, you wrote Node.js functions using `gulp.src()`, `.pipe()`, and `gulp.dest()`. This stream-based approach promised better performance (less disk I/O, as files flowed through plugins in memory) and more intuitive, programmable pipelines.
```javascript // gulpfile.js: 基于流的管道,逻辑更直观,但本质仍是文件流 const { src, dest, watch, series } = require('gulp'); const sass = require('gulp-sass')(require('sass')); const babel = require('gulp-babel'); const uglify = require('gulp-uglify'); const sourcemaps = require('gulp-sourcemaps');
function styles() { return src('src/scss/**/*.scss') .pipe(sourcemaps.init()) .pipe(sass().on('error', sass.logError)) .pipe(sourcemaps.write('.')) .pipe(dest('dist/css')); }
function scripts() { return src('src/js/**/*.js') .pipe(sourcemaps.init()) .pipe(babel({ presets: ['@babel/env'] })) .pipe(uglify()) .pipe(sourcemaps.write('.')) .pipe(dest('dist/js')); }
function watchFiles() { watch('src/scss/**/*.scss', styles); watch('src/js/**/*.js', scripts); }
exports.default = series(styles, scripts, watchFiles); ```
While a marked improvement in ergonomics and speed for simple pipelines, Gulp did not solve the core architectural deficiency: the absence of a unified dependency graph. The `scripts` task still operated on a glob pattern (`src/js//*.js`) and output a single concatenated file. It had no concept of `import` statements between JavaScript files. If `app.js` imported `utils.js`, changing `utils.js` would cause Gulp’s watcher to re-run the entire `scripts` pipeline on *all* matched JavaScript files, not just `app.js` and its dependents. The stream metaphor, while powerful for linear data transformation, was ill-suited for modeling a directed acyclic graph (DAG) of module dependencies. Both Grunt and Gulp were task runners, not bundlers**. Their domain was file orchestration, not module resolution and packaging. They automated the *how* of file conversion but left the *what* and *why* of module relationships entirely to the developer’s manual management, often via global namespace pollution or brittle script tag ordering in HTML.
The Dawn of Module Awareness: Browserify and the CommonJS Revolution
The breakthrough that shifted the paradigm from file concatenation to true module bundling came from an unlikely source: the Node.js ecosystem. Node had long used the CommonJS module format (`require()` and `module.exports`), a synchronous, server-side system for encapsulating code. Browserify, created by James Halliday (substack) in 2011, was the audacious project that asked: “What if we could make the browser understand CommonJS?” Its genius was in static analysis. Browserify would start from a designated entry file (e.g., `main.js`), parse its Abstract Syntax Tree (AST) to find all `require()` calls, recursively resolve those dependencies, and then bundle all identified modules into a single, self-executing JavaScript file that could run in the browser.
```javascript // Browserify 的核心工作流概念图 // graph TD // A[入口文件 main.js] --> B[AST 解析]; // B --> C{发现 require('./utils')}; // C --> D[解析 utils.js]; // D --> E[递归遍历依赖树]; // E --> F[所有模块打包]; // F --> G[生成 bundle.js]; // G --> H[浏览器执行]; ```
This was a monumental leap. For the first time, the build tool