Simple, Tear Free Webpack Config with ES6 and SCSS

If there are two things that webpack is good for, it’s transpiling and packing code into nice, neat bundles for use on the web, node, cordova, electron, etc and making developers re-evaluate their life choices.

In this tutorial, we will attempt to focus on the former and mitigate the latter.

What we’ll be building

A webpack configuration that accomplishes the following:

  • Transpiles ES6 into compatible javascript for the web
  • Separates dependencies from application code into a different file for caching purposes
  • Generates a css file using SCSS
  • Takes a template html file and injects it with generated bundle files
  • Generates visual stats about the build for further optimization
  • Minifies all code and adds content based hashes to filenames in production

Lets get started!

This tutorial assumes that you have node installed. If you don’t, I highly recommend NVM. It makes managing node versions and switching back and forth between versions super easy.

All of the code for this tutorial is available at this GitHub repo. You can clone the repo or start from scratch and follow along. Here we go!

// First, create a directory for this tutorial
mkdir simple-webpack-demo && cd simple-webpack-demo

We like to use yarn but you can use npm if you like. We’ll make sure to provide both yarn and npm snippets using a format like:

yarn command || npm command

Just choose your favorite side!

// Initialize your project
yarn init || npm init

Now that you have a package.json let’s add some scripts.

"scripts": {
  "dev": "rimraf dist && webpack --watch",
  "prod": "rimraf dist && cross-env NODE_ENV=production webpack"
},

Go ahead and make any other changes you like to your package.json like updating the name, version, license, etc.

Add a .gitignore and include the following:

node_modules
.DS_Store
dist

Now, let’s add some dependencies!

yarn add --dev buble-loader cross-env css-loader extract-text-webpack-plugin html-webpack-plugin jquery moment node-sass raf rimraf sass-loader webpack webpack-bundle-analyzer
||
npm install --save-dev buble-loader cross-env css-loader extract-text-webpack-plugin html-webpack-plugin jquery moment node-sass raf rimraf sass-loader webpack webpack-bundle-analyzer

Finally, let’s add our webpack config and some files to pack with webpack. Our directory structure should look something like this:

|src/
|  |js/
|  |  |demo.js
|  |  |index.js
|  |scss/
|  |  |_global.scss
|  |  |_reset.scss
|  |  |_vars.scss
|  |  |index.scss
|  |index.template.html
|.gitignore
|package.json
|webpack.config.js

The files in src/ are mostly inconsequential demo stuff. The important things to note are that in the .js files we will be able to use ES6 features like classes and module imports/exports. We’re requiring our main scss file in the main javascript file (Kind of weird, but don’t worry. Webpack will sort all of that out).

Let’s get all of the code copied into their files and then we’ll regroup and break down the webpack config.

Begin copy paste frenzy!

// src/js/demo.js

import moment from 'moment'
import raf from 'raf'
import $ from 'jquery'

raf.polyfill()

export default class Demo {
    constructor() {
        this.$date = $('#date')
        this.$time = $('#time')
        this.$timeToggle = $('#time-toggle')
        this.timeRunning = false
        this.timeHandle = null

        this.$timeToggle.on('click', this.toggleTime.bind(this))
    }
    moment(format) {
        return moment(Date.now()).format(format)
    }
    setDate() {
        this.$date.text(this.moment('MMM Do, YYYY'))
    }
    setTime() {
        this.$time.text(this.moment('h:mm:ss a'))
        this.timeHandle = raf(this.setTime.bind(this))
    }
    startTime() {
        this.timeHandle = raf(this.setTime.bind(this))
        this.$timeToggle.text('Stop Time')
        this.timeRunning = true
    }
    stopTime() {
        raf.cancel(this.timeHandle)
        this.$timeToggle.text('Start Time')
        this.timeRunning = false
    }
    toggleTime(e) {
        if (e) {
            e.preventDefault()
        }
        this.timeRunning ? this.stopTime() : this.startTime()
    }
}
// src/js/index.js

import $ from 'jquery'
import Demo from './demo.js'

require('../scss/index.scss')

$(document).ready(()=> {
    const demo = new Demo()
    demo.setDate()
    demo.startTime()
})
// src/scss/_global.scss

body {
    font-family: $body-font;
    background-color: $body-background;
    color: $body-color;
    font-size: 100%;
}

a {
    color: $primary;
    text-decoration: none;
    &:hover {
        color: $secondary;
    }
}

strong {
    font-weight: 500;
}

header {
    background-color: $header-background;
    color: $header-color;
    padding: $standard-padding;
    font-size: 2rem;
    font-weight: bold;
}

main {
    padding: $standard-padding;
    font-size: 1.2rem;
}
// src/scss/_reset.scss

/*
html5doctor.com Reset Stylesheet
v1.6.1
Last Updated: 2010-09-17
Author: Richard Clark - http://richclarkdesign.com
Twitter: @rich_clark
*/

