Which is the better option: Webpack or Browserify & Gulp?

With the increasing complexity of web applications, achieving scalability has become paramount. While ad-hoc JavaScript and jQuery sufficed in the past, modern web app development demands more discipline and formal software development practices. These practices include:

  • Unit tests to prevent new code from breaking existing functionality.
  • Linting to maintain consistent coding style and avoid errors.
  • Separate production builds that are optimized for performance.

Web development also presents unique challenges. For instance, webpages rely heavily on asynchronous requests, and requesting numerous JS and CSS files can hinder performance due to overhead from headers, handshakes, etc. Bundling these files into single JS and CSS files is a common solution.

Bundling tools tradeoffs: Webpack vs Browserify
Which bundling tool should you use: Webpack or Browserify + Gulp? Here is the guide to choosing.

Furthermore, developers often use preprocessors like SASS and JSX that compile to JS and CSS, along with JS transpilers like Babel, to leverage ES6 while ensuring ES5 compatibility.

These tasks, unrelated to the core application logic, can be automated using task runners. Task runners enhance the development environment and streamline workflows, allowing developers to focus on writing code. Once configured, a single terminal command executes these tasks.

This article will utilize Gulp as the task runner due to its user-friendliness, ease of learning, and clear syntax.

A Quick Introduction to Gulp

Gulp’s API comprises four primary functions:

  • gulp.src
  • gulp.dest
  • gulp.task
  • gulp.watch
How Gulp Works

Here’s a simple task illustrating three of these functions:

1
2
3
4
5
6
gulp.task('my-first-task', function() {
  gulp.src('/public/js/**/*.js')
  .pipe(concat())
  .pipe(minify())
  .pipe(gulp.dest('build'))
});

When my-first-task is executed, all JS files within the /public/js directory and its subdirectories are minified and moved to a build folder.

The elegance of Gulp lies in its .pipe() chaining. It takes input files, applies transformations sequentially, and outputs the results. Libraries usually handle the transformations, like minify(), so you rarely need to write custom ones beyond file renaming.

Understanding task dependencies is key to mastering Gulp.

1
2
3
gulp.task('my-second-task', ['lint', 'bundle'], function() {
  ...
});

In this example, my-second-task only executes its callback after the lint and bundle tasks are finished. This promotes separation of concerns, where small, focused tasks handle specific actions like converting LESS to CSS, while a master task orchestrates these sub-tasks through dependencies.

Lastly, gulp.watch monitors files for changes and runs tasks upon detection.

1
2
3
gulp.task('my-third-task', function() {
  gulp.watch('/public/js/**/*.js', ['lint', 'reload'])
})

Here, changes to JS files within /public/js and its subdirectories trigger the lint and reload tasks. Live reloads in the browser, a valuable development feature, are a common use case for gulp.watch.

With that, you’ve grasped the fundamentals of gulp.

Where Does Webpack Fit In?

How Webpack Works

Unlike simple concatenation, bundling JavaScript files using the CommonJS pattern requires resolving dependencies. An entry point, typically named index.js or app.js, contains require or import statements:

ES5

1
2
var Component1 = require('./components/Component1');
var Component2 = require('./components/Component2');

ES6

1
2
import Component1 from './components/Component1';
import Component2 from './components/Component2';

Dependencies must be resolved before executing the remaining app.js code. These dependencies might have their own dependencies, creating a complex tree. Additionally, a dependency might be required in multiple locations but should only be resolved once. Bundlers like Browserify and Webpack address this complexity.

Why Use Webpack over Gulp?

Webpack is a module bundler, while Gulp is a task runner. You might expect them to be used together, but there’s a growing trend, particularly within the React community, to use Webpack instead of Gulp. Why?

Webpack’s capabilities often overlap with those of a task runner. It handles minification, sourcemaps, and can be run as middleware via webpack-dev-server, which supports both live and hot reloading. Loaders further extend its functionality to include ES6 to ES5 transpilation, and CSS pre- and post-processing. This leaves only unit tests and linting as tasks Webpack can’t handle independently, leading many developers to use NPM scripts directly, simplifying their project setup.

However, Webpack’s configuration can be complex, making it less appealing for quick project setups.

Three Task Runner Setups

This article will showcase a project with three different task runner setups, each accomplishing the following:

  • Development server with live reloading.
  • Scalable JS and CSS bundling (including ES6 transpilation, SASS compilation, and sourcemaps).
  • Unit tests (standalone and watch modes).
  • Linting (standalone and watch modes).
  • Single command execution.
  • Production bundle generation with optimizations.

The setups are:

  • Gulp + Browserify
  • Gulp + Webpack
  • Webpack + NPM Scripts

We’ll use React for the front-end, simplifying tasks for the runner as it only requires a single HTML file and works well with CommonJS.

Each setup’s benefits and drawbacks will be analyzed to help you decide which best suits your project.

A Git repository with branches for each approach is available (link). To test each setup:

1
2
3
4
git checkout <branch name>
npm prune (optional)
npm install
gulp (or npm start, depending on the setup)

Let’s explore the code for each branch.

Common Code

Folder Structure

1
2
3
4
5
6
7
8
- app
 - components
 - fonts
 - styles
- index.html
- index.js
- index.test.js
- routes.js

index.html

A simple HTML file loads the React application into a <div> with the id “app.” Only a single bundled JS and CSS file is used (the Webpack development setup won’t even need the separate CSS file).

index.js

This is the entry point for the JavaScript code. It loads React Router into the “app” div defined in the HTML.

routes.js

This file defines the application routes. URLs like /, /about, and /contact are mapped to corresponding React components.

index.test.js

This contains unit tests for native JavaScript behavior. In a production application, you’d typically have unit tests for each React component, but for this example, functional unit tests that can run in watch mode are sufficient.

components/App.js

This component acts as a container for page views. Each page will include a header component and the specific content for that page.

components/home/HomePage.js

This is the home view of the application. It utilizes react-bootstrap to leverage Bootstrap’s grid system for responsive layout.

The remaining components (Header, AboutPage, ContactPage) follow a similar structure.

CSS Styling Approach

This project employs a modular CSS approach with one stylesheet per component, scoping styles to that component. Each component’s top-level div has a class matching the component name. For instance, HomePage.js has:

1
2
3
<div className="HomePage">
  ...
</div>

The corresponding HomePage.scss file looks like this:

1
2
3
4
5
@import '../../styles/variables';

.HomePage {
  // Content here
}

This approach minimizes unintended cascading by isolating component styles.

Consider two components, Component1 and Component2, where we want to override the h2 font size:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
/* Component1.scss */
.Component1 {
  h2 {
    font-size: 30px;
  }
}

/* Component2.scss */
.Component2 {
  h2 {
    font-size: 60px;
  }
}

The h2 font size in each component remains independent regardless of their placement in the markup. This self-contained styling ensures consistent component appearance throughout the application.

In addition to component-specific styles, a styles folder contains a global stylesheet (global.scss) and SASS partials (_fonts.scss and _variables.scss). The global stylesheet defines the overall application style, while partials can be imported by component stylesheets as needed.

Now, let’s delve into the first task runner/bundler combination.

Gulp + Browserify Setup

gulpfile.js

This setup results in a large gulpfile with numerous imports and lines of code. For brevity, we’ll focus on the js, css, server, watch, and default tasks.

JS Bundle

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Browserify specific configuration
const b = browserify({
  entries: [config.paths.entry],
  debug: true,
  plugin: PROD ? [] : [hmr, watchify],
  cache: {},
  packageCache: {}
})
.transform('babelify');
b.on('update', bundle);
b.on('log', gutil.log);

(...)

gulp.task('js', bundle);

(...)

// Bundles our JS using Browserify. Sourcemaps are used in development, while minification is used in production.
function bundle() {
  return b.bundle()
  .on('error', gutil.log.bind(gutil, 'Browserify Error'))
  .pipe(source('bundle.js'))
  .pipe(buffer())
  .pipe(cond(PROD, minifyJS()))
  .pipe(cond(!PROD, sourcemaps.init({loadMaps: true})))
  .pipe(cond(!PROD, sourcemaps.write()))
  .pipe(gulp.dest(config.paths.baseDir));
}

This approach is less than ideal for several reasons. First, the task is fragmented into three parts. The Browserify bundle object b is created with options and event handlers. The Gulp task itself takes a named function as a callback due to how b.on('update') utilizes this callback. This lacks the typical Gulp task elegance of chaining transformations from gulp.src.

Second, it forces different reload approaches for HTML, CSS, and JS in the browser. In the Gulp watch task:

1
2
3
4
5
6
7
8
9
gulp.task('watch', () => {
  livereload.listen({basePath: 'dist'});

  gulp.watch(config.paths.html, ['html']);
  gulp.watch(config.paths.css, ['css']);
  gulp.watch(config.paths.js, () => {
    runSequence('lint', 'test');
  });
});