html, body, div, span, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
abbr, address, cite, code,
del, dfn, em, img, ins, kbd, q, samp,
small, strong, sub, sup, var,
b, i,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, figcaption, figure,
footer, header, hgroup, menu, nav, section, summary,
time, mark, audio, video {
    margin:0;
    padding:0;
    border:0;
    outline:0;
    font-size:100%;
    vertical-align:baseline;
    background:transparent;
}

body {
    line-height:1;
}

article,aside,details,figcaption,figure,
footer,header,hgroup,menu,nav,section {
    display:block;
}

nav ul {
    list-style:none;
}

blockquote, q {
    quotes:none;
}

blockquote:before, blockquote:after,
q:before, q:after {
    content:'';
    content:none;
}

a {
    margin:0;
    padding:0;
    font-size:100%;
    vertical-align:baseline;
    background:transparent;
}

/* change colours to suit your needs */
ins {
    background-color:#ff9;
    color:#000;
    text-decoration:none;
}

/* change colours to suit your needs */
mark {
    background-color:#ff9;
    color:#000;
    font-style:italic;
    font-weight:bold;
}

del {
    text-decoration: line-through;
}

abbr[title], dfn[title] {
    border-bottom:1px dotted;
    cursor:help;
}

table {
    border-collapse:collapse;
    border-spacing:0;
}

/* change border colour to suit your needs */
hr {
    display:block;
    height:1px;
    border:0;
    border-top:1px solid #cccccc;
    margin:1em 0;
    padding:0;
}

input, select {
    vertical-align:middle;
}
// src/scss/_vars.scss

$white: #eee;
$black: #111;

$primary: green;
$secondary: purple;

$header-background: $black;
$header-color: $white;
$body-font: 'Roboto', sans-serif;
$body-background: $white;
$body-color: $black;

$standard-padding: 20px;
// src/scss/index.scss

@import 'reset';
@import 'vars';
@import 'global';
// src/index.template.html

<!DOCTYPE html>
<html>
    <head>
        <title>ES6 Webpack Demo</title>
        <meta charset="utf-8">
        <link href="https://fonts.googleapis.com/css?family=Roboto:400,500,700" rel="stylesheet">
        <% for (var chunk of webpack.chunks) {
            for (var file of chunk.files) {
                if (file.match(/\.(js|css)$/)) { %>
                    <link rel="<%= chunk.initial?'preload':'prefetch' %>" href="<%= htmlWebpackPlugin.files.publicPath + file %>" as="<%= file.match(/\.css$/)?'style':'script' %>">
                <% }
            }
        } %>
    </head>
    <body>
        <header>
            <h1>ES6 Webpack Demo</h1>
        </header>
        <main>
            <div><strong>Today's date is</strong>: <span id="date"></span></div>
            <br>
            <div><strong>The current time is</strong>: <span id="time"></span></div>
            <br>
            <a href="#!" id="time-toggle">Stop Time</a>
        </main>
    </body>
</html>
// webpack.config.js

const path = require('path')
const webpack = require('webpack')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const HTMLPlugin = require('html-webpack-plugin')
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
const isProd = process.env.NODE_ENV === 'production'
const config = {
    devtool: '#eval-source-map',
    entry: './src/js/index.js',
    output: {
        filename: isProd ? '[name].[chunkhash].js' : '[name].js',
        path: path.resolve(__dirname, 'dist')
    },
    module: {
        rules: [{
            test: /\.js$/,
            loader: 'buble-loader',
            exclude: /node_modules/,
            options: {
                objectAssign: 'Object.assign'
            }
        }, {
            test: /\.scss$/,
            loader: ExtractTextPlugin.extract(['css-loader', 'sass-loader'])
        }]
    },
    plugins: [
        new ExtractTextPlugin(isProd ? 'index.[chunkhash].css' : 'index.css'),
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            minChunks: function(module) {
                return module.context && module.context.indexOf('node_modules') !== -1
            }
        }),
        new webpack.optimize.CommonsChunkPlugin({
            name: 'manifest'
        }),
        new HTMLPlugin({
            template: 'src/index.template.html',
            minify: {
                collapseWhitespace: isProd
            }
        }),
        new BundleAnalyzerPlugin({
            analyzerMode: 'static',
            reportFilename: 'stats.html',
            defaultSizes: 'parsed',
            openAnalyzer: false
        })
    ]
}

if (isProd) {
    config.devtool = '#source-map'
    config.plugins.push(
        new webpack.LoaderOptionsPlugin({
            minimize: true
        }),
        new webpack.optimize.UglifyJsPlugin({
            sourceMap: true
        })
    )
}

module.exports = config

Now that all of the code has been copied, you should be able to run

yarn run dev || npm run dev
or
yarn run prod || npm run prod

to generate a bundle in dist/

But what exactly is going on? Let’s break down the webpack config to figure that out.