Changing an HTML file re-runs the html task.

1
2
3
4
5
gulp.task('html', () => {
  return gulp.src(config.paths.html)
  .pipe(gulp.dest(config.paths.baseDir))
  .pipe(cond(!PROD, livereload()));
});

The final pipe calls livereload() unless in a production environment, refreshing the browser.

CSS watches follow the same logic, but JS watches are different. Instead of calling the js task directly, Browserify’s b.on('update', bundle) handles reloads using hot module replacement. This inconsistency, while annoying, is necessary for incremental builds. Simply using livereload() at the end of the bundle function would rebuild the entire JS bundle for any JS file change, leading to performance issues as the project grows.

CSS Bundle

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
gulp.task('css', () => {
  return gulp.src(
    [
      'node_modules/bootstrap/dist/css/bootstrap.css',
      'node_modules/font-awesome/css/font-awesome.css',
      config.paths.css
    ]
  )
  .pipe(cond(!PROD, sourcemaps.init()))
  .pipe(sass().on('error', sass.logError))
  .pipe(concat('bundle.css'))
  .pipe(cond(PROD, minifyCSS()))
  .pipe(cond(!PROD, sourcemaps.write()))
  .pipe(gulp.dest(config.paths.baseDir))
  .pipe(cond(!PROD, livereload()));
});

Vendor CSS inclusion is cumbersome, requiring manual gulpfile modifications for new files instead of adding imports in the source code.

The pipe logic is also convoluted, requiring an external library (gulp-cond) for conditional logic within the pipes, resulting in less readable code.

Server Task

1
2
3
4
5
gulp.task('server', () => {
  nodemon({
    script: 'server.js'
  });
});

This task essentially wraps the command nodemon server.js, running server.js in a Node.js environment. nodemon automatically restarts the process upon file changes. The nodemon.json file limits the scope to prevent restarts on every JS file change.

1
2
3
{
  "watch": "server.js"
}

Let’s review the server code.

server.js

1
2
3
const baseDir = process.env.NODE_ENV === 'production' ? 'build' : 'dist';
const port = process.env.NODE_ENV === 'production' ? 8080: 3000;
const app = express();

This sets the server’s base directory and port based on the environment and creates an Express.js instance.

1
2
app.use(require('connect-livereload')({port: 35729}));
app.use(express.static(path.join(__dirname, baseDir)));

Middleware for live reloading (connect-livereload) and serving static assets is added.

1
2
3
4
5
6
app.get('/api/sample-route', (req, res) => {
  res.send({
    website: 'Toptal',
    blogPost: true
  });
});

A simple API route demonstrates backend integration. Navigating to localhost:3000/api/sample-route displays a response.

1
2
3
4
{
  website: "Toptal",
  blogPost: true
}

A real backend would typically involve a dedicated folder for API routes, separate files for database connections, and more.

1
2
3
app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, './', baseDir ,'/index.html'));
});

This catch-all route serves index.html for any unmatched URL, letting React Router handle client-side routing.

1
2
3
app.listen(port, () => {
  open(`http://localhost:${port}`);
});

This starts the Express.js server on the specified port and opens a new browser tab with the application.

A minor annoyance in the server setup is:

1
app.use(require('connect-livereload')({port: 35729}));

This duplicates the livereload functionality already present in the gulpfile.

Default Task

1
2
3
gulp.task('default', (cb) => {
  runSequence('clean', 'lint', 'test', 'html', 'css', 'js', 'fonts', 'server', 'watch', cb);
});

This task executes when running gulp in the terminal. It uses runSequence to enforce sequential execution, as tasks within an array normally run in parallel. For instance, the clean task needs to precede the html task to ensure empty destination folders. Gulp 4 will natively support gulp.series and gulp.parallel, but for now, we rely on this workaround.

Beyond that, the setup is elegant. A single command sets up and hosts the application. The workflow is easy to grasp by examining individual tasks in the run sequence. Breaking down the sequence into smaller, focused tasks like validate (combining lint and test) or host (combining server and watch) enhances task management as the application grows.

Development vs Production Builds

1
2
3
4
if (argv.prod) {
  process.env.NODE_ENV = 'production';
}
let PROD = process.env.NODE_ENV === 'production';

The yargs library enables passing command-line flags to Gulp. Here, the --prod flag sets the node environment to “production.” The PROD variable differentiates development and production behavior within the gulpfile. For example:

1
plugin: PROD ? [] : [hmr, watchify]

This configures Browserify to disable plugins in production and use hmr and watchify otherwise.

The PROD conditional avoids writing separate Gulp files for different environments, reducing code duplication. It allows running tasks in production mode (e.g., gulp --prod or gulp html --prod). However, conditional logic within pipes using cond(!PROD, livereload()) can impact readability. Ultimately, choosing between a boolean flag and separate gulpfiles is a matter of preference.

Now, let’s explore replacing Browserify with Webpack while keeping Gulp as the task runner.

Gulp + Webpack Setup

The gulpfile significantly shrinks to 99 lines with fewer imports. The default task becomes:

1
2
3
gulp.task('default', (cb) => {
  runSequence('lint', 'test', 'build', 'server', 'watch', cb);
});

The setup now requires only five tasks instead of nine.

The watch task is simplified:

1
2
3
4
5
gulp.task('watch', () => {
  gulp.watch(config.paths.js, () => {
    runSequence('lint', 'test');
  });
});

It no longer triggers rebundling, and transferring index.html is unnecessary.

The html, css, js, and fonts tasks are replaced by a single build task:

1
2
3
4
5
6
7
gulp.task('build', () => {
  runSequence('clean', 'html');

  return gulp.src(config.paths.entry)
  .pipe(webpack(require('./webpack.config')))
  .pipe(gulp.dest(config.paths.baseDir));
});

This task runs clean and html sequentially, then pipes the entry point through Webpack, using a webpack.config.js file for configuration, and outputs the bundle to the appropriate directory.

Let’s examine the Webpack config:

webpack.config.js

1
devtool: PROD ? 'source-map' : 'eval-source-map',

This specifies the sourcemap type. Webpack offers various sourcemap options balancing detail and rebuild speed. We can use a “cheap” option during development for faster reloads and a more detailed option for production.

1
2
3
4
5
entry: PROD ? './app/index' :
[
  'webpack-hot-middleware/client?reload=true', // reloads the page if hot module reloading fails.
  './app/index'
]

This defines the entry point for bundling. The array allows multiple entry points. We include the main application entry point (app/index.js) and webpack-hot-middleware for hot reloading.

1
2
3
4
5
output: {
  path: PROD ? __dirname + '/build' : __dirname + '/dist',
  publicPath: '/',
  filename: 'bundle.js'
},

This sets the output path for the bundled file. The publicPath determines the base URL for the bundle on the server. For example, if publicPath is /public/assets, the bundle will be accessible at /public/assets/bundle.js.

1
2
3
devServer: {
  contentBase: PROD ? './build' : './app'
}

This sets the server’s root directory, determining where static files are served from.

To clarify how Webpack maps files:

  • path + filename: The bundle’s location within the project.
  • contentBase (as the root, /) + publicPath: The bundle’s URL on the server.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
plugins: PROD ?
[
  new webpack.optimize.OccurenceOrderPlugin(),
  new webpack.DefinePlugin(GLOBALS),
  new ExtractTextPlugin('bundle.css'),
  new webpack.optimize.DedupePlugin(),
  new webpack.optimize.UglifyJsPlugin({compress: {warnings: false}})
] :
[
  new webpack.HotModuleReplacementPlugin(),
  new webpack.NoErrorsPlugin()
],

Plugins extend Webpack’s functionality. For example, webpack.optimize.UglifyJsPlugin handles minification.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
loaders: [
  {test: /\.js$/, include: path.join(__dirname, 'app'), loaders: ['babel']},
  {
    test: /\.css$/,
    loader: PROD ?
      ExtractTextPlugin.extract('style', 'css?sourceMap'):
      'style!css?sourceMap'
  },
  {
    test: /\.scss$/,
    loader: PROD ?
      ExtractTextPlugin.extract('style', 'css?sourceMap!resolve-url!sass?sourceMap') :
      'style!css?sourceMap!resolve-url!sass?sourceMap'
  },
  {test: /\.(svg|png|jpe?g|gif)(\?\S*)?$/, loader: 'url?limit=100000&name=img/[name].[ext]'},
  {test: /\.(eot|woff|woff2|ttf)(\?\S*)?$/, loader: 'url?limit=100000&name=fonts/[name].[ext]'}
]

Loaders pre-process files loaded via require(), similar to Gulp pipes.

Here’s a sample loader:

1
{test: /\.scss$/, loader: 'style!css?sourceMap!resolve-url!sass?sourceMap'}

The test property applies the loader to files matching the regex (in this case, SASS files). The loader property defines the action. We chain the style, css, resolve-url, and sass loaders, executed in reverse order.