Webpack Config

const path = require('path')
const webpack = require('webpack')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const HTMLPlugin = require('html-webpack-plugin')
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
const isProd = process.env.NODE_ENV === 'production'
...

First we require some dependencies for use in our config and we determine whether or not this is a production build.

ExtractTextPlugin is responsible for taking all of our generated css and puts it into a css file.

HTMLPlugin takes our index.template.html file, injects it with the files that webpack outputs using script or link tags and creates our index.html file.

BundleAnalyzerPlugin generates information about our webpack bundle including file sizes, what code is in which file and puts it into a fancy FoamTree tree map.

...
const config = {
    devtool: '#eval-source-map',
    entry: './src/js/index.js',
    output: {
        filename: isProd ? '[name].[chunkhash].js' : '[name].js',
        path: path.resolve(__dirname, 'dist')
    },
    ...
}
...

config is our actual webpack config that will ultimately be used to generate our bundle.

devtool tells webpack what kind of sourcemaps to use.

entry this is the entry point for our bundle.

output as the name implies, this is where we tell webpack where to put our bundle and what to name js files.  filename uses a ternary expression with the isProd variable we defined earlier to determine whether or not to include a hash in the filename. No need to worry about hashed in development. path tells webpack where to put files. In this case we want our entire bundle to be output under dist/

...
const config = {
    ...
    module: {
        rules: [{
            test: /\.js$/,
            loader: 'buble-loader',
            exclude: /node_modules/,
            options: {
                objectAssign: 'Object.assign'
            }
        }, {
            test: /\.scss$/,
            loader: ExtractTextPlugin.extract(['css-loader', 'sass-loader'])
        }]
    },
    ...
}
...

module.rules tells webpack how to handle different files. The main gist is that in each object in the rules array you specify a test which is just some regex that we use to test for file extensions.

For the first rule, we specify a test for .js files. We then say to use buble-loader which is what transpiles our ES6. We also exclude node_modules from transpilation and specify some buble-loader specific options. This particular option tells buble-loader that we want to use object spreading syntax.

The second rule tests for .scss files. An important note here is that webpack loaders work right to left. This means that when webpack encounters a .scss file the contents get passed to sass-loader the output of which gets passed to css-loader which then gets passed to ExtractTextPlugin.

...
const config = {
    ...
    plugins: [
        new ExtractTextPlugin(isProd ? 'index.[chunkhash].css' : 'index.css'),
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            minChunks: function(module) {
                return module.context && module.context.indexOf('node_modules') !== -1
            }
        }),
        new webpack.optimize.CommonsChunkPlugin({
            name: 'manifest'
        }),
        new HTMLPlugin({
            template: 'src/index.template.html',
            minify: {
                collapseWhitespace: isProd
            }
        }),
        new BundleAnalyzerPlugin({
            analyzerMode: 'static',
            reportFilename: 'stats.html',
            defaultSizes: 'parsed',
            openAnalyzer: false
        })
    ]
}
...

Here we have plugins. You can use webpack plugins to fine tune how the bundle is generated and some webpack loaders are used with a plugin, like ExtractTextPlugin.

First we tell ExtractTextPlugin what to call our css file using a similar ternary to output.filename.

Next we use webpack.optimize.CommonChunkPlugin to create a new file called vendor. Using the minChunks method which should return true or false, we tell webpack whether or not to include the code in the vendor file. Here we are saying to include the files in vendor if they come from node_modules

Again we use webpack.optimize.CommonChunkPlugin to create another file called manifest. The purpose of this file is to keep track of the main file and the vendor file. This is necessary to ensure that changing app code doesn’t change the vendor hash which will allow end users to keep using the cached version of a vendor file, so long as the dependencies don’t change.

Next HTMLPlugin is used to take our index.template.html file, inject it with our bundle files, and in production, minify.

Finally we have BundleAnalyzerPlugin which generates a stats.html file with our bundle so we can get valuable information about our bundle.

...
if (isProd) {
    config.devtool = '#source-map'
    config.plugins.push(
        new webpack.LoaderOptionsPlugin({
            minimize: true
        }),
        new webpack.optimize.UglifyJsPlugin({
            sourceMap: true
        })
    )
}

module.exports = config

Here we change a few things in production right before exporting our config for use with webpack.

If we’re in production mode, we first change config.devtool  to a production sourcemap (We could have also used a ternary in the config like we did with config.output.filename). Then we push two more plugins into config.plugins. The first plugin tells webpack loaders to minimize their output and the second tells webpack to uglify our javascript.

Now what?

Now that you have a working webpack config, the sky’s the limit. Read up on the webpack documentation

Don’t like SCSS swap it with the preprocessor you do like.

Add a front end framwork like Vue or React.

Create a PWA.

Implement code splitting.

Most importantly, have fun and check out our Blog for more awesome posts!

Leave a Reply

Your email address will not be published. Required fields are marked *