The loader3!loader2!loader1 syntax, while functional, is less elegant than reading from left to right. Despite this, loaders are a powerful feature. We can import SASS files directly into JavaScript:

index.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import React from 'react';
import {render} from 'react-dom';
import {Router, browserHistory} from 'react-router';
import routes from './routes';
// CSS imports
import '../node_modules/bootstrap/dist/css/bootstrap.css';
import '../node_modules/font-awesome/css/font-awesome.css';
import './styles/global.scss';

render(<Router history={browserHistory} routes={routes} />, document.getElementById('app'));

Similarly, components can import their stylesheets. Webpack bundles the CSS (and potentially images and fonts) into the JS bundle.

This capability significantly streamlines JavaScript development. CSS bundling, minification, and sourcemaps are handled seamlessly, and hot module reloading works with CSS. Managing JS and CSS imports in the same file simplifies development, reducing context switching.

Webpack can also inline images and fonts:

1
2
{test: /\.(svg|png|jpe?g|gif)(\?\S*)?$/, loader: 'url?limit=100000&name=img/[name].[ext]'},
{test: /\.(eot|woff|woff2|ttf)(\?\S*)?$/, loader: 'url?limit=100000&name=fonts/[name].[ext]'}

The URL loader inlines resources under 100KB as data URLs, improving performance.

This summarizes Webpack configuration. While setup might seem daunting, the benefits are substantial. Webpack loaders offer greater flexibility compared to Browserify’s plugins and transforms.

Webpack + NPM Scripts Setup

This setup utilizes NPM scripts directly for task automation, eliminating the need for a gulpfile.

package.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
"scripts": {
  "start": "npm-run-all --parallel lint:watch test:watch build",
  "start:prod": "npm-run-all --parallel lint test build:prod",
  "clean-dist": "rimraf ./dist && mkdir dist",
  "clean-build": "rimraf ./build && mkdir build",
  "clean": "npm-run-all clean-dist clean-build",
  "test": "mocha ./app/**/*.test.js --compilers js:babel-core/register",
  "test:watch": "npm run test -- --watch",
  "lint": "esw ./app/**/*.js",
  "lint:watch": "npm run lint -- --watch",
  "server": "nodemon server.js",
  "server:prod": "cross-env NODE_ENV=production nodemon server.js",
  "build-html": "node tools/buildHtml.js",
  "build-html:prod": "cross-env NODE_ENV=production node tools/buildHtml.js",
  "prebuild": "npm-run-all clean-dist build-html",
  "build": "webpack",
  "postbuild": "npm run server",
  "prebuild:prod": "npm-run-all clean-build build-html:prod",
  "build:prod": "cross-env NODE_ENV=production webpack",
  "postbuild:prod": "npm run server:prod"
}

Development and production builds are triggered using npm start and npm run start:prod, respectively.

This approach is significantly more concise than a gulpfile. However, the commands are less expressive than Gulp tasks. For instance, there’s no direct way to run some commands in series and others in parallel within a single script.

A major advantage is the direct use of NPM libraries like mocha. Instead of installing Gulp wrappers, you install the libraries directly.

Instead of:

  • gulp-eslint
  • gulp-mocha
  • gulp-nodemon
  • etc

We install:

  • eslint
  • mocha
  • nodemon
  • etc

Cory House, in his post Why I Left Gulp and Grunt for NPM Scripts, highlights three key issues with Gulp:

  1. Plugin dependency.
  2. Debugging difficulties.
  3. Fragmented documentation.

1. Plugin Dependency

Gulp plugins rely on their native counterparts to stay updated. Maintainer inactivity or delays can lead to compatibility issues and limit access to new features.

2. Frustrating to Debug

While tools like gulp-plumber improve error handling, Gulp’s error reporting can be cryptic, hindering debugging. Stack traces might not always point to the root cause in the source code.

3. Disjointed Documentation

Smaller Gulp plugins often lack comprehensive documentation, and developers might need to consult both the plugin and native library documentation, increasing the cognitive load.

Conclusion

While all options have their merits, Webpack combined with NPM scripts emerges as a strong contender, minimizing dependencies and streamlining the development process.

Gulp provides greater expressiveness compared to NPM scripts, but at the cost of abstraction. The ideal choice depends on your project’s specific needs. However, if minimizing dependencies and easing debugging are priorities, Webpack with NPM scripts offers a compelling approach.

Licensed under CC BY-NC-SA 4.0