Andrew Welch · Insights · #frontend #webpack #workflow

Published , updated · 5 min read ·


For more tools, technologies, and techniques, check out the devMode.fm podcast!

An Annotated webpack 4 Config for Frontend Web Development

As web devel­op­ment becomes more com­plex, we need tool­ing to help us build mod­ern web­sites. Here’s a com­plete real-world pro­duc­tion exam­ple of a sophis­ti­cat­ed web­pack 4 config

Build­ing a mod­ern web­site has become cus­tom appli­ca­tion devel­op­ment. Web­sites are expect­ed to do more than just be mar­ket­ing sites as they take on the func­tion­al­i­ty of tra­di­tion­al apps.

Any time a process becomes com­pli­cat­ed, we break it down into man­age­able com­po­nents, and auto­mate the build process with tool­ing. This is the case in whether we are man­u­fac­tur­ing cars, draft­ing legal doc­u­ments, or build­ing websites. 

Use the right tool for the job

Tools like web­pack have been at the fore­front of mod­ern web devel­op­ment for pre­cise­ly that rea­son: they help us build com­plex things.

web­pack 4 boasts some amaz­ing improve­ments, the most appeal­ing to me was how much faster it’d become at build­ing. So I decid­ed to adopt it.

Buck­le up, because this is a long arti­cle filled with tons of information.

Link Adopting Webpack

A bit over a year ago, I pub­lished the arti­cle A Gulp Work­flow for Fron­tend Devel­op­ment Automa­tion that showed how to use Gulp to accom­plish the same thing. How­ev­er in the inter­ven­ing time, I’ve been doing more and more with fron­tend frame­works like Vue­JS and GraphQL, as dis­cussed in the Using Vue­JS + GraphQL to make Prac­ti­cal Mag­ic article.

I have found that web­pack makes it eas­i­er for me to build the types of web­sites and appli­ca­tions that I’m mak­ing these days, and it also allows me to use the most mod­ern tool­chain around.

There are oth­er choices:

  • Lar­avel Mix is a lay­er on top of web­pack. It’s appeal­ing in its sim­plic­i­ty: you can get up and run­ning quick­ly, and it’ll do what you want 90% of the time. But that remain­ing 10% means a drop down into web­pack any­way. And it still doesn’t sup­port web­pack 4.
  • vue-cli is very appeal­ing if you’re build­ing noth­ing but Vue­JS fron­tends. It again is a lay­er on top of web­pack that works great most of the time, and does some amaz­ing things for you. But again, you need to drop down into web­pack when your needs diverge from what it pro­vides. And I’m not always using Vue­JS exclusively.
  • Neu­tri­no is an inter­est­ing lay­er on web­pack that we explored in the Neu­tri­no: How I Learned to Stop Wor­ry­ing and Love Web­pack pod­cast. The premise is amaz­ing, build­ing a web­pack con­fig by snap­ping togeth­er pre­fab Lego brick com­po­nents. But learn­ing how it worked seemed almost as much work as learn­ing web­pack itself.

I won’t fault you if you choose any of the above tools (or even some­thing else), but note that there’s a com­mon theme to all of them: they lay­er on top of webpack.

Understanding how the layers in your development system work pays dividends in the end

Ulti­mate­ly, you just need to decide where in the pyra­mid of fron­tend tech­nolo­gies you want to stand.

At some point, I think it makes sense to under­stand how an impor­tant tool like web­pack works. A while ago, I’d com­plained to Sean Larkin (one of the web­pack core team mem­bers) that web­pack was like a black box”. His reply was pithy, but quite poignant:

It’s only black if you haven’t opened it

He’s right. Time to open the box.

This arti­cle will not teach you all there is to know about web­pack or even how to install it. There are plen­ty of resources avail­able for that — pick the for­mat that you learn best from:

…and there are many, many more. Instead, this arti­cle will anno­tate a full work­ing exam­ple of a fair­ly sophis­ti­cat­ed web­pack 4 set­up. You may use all of it; you may use bits and pieces of it. But hope­ful­ly you’ll learn a thing or two from it.

While on my con­tin­u­ing jour­ney learn­ing web­pack, I found many tuto­r­i­al videos, a bunch of write-ups show­ing how to install it and a basic con­fig, but not a whole lot of real-world pro­duc­tion exam­ples of web­pack con­figs. So here we are.

Link What We Get Out of the Box

As I set about learn­ing web­pack by open­ing up the box, I had a list of tech­nolo­gies that I relied upon that I want­ed to be part of the build process. I also took the time to look around to see what else was out there that I could adopt in the process.

As dis­cussed in the A Pret­ty Web­site Isn’t Enough arti­cle, web­site per­for­mance has always been a key con­cern of mine, so it should be no sur­prise that there’s a focus on that in this web­pack con­fig as well.

So here is my very opin­ion­at­ed list of things that I want­ed web­pack to do for me, and tech­nolo­gies I want­ed to incor­po­rate in my build process:

  • Devel­op­ment / Pro­duc­tion — in local devel­op­ment, I want fast builds via the in-mem­o­ry web­pack-dev-serv­er, and for pro­duc­tion builds (often done in a Dock­er con­tain­er via buddy.works), I want the every pos­si­ble opti­miza­tion. Thus we have sep­a­rate dev and prod con­figs & builds.
  • Hot Mod­ule Replace­ment — as I make changes to my JavaScript, CSS, or tem­plates, I want the web­page to seam­less­ly refresh. This speeds devel­op­ment tremen­dous­ly: just say no to the Reload button.
  • Dynam­ic Code Split­ting — I don’t want to man­u­al­ly have to define JavaScript chunks in a con­fig file, I want web­pack to sort it out for me.
  • Lazy Load­ing — aka async dynam­ic mod­ule load­ing. Load only the code/​resources need­ed, when they are need­ed, with­out ren­der blocking.
  • Mod­ern & Lega­cy JS Bun­dles — I want­ed to deploy mod­ern ES2015+ JavaScript mod­ules to the 75%+ of world­wide browsers that sup­port it, while grace­ful­ly pro­vid­ing a fall­back lega­cy bun­dle for lega­cy browsers (with all of the tran­spiled code and polyfills).
  • Cache Bust­ing via manifest.json - this allows us to set a long expiry data for our sta­t­ic assets, while also ensur­ing that they are auto­mat­i­cal­ly cache bust­ed if they change.
  • Crit­i­cal CSS — as per the Imple­ment­ing Crit­i­cal CSS on your web­site arti­cle, this is some­thing that makes ini­tial page loads sig­nif­i­cant­ly faster.
  • Work­box Ser­vice Work­er — we can lever­age Google’s Work­box project to gen­er­ate a Ser­vice Work­er for us that will know about all of our project’s assets. PWA, here we come!
  • PostC­SS — I think of it as the Babel of CSS”, things like SASS and SCSS are built on it, and it lets you use upcom­ing CSS fea­tures now.
  • Image Opti­miza­tion — Images are by far the largest thing on most web­pages, so it makes sense to opti­mize them via auto­mat­ed tools like mozjpeg, optipng, svgo, etc.
  • Auto­mat­ic .webp Cre­ation — Chrome, Edge, and Fire­fox all are sup­port­ing .webp, a for­mat that is more effi­cient than JPEG.
  • Vue­JS — Vue­JS is my fron­tend frame­work of choice. I want to be able to use sin­gle file .vue com­po­nents as a seam­less part of my devel­op­ment process.
  • Tail­wind CSS — Tail­wind is a util­i­ty-first CSS that I use for rapid­ly pro­to­typ­ing in local dev, and then run through PurgeC­SS for pro­duc­tion, to reduce the size dramatically.

Phew, quite an ambi­tious list!

There’s more too, like the auto­mat­ic ugli­fi­ca­tion of JavaScript, mini­fi­ca­tion of CSS, and oth­er stan­dard things we’d expect from a fron­tend build system.

I also want it to work with a devel­op­ment team that may use dis­parate tools for their local dev envi­ron­ment, and to have the con­fig be easy to main­tain and reuse from project to project.

The importance of maintainability and reusability can’t be understated

Your stack of fron­tend frameworks/​technologies may look dif­fer­ent from mine, but the prin­ci­ples applied will be the same. So read on, regard­less of what you use!

Link Project Tree & Organization

To give you an overview of what the set­up looks like, here’s a bare bones project tree:

├── example.env
├── package.json
├── postcss.config.js
├── src
│   ├── css
│   │   ├── app.pcss
│   │   ├── components
│   │   │   ├── global.pcss
│   │   │   ├── typography.pcss
│   │   │   └── webfonts.pcss
│   │   ├── pages
│   │   │   └── homepage.pcss
│   │   └── vendor.pcss
│   ├── fonts
│   ├── img
│   │   └── favicon-src.png
│   ├── js
│   │   ├── app.js
│   │   └── workbox-catch-handler.js
│   └── vue
│       └── Confetti.vue
├── tailwind.config.js
├── templates
├── webpack.common.js
├── webpack.dev.js
├── webpack.prod.js
├── webpack.settings.js
└── yarn.lock

For the com­plete source code for every­thing pre­sent­ed here, check out the anno­tat­ed-web­pack-4-con­fig github repo.

So in terms of the core con­fig files, we have:

  • .env — envi­ron­men­tal-spe­cif­ic set­tings for the webpack-dev-server; this is nev­er checked into git
  • webpack.settings.js — a JSON-ish set­tings file, the only file we need to edit from project to project
  • webpack.common.js — com­mon set­tings for both types of builds
  • webpack.dev.js — set­tings for local devel­op­ment builds
  • webpack.prod.js — set­tings for pro­duc­tion builds

Here’s a dia­gram of how it all fits together:

The goal is that you need to edit only what is in the gold col­ored round­ed-rec­tan­gles (.env & webpack.settings.js) from project to project.

Sep­a­rat­ing things out in this way makes work­ing with the con­fig files quite a bit eas­i­er. Even if you do end up chang­ing the var­i­ous web­pack con­fig files from what I’ve pre­sent­ed here, keep­ing with this method­ol­o­gy will help you main­tain them long-term.

Don’t wor­ry, we’ll get into each file in detail later.

Link Annotated package.json

Let’s start by break­ing down our package.json :

{
    "name": "example-project",
    "version": "1.0.0",
    "description": "Example Project brand website",
    "keywords": [
        "Example",
        "Keywords"
    ],
    "homepage": "https://github.com/example-developer/example-project",
    "bugs": {
        "email": "someone@example-developer.com",
        "url": "https://github.com/example-developer/example-project/issues"
    },
    "license": "SEE LICENSE IN LICENSE.md",
    "author": {
        "name": "Example Developer",
        "email": "someone@example-developer.com",
        "url": "https://example-developer.com"
    },
    "browser": "/web/index.php",
    "repository": {
        "type": "git",
        "url": "git+https://github.com/example-developer/example-project.git"
    },
    "private": true,

Noth­ing par­tic­u­lar­ly inter­est­ing here, just meta infor­ma­tion for our web­site as out­lined in the package.json spec­i­fi­ca­tion.

"scripts": {
        "dev": "webpack-dev-server --config webpack.dev.js",
        "build": "webpack --config webpack.prod.js --progress --hide-modules"
    },

These are the scripts that rep­re­sent the two major build steps we have for our project:

  • dev — used when­ev­er we’re work­ing on the project, it spins up the webpack-dev-server to allow for Hot Mod­ule Replace­ment (HMR), in mem­o­ry com­pi­la­tion, and oth­er niceties.
  • build — used when we do a pro­duc­tion deploy­ment, it does all of the fan­cy and time con­sum­ing things like Crit­i­cal CSS, ugli­fi­ca­tion of JavaScript, etc. that need to be done for pro­duc­tion deployment.

To run them, we just use the CLI inside of our devel­op­ment envi­ron­ment to do yarn dev or yarn build if we’re using yarn, and npm run dev or npm run build if we’re using npm. These are the only two com­mands you’ll need to use.

Notice that via the --config flag, we’re also pass­ing in sep­a­rate con­fig files. This lets us break down our web­pack con­fig into sep­a­rate log­i­cal files, because we’re going to be doing things very dif­fer­ent­ly for devel­op­ment builds com­pared to pro­duc­tion builds.

Next up we have our browser­slist:

"browserslist": {
        "production": [
            "> 1%",
            "last 2 versions",
            "Firefox ESR"
        ],
        "legacyBrowsers": [
            "> 1%",
            "last 2 versions",
            "Firefox ESR"
        ],
        "modernBrowsers": [
            "last 2 Chrome versions",
            "not Chrome < 60",
            "last 2 Safari versions",
            "not Safari < 10.1",
            "last 2 iOS versions",
            "not iOS < 10.3",
            "last 2 Firefox versions",
            "not Firefox < 54",
            "last 2 Edge versions",
            "not Edge < 15"
        ]
    },

This is a browser­slist that tar­gets spe­cif­ic browsers based on human-read­able con­figs. The PostC­SS auto­pre­fix­er defaults to using our production set­tings. We pass in the legacyBrowsers and modernBrowsers to Babel to han­dle build­ing both lega­cy and mod­ern JavaScript bun­dles. More on that later!

Next up we have our devDe­pen­den­cies, which are all of the npm pack­ages required for our build system:

"devDependencies": {
        "@babel/core": "^7.1.0",
        "@babel/plugin-syntax-dynamic-import": "^7.0.0",
        "@babel/plugin-transform-runtime": "^7.1.0",
        "@babel/preset-env": "^7.1.0",
        "@babel/register": "^7.0.0",
        "@babel/runtime": "^7.0.0",
        "autoprefixer": "^9.1.5",
        "babel-loader": "^8.0.2",
        "clean-webpack-plugin": "^0.1.19",
        "copy-webpack-plugin": "^4.5.2",
        "create-symlink-webpack-plugin": "^1.0.0",
        "critical": "^1.3.4",
        "critical-css-webpack-plugin": "^0.2.0",
        "css-loader": "^1.0.0",
        "cssnano": "^4.1.0",
        "dotenv": "^6.1.0",
        "file-loader": "^2.0.0",
        "git-rev-sync": "^1.12.0",
        "glob-all": "^3.1.0",
        "html-webpack-plugin": "^3.2.0",
        "ignore-loader": "^0.1.2",
        "imagemin": "^6.0.0",
        "imagemin-gifsicle": "^5.2.0",
        "imagemin-mozjpeg": "^7.0.0",
        "imagemin-optipng": "^5.2.1",
        "imagemin-svgo": "^7.0.0",
        "imagemin-webp": "^4.1.0",
        "imagemin-webp-webpack-plugin": "^1.0.2",
        "img-loader": "^3.0.1",
        "mini-css-extract-plugin": "^0.4.3",
        "moment": "^2.22.2",
        "optimize-css-assets-webpack-plugin": "^5.0.1",
        "postcss": "^7.0.2",
        "postcss-extend": "^1.0.5",
        "postcss-hexrgba": "^1.0.1",
        "postcss-import": "^12.0.0",
        "postcss-loader": "^3.0.0",
        "postcss-nested": "^4.1.0",
        "postcss-nested-ancestors": "^2.0.0",
        "postcss-simple-vars": "^5.0.1",
        "purgecss-webpack-plugin": "^1.3.0",
        "purgecss-whitelister": "^2.2.0",
        "resolve-url-loader": "^3.0.0",
        "sane": "^4.0.1",
        "save-remote-file-webpack-plugin": "^1.0.0",
        "style-loader": "^0.23.0",
        "symlink-webpack-plugin": "^0.0.4",
        "terser-webpack-plugin": "^1.1.0",
        "vue-loader": "^15.4.2",
        "vue-style-loader": "^4.1.2",
        "vue-template-compiler": "^2.5.17",
        "webapp-webpack-plugin": "https://github.com/brunocodutra/webapp-webpack-plugin.git",
        "webpack": "^4.19.1",
        "webpack-bundle-analyzer": "^3.0.2",
        "webpack-cli": "^3.1.1",
        "webpack-dashboard": "^2.0.0",
        "webpack-dev-server": "^3.1.9",
        "webpack-manifest-plugin": "^2.0.4",
        "webpack-merge": "^4.1.4",
        "webpack-notifier": "^1.6.0",
        "workbox-webpack-plugin": "^3.6.2"
    },

Yep, that’s quite a bit of pack­ages. But our build process does quite a bit.

And final­ly, we use the depen­den­cies for the pack­ages we use on the fron­tend of our website:

"dependencies": {
        "@babel/polyfill": "^7.0.0",
        "axios": "^0.18.0",
        "tailwindcss": "^0.6.6",
        "vue": "^2.5.17",
        "vue-confetti": "^0.4.2"
    }
}

Obvi­ous­ly for an actu­al website/​app, there would be more pack­ages in depen­den­cies; but we’re focus­ing on the build process.

Link Annotated webpack.settings.js

I’m also using a sim­i­lar approach I dis­cussed in the A Bet­ter package.json for the Fron­tend arti­cle, which is to cor­don off the con­fig that changes from project to project into a sep­a­rate webpack.settings.js, and keep the web­pack con­fig itself the same.

The key concept is that the only file we need to edit from project to project is the webpack.settings.js

Since most projects have a very sim­i­lar set of things that need to be done, we can cre­ate a web­pack con­fig that works for a wide vari­ety of projects. We just need to change the data it oper­ates on.

Thus the sep­a­ra­tion of con­cerns between what is in our webpack.settings.js file (the data that changes from project to project) and what is in our web­pack con­fig (how that data is manip­u­lat­ed to pro­duce an end result).

// webpack.settings.js - webpack settings config

// node modules
require('dotenv').config();

// Webpack settings exports
// noinspection WebpackConfigHighlighting
module.exports = {
    name: "Example Project",
    copyright: "Example Company, Inc.",
    paths: {
        src: {
            base: "./src/",
            css: "./src/css/",
            js: "./src/js/"
        },
        dist: {
            base: "./web/dist/",
            clean: [
                "./img",
                "./criticalcss",
                "./css",
                "./js"
            ]
        },
        templates: "./templates/"
    },
    urls: {
        live: "https://example.com/",
        local: "http://example.test/",
        critical: "http://example.test/",
        publicPath: "/dist/"
    },
    vars: {
        cssName: "styles"
    },
    entries: {
        "app": "app.js"
    },
    copyWebpackConfig: [
        {
            from: "./src/js/workbox-catch-handler.js",
            to: "js/[name].[ext]"
        }
    ],
    criticalCssConfig: {
        base: "./web/dist/criticalcss/",
        suffix: "_critical.min.css",
        criticalHeight: 1200,
        criticalWidth: 1200,
        ampPrefix: "amp_",
        ampCriticalHeight: 19200,
        ampCriticalWidth: 600,
        pages: [
            {
                url: "",
                template: "index"
            }
        ]
    },
    devServerConfig: {
        public: () => process.env.DEVSERVER_PUBLIC || "http://localhost:8080",
        host: () => process.env.DEVSERVER_HOST || "localhost",
        poll: () => process.env.DEVSERVER_POLL || false,
        port: () => process.env.DEVSERVER_PORT || 8080,
        https: () => process.env.DEVSERVER_HTTPS || false,
    },
    manifestConfig: {
        basePath: ""
    },
    purgeCssConfig: {
        paths: [
            "./templates/**/*.{twig,html}",
            "./src/vue/**/*.{vue,html}"
        ],
        whitelist: [
            "./src/css/components/**/*.{css,pcss}"
        ],
        whitelistPatterns: [],
        extensions: [
            "html",
            "js",
            "twig",
            "vue"
        ]
    },
    saveRemoteFileConfig: [
        {
            url: "https://www.google-analytics.com/analytics.js",
            filepath: "js/analytics.js"
        }
    ],
    createSymlinkConfig: [
        {
            origin: "img/favicons/favicon.ico",
            symlink: "../favicon.ico"
        }
    ],
    webappConfig: {
        logo: "./src/img/favicon-src.png",
        prefix: "img/favicons/"
    },
    workboxConfig: {
        swDest: "../sw.js",
        precacheManifestFilename: "js/precache-manifest.[manifestHash].js",
        importScripts: [
            "/dist/workbox-catch-handler.js"
        ],
        exclude: [
            /\.(png|jpe?g|gif|svg|webp)$/i,
            /\.map$/,
            /^manifest.*\\.js(?:on)?$/,
        ],
        globDirectory: "./web/",
        globPatterns: [
            "offline.html",
            "offline.svg"
        ],
        offlineGoogleAnalytics: true,
        runtimeCaching: [
            {
                urlPattern: /\.(?:png|jpg|jpeg|svg|webp)$/,
                handler: "cacheFirst",
                options: {
                    cacheName: "images",
                    expiration: {
                        maxEntries: 20
                    }
                }
            }
        ]
    }
};

We’ll cov­er what all of these things are down in the web­pack con­fig sec­tions. The impor­tant thing to note here is that we’ve tak­en things that change from project to project, and bro­ken them out of our web­pack con­fig, and into a sep­a­rate webpack.settings.js file.

This means we can just define what’s dif­fer­ent in each project in our webpack.settings.js file, and not have to be wran­gling with the web­pack con­fig itself.

Even though the webpack.settings.js file is just JavaScript, I tried to keep it as JSON-ish as pos­si­ble, so we’re just chang­ing sim­ple set­tings in it. I didn’t use JSON as a file for­mat for flex­i­bil­i­ty, and also to allow for com­ments to be added.

Link Common Conventions for webpack configs

I’ve adopt­ed a few con­ven­tions for all of the web­pack con­fig files (webpack.common.js, webpack.dev.js, & webpack.prod.js) to make things more consistent.

Each con­fig file has two inter­nal configs:

  • lega­cy­Con­fig — the con­fig that applies to the lega­cy ES5 build
  • mod­ern­Con­fig — the con­fig that applies to the mod­ern ES2015+ build

We do it this way because we have sep­a­rate con­fig­u­ra­tions to cre­ate the lega­cy and mod­ern builds. This keeps them log­i­cal­ly sep­a­rate. The webpack.common.js also has a baseC­on­fig; this is pure­ly organizational.

Think of it like Object Ori­ent­ed Pro­gram­ming, where the var­i­ous con­figs inher­it from each oth­er, with the baseC­on­fig being the root object.

Anoth­er con­ven­tion that I’ve adopt­ed to keep the con­fig­u­ra­tion clean and read­able is to have configure() func­tions for the var­i­ous web­pack plu­g­ins and oth­er pieces of web­pack that need con­fig­ur­ing, rather than putting it all inline.

I did this because some data com­ing from the webpack.settings.js needs to be trans­formed before it can be used by web­pack, and because of the dual legacy/​modern builds, we need to return a dif­fer­ent con­fig depend­ing on the type of build.

It also makes the con­fig files a bit more read­able as well.

As a gen­er­al web­pack con­cept, under­stand that web­pack itself knows only how to load JavaScript and JSON. To load any­thing else, we need to to use a loader. We’ll be using a num­ber of dif­fer­ent load­ers in our web­pack config.

Link Annotated webpack.common.js

Now let’s have a look at our webpack.common.js con­fig file that has all of the set­tings that are shared by both the dev and prod build types.

// webpack.common.js - common webpack config
const LEGACY_CONFIG = 'legacy';
const MODERN_CONFIG = 'modern';

// node modules
const path = require('path');
const merge = require('webpack-merge');

// webpack plugins
const CopyWebpackPlugin = require('copy-webpack-plugin');
const ManifestPlugin = require('webpack-manifest-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const WebpackNotifierPlugin = require('webpack-notifier');

// config files
const pkg = require('./package.json');
const settings = require('./webpack.settings.js');

In the pre­am­ble we pull in the Node pack­ages we need, and the web­pack plu­g­ins we use. We then import our webpack.settings.js as settings so we can access the set­tings there, and also import our package.json as pkg to access a few set­tings there as well.

CON­FIG­U­RA­TION FUNCTIONS

Here’s what the configureBabelLoader() looks like:

// Configure Babel loader
const configureBabelLoader = (browserList) => {
    return {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
            loader: 'babel-loader',
            options: {
                presets: [
                    [
                        '@babel/preset-env', {
                        modules: false,
                        useBuiltIns: 'entry',
                        targets: {
                            browsers: browserList,
                        },
                    }
                    ],
                ],
                plugins: [
                    '@babel/plugin-syntax-dynamic-import',
                    [
                        "@babel/plugin-transform-runtime", {
                        "regenerator": true
                    }
                    ]
                ],
            },
        },
    };
};

The configureBabelLoader() func­tion con­fig­ures the babel-loader to han­dle the load­ing of all files that end in .js. It uses @babel/preset-env instead of a .babelrc file so we can keep every­thing com­part­men­tal­ized in our web­pack config.

Babel can com­pile mod­ern ES2015+ JavaScript (and many oth­er lan­guages like Type­Script or Cof­fee­Script) down to JavaScript that tar­gets a spe­cif­ic set of browsers or stan­dards. We pass in the browserList as a para­me­ter so that we can build both mod­ern ES2015+ mod­ules and lega­cy ES5 JavaScript with poly­fills for lega­cy browsers.

In our HTML, we just do some­thing like this:

<!-- Browsers with ES module support load this file. -->
<script type="module" src="main.js"></script>

<!-- Older browsers load this file (and module-supporting -->
<!-- browsers know *not* to load this file). -->
<script nomodule src="main-legacy.js"></script>

No poly­fills, no fuss. Old browsers ignore the type="module" script, and get the main-legacy.js. Mod­ern browsers load the main.js, and ignore the nomodule. It’s bril­liant; I wish I came up with the idea! Lest you think it’s fringe, vue-cli has adopt­ed this strat­e­gy in ver­sion 3.

The @babel/plugin-syntax-dynamic-import plu­g­in is what allows us to do dynam­ic imports even before the ECMAScript dynam­ic import pro­pos­al is imple­ment­ed by web browsers. This lets us load our JavaScript mod­ules asyn­chro­nous­ly, and dynam­i­cal­ly as needed.

So what does this mean? It means we can do some­thing like this:

// App main
const main = async () => {
    // Async load the vue module
    const Vue = await import(/* webpackChunkName: "vue" */ 'vue');
    // Create our vue instance
    const vm = new Vue.default({
        el: "#app",
        components: {
            'confetti': () => import(/* webpackChunkName: "confetti" */ '../vue/Confetti.vue'),
        },
    });
};
// Execute async function
main().then( (value) => {
});

This does two pri­ma­ry things:

  1. Via the /* webpackChunkName: "vue" */ com­ment, we’ve told web­pack what we want this dynam­i­cal­ly code split chunk to be named
  2. Since we’re using import() in an async func­tion (“main”), that func­tion awaits the result of our dynam­i­cal­ly loaded JavaScript import while the rest of our code con­tin­ues on its mer­ry way

We’ve effec­tive­ly told web­pack how we want our chunks split up through code, rather than via con­fig. And through the mag­ic of @babel/plugin-syntax-dynamic-import, this JavaScript chunk can be loaded asyn­chro­nous­ly, on demand as needed.

Notice we did the same thing with our .vue sin­gle file com­po­nents, too. Nice.

Instead of using await, we could also just exe­cute our code after the import() Promise has returned:

// Async load the vue module
import(/* webpackChunkName: "vue" */ 'vue').then(Vue => {
    // Vue has loaded, do something with it
    // Create our vue instance
    const vm = new Vue.default({
        el: "#app",
        components: {
            'confetti': () => import(/* webpackChunkName: "confetti" */ '../vue/Confetti.vue'),
        },
    });
});

Here instead of using await with import() we’re using the Promise, so then we know the dynam­ic import has hap­pened and can hap­pi­ly use Vue.

If you’re pay­ing atten­tion, you can see that we’ve effec­tive­ly solved JavaScript depen­den­cies via Promis­es. Nice!

We can even do fun things like load cer­tain JavaScript chunks only after the user has clicked on some­thing, scrolled to a cer­tain posi­tion, or sat­is­fied some oth­er con­di­tion. Check out the Mod­ule Meth­ods import() for more.

If you’re inter­est­ed in learn­ing more about Babel, check out the Work­ing with Babel 7 and Web­pack article.

Next up we have configureEntries():

// Configure Entries
const configureEntries = () => {
    let entries = {};
    for (const [key, value] of Object.entries(settings.entries)) {
        entries[key] = path.resolve(__dirname, settings.paths.src.js + value);
    }

    return entries;
};

Here we pull in the web­pack Entry Points from our webpack.settings.js via settings.entries. For a Sin­gle Page App (SPA) you’ll have just one entry point. For a more tra­di­tion­al web­site, you may have sev­er­al entry points (per­haps one per page template).

Either way, because we’ve defined our entry points in our webpack.settings.js, it’s easy to con­fig­ure them there. An entry point is real­ly just a <script src="app.js"></script> tag that you’ll include in your HTML to boot­strap the JavaScript.

Since we’re using dynam­i­cal­ly import­ed mod­ules, we typ­i­cal­ly would have only one <script></script> tag on a page; the rest of our JavaScript gets loaded dynam­i­cal­ly as needed.

Next we have the configureFontLoader() function:

// Configure Font loader
const configureFontLoader = () => {
    return {
        test: /\.(ttf|eot|woff2?)$/i,
        use: [
            {
                loader: 'file-loader',
                options: {
                    name: 'fonts/[name].[ext]'
                }
            }
        ]
    };
};

Font load­ing is the same for both dev and prod builds, so we include it here. For any local fonts that we’re using, we can tell web­pack to load them in our JavaScript:

import comicsans from '../fonts/ComicSans.woff2';

Next we have the configureManifest() function:

// Configure Manifest
const configureManifest = (fileName) => {
    return {
        fileName: fileName,
        basePath: settings.manifestConfig.basePath,
        map: (file) => {
            file.name = file.name.replace(/(\.[a-f0-9]{32})(\..*)$/, '$2');
            return file;
        },
    };
};

This con­fig­ures the web­pack-man­i­fest-plu­g­in for file­name-based cache bust­ing. In a nut­shell, web­pack knows about all of the JavaScript, CSS, and oth­er resources we need, so it can gen­er­ate a man­i­fest that points to the con­tent-hashed name of the resource, e.g.:

{
  "vendors~confetti~vue.js": "/dist/js/vendors~confetti~vue.03b9213ce186db5518ea.js",
  "vendors~confetti~vue.js.map": "/dist/js/vendors~confetti~vue.03b9213ce186db5518ea.js.map",
  "app.js": "/dist/js/app.30334b5124fa6e221464.js",
  "app.js.map": "/dist/js/app.30334b5124fa6e221464.js.map",
  "confetti.js": "/dist/js/confetti.1152197f8c58a1b40b34.js",
  "confetti.js.map": "/dist/js/confetti.1152197f8c58a1b40b34.js.map",
  "js/precache-manifest.js": "/dist/js/precache-manifest.f774c437974257fc8026ca1bc693655c.js",
  "../sw.js": "/dist/../sw.js"
}

We pass in a file­name because we cre­ate both a mod­ern manifest.json and a lega­cy manifest-legacy.json that have the entry points for our mod­ern ES2015+ mod­ules and lega­cy ES5 mod­ules, respec­tive­ly. The keys in both of the man­i­fests are iden­ti­cal for resources that are built for both mod­ern and lega­cy builds.

Next up we have a pret­ty stan­dard look­ing configureVueLoader():

// Configure Vue loader
const configureVueLoader = () => {
    return {
        test: /\.vue$/,
        loader: 'vue-loader'
    };
};

This just lets us load Vue Sin­gle File Com­po­nents eas­i­ly. web­pack takes care of extract­ing the appro­pri­ate HTML, CSS, and JavaScript for you.

BASE CON­FIG

The baseConfig gets merged with both the modernConfig and legacyConfig:

// The base webpack config
const baseConfig = {
    name: pkg.name,
    entry: configureEntries(),
    output: {
        path: path.resolve(__dirname, settings.paths.dist.base),
        publicPath: settings.urls.publicPath
    },
    resolve: {
        alias: {
            'vue$': 'vue/dist/vue.esm.js'
        }
    },
    module: {
        rules: [
            configureVueLoader(),
        ],
    },
    plugins: [
        new WebpackNotifierPlugin({title: 'Webpack', excludeWarnings: true, alwaysNotify: true}),
        new VueLoaderPlugin(),
    ]
};

Every­thing here is pret­ty stan­dard web­pack fare, but note that we alias vue$ to vue/dist/vue.esm.js so that we can get the ES2015 mod­ule ver­sion of Vue.

We use the Web­pac­kNo­ti­fier­Plu­g­in to let us know the sta­tus of our builds in a friend­ly way.

LEGA­CY CONFIG

The legacyConfig is for build­ing ES5 lega­cy JavaScript with the appro­pri­ate polyfills:

// Legacy webpack config
const legacyConfig = {
    module: {
        rules: [
            configureBabelLoader(Object.values(pkg.browserslist.legacyBrowsers)),
        ],
    },
    plugins: [
        new CopyWebpackPlugin(
            settings.copyWebpackConfig
        ),
        new ManifestPlugin(
            configureManifest('manifest-legacy.json')
        ),
    ]
};

Note that we pass in pkg.browserslist.legacyBrowsers to configureBabelLoader(), and we pass in 'manifest-legacy.json' to configureManifest().

We also include the Copy­Web­pack­Plu­g­in in this build, so that we only copy the files defined in settings.copyWebpackConfig once.

MOD­ERN CONFIG

The modernConfig is for build­ing mod­ern ES2015 JavaScript mod­ules with­out the cruft:

// Modern webpack config
const modernConfig = {
    module: {
        rules: [
            configureBabelLoader(Object.values(pkg.browserslist.modernBrowsers)),
        ],
    },
    plugins: [
        new ManifestPlugin(
            configureManifest('manifest.json')
        ),
    ]
};

Note that we pass in pkg.browserslist.modernBrowsers to configureBabelLoader(), and we pass in'manifest.json' to configureManifest().

MODULE.EXPORTS

Final­ly, the module.exports uses the web­pack-merge pack­age to merge the con­figs togeth­er, and returns an object that is used by the webpack.dev.js and webpack.prod.js.

// Common module exports
// noinspection WebpackConfigHighlighting
module.exports = {
    'legacyConfig': merge(
        legacyConfig,
        baseConfig,
    ),
    'modernConfig': merge(
        modernConfig,
        baseConfig,
    ),
};

Link Annotated webpack.dev.js

Now let’s have a look at our webpack.dev.js con­fig file that has all of the set­tings that are used for devel­op­men­tal builds while we’re work­ing on the project. It gets merged with the set­tings in webpack.common.js to form a com­plete web­pack configuration.

// webpack.dev.js - developmental builds
const LEGACY_CONFIG = 'legacy';
const MODERN_CONFIG = 'modern';

// node modules
const merge = require('webpack-merge');
const path = require('path');
const sane = require('sane');
const webpack = require('webpack');

// webpack plugins
const Dashboard = require('webpack-dashboard');
const DashboardPlugin = require('webpack-dashboard/plugin');
const dashboard = new Dashboard();

// config files
const common = require('./webpack.common.js');
const pkg = require('./package.json');
const settings = require('./webpack.settings.js');

In the pre­am­ble we again pull in the Node pack­ages we need, and the web­pack plu­g­ins we use. We then import our webpack.settings.js as settings so we can access the set­tings there, and also import our package.json as pkg to access a few set­tings there as well.

We also import our webpack.common.js com­mon web­pack con­fig that we’ll merge our dev set­tings with.

CON­FIG­U­RA­TION FUNCTIONS

Here’s what the configureDevServer() looks like:

// Configure the webpack-dev-server
const configureDevServer = (buildType) => {
    return {
        public: settings.devServerConfig.public(),
        contentBase: path.resolve(__dirname, settings.paths.templates),
        host: settings.devServerConfig.host(),
        port: settings.devServerConfig.port(),
        https: !!parseInt(settings.devServerConfig.https()),
        quiet: true,
        hot: true,
        hotOnly: true,
        overlay: true,
        stats: 'errors-only',
        watchOptions: {
            poll: !!parseInt(settings.devServerConfig.poll()),
        },
        headers: {
            'Access-Control-Allow-Origin': '*'
        },
        // Use sane to monitor all of the templates files and sub-directories
        before: (app, server) => {
            const watcher = sane(path.join(__dirname, settings.paths.templates), {
                glob: ['**/*'],
                poll: !!parseInt(settings.devServerConfig.poll()),
            });
            watcher.on('change', function(filePath, root, stat) {
                console.log('  File modified:', filePath);
                server.sockWrite(server.sockets, "content-changed");
            });
        },
    };
};

When we do a pro­duc­tion build, web­pack bun­dles up all of our var­i­ous assets and saves them to the file sys­tem. By con­trast, when we’re work­ing on a project in local dev, we use a devel­op­ment build via web­pack-dev-serv­er that:

  • Spins up a local Express web serv­er that serves our assets
  • Builds our assets in mem­o­ry rather than to the file sys­tem, for speed
  • Will rebuild assets like JavaScript, CSS, Vue com­po­nents, etc. as we change them and inject them into the web­page via Hot Mod­ule Replace­ment (HMR) with­out a page reload
  • Will reload the page when we make changes to our templates

This is akin to a much more sophis­ti­cat­ed vari­ant of Browser­sync, and great­ly speeds development.

The only slight­ly unusu­al thing here is we use Sane to mon­i­tor files that aren’t run through web­pack (in this case, our tem­plates), to do a full page reload when­ev­er one of them is changed.

Note that con­fig for the webpack-dev-server again comes from our webpack.settings.js file. The defaults are prob­a­bly okay for many peo­ple, but I use Lar­avel Home­stead for local dev, as dis­cussed in the Local Devel­op­ment with Vagrant / Home­stead arti­cle. This means I run all devel­op­ment tool­ing inside of my Home­stead VM.

So instead of hard-cod­ing the local devel­op­ment envi­ron­ment in my webpack.settings.js file (since it can vary from per­son to per­son work­ing on a team), the webpack.settings.js can read from from an option­al .env file for your own par­tic­u­lar devServer config:

# webpack example settings for Homestead/Vagrant
DEVSERVER_PUBLIC="http://192.168.10.10:8080"
DEVSERVER_HOST="0.0.0.0"
DEVSERVER_POLL=1
DEVSERVER_PORT=8080
DEVSERVER_HTTPS=0

You may use some­thing dif­fer­ent, so change the set­tings as appro­pri­ate in your .env file as need­ed. The idea behind dotenv is that we put any­thing spe­cif­ic to an envi­ron­ment in the .env file, and we do not check it in to our git repo. If the .env file isn’t present, that’s fine, it just uses default values:

devServerConfig: {
    public: () => process.env.DEVSERVER_PUBLIC || "http://localhost:8080",
    host: () => process.env.DEVSERVER_HOST || "localhost",
    poll: () => process.env.DEVSERVER_POLL || false,
    port: () => process.env.DEVSERVER_PORT || 8080,
    https: () => process.env.DEVSERVER_HTTPS || false,
},

Next up is the configureImageLoader():

// Configure Image loader
const configureImageLoader = (buildType) => {
    if (buildType === LEGACY_CONFIG) {
        return {
            test: /\.(png|jpe?g|gif|svg|webp)$/i,
            use: [
                {
                    loader: 'file-loader',
                    options: {
                        name: 'img/[name].[hash].[ext]'
                    }
                }
            ]
        };
    }
    if (buildType === MODERN_CONFIG) {
        return {
            test: /\.(png|jpe?g|gif|svg|webp)$/i,
            use: [
                {
                    loader: 'file-loader',
                    options: {
                        name: 'img/[name].[hash].[ext]'
                    }
                }
            ]
        };
    }
};

We pass in the buildType so that we can return dif­fer­ent results depend­ing on whether it is a lega­cy or mod­ern build. In this case, we return the same con­fig, but it’s very con­ceiv­able that might change.

It’s impor­tant to note that this is only for images that are includ­ed in our web­pack build; many oth­er images will be com­ing from else­where (a CMS sys­tem, an asset man­age­ment sys­tem, etc.).

To let web­pack know about an image, you import it into your JavaScript:

import Icon from './icon.png';

Check out the Load­ing Images sec­tion of the web­pack docs for more details on this.

Next up is our configurePostcssLoader():

// Configure the Postcss loader
const configurePostcssLoader = (buildType) => {
    // Don't generate CSS for the legacy config in development
    if (buildType === LEGACY_CONFIG) {
        return {
            test: /\.(pcss|css)$/,
            loader: 'ignore-loader'
        };
    }
    if (buildType === MODERN_CONFIG) {
        return {
            test: /\.(pcss|css)$/,
            use: [
                {
                    loader: 'style-loader',
                },
                {
                    loader: 'vue-style-loader',
                },
                {
                    loader: 'css-loader',
                    options: {
                        importLoaders: 2,
                        sourceMap: true
                    }
                },
                {
                    loader: 'resolve-url-loader'
                },
                {
                    loader: 'postcss-loader',
                    options: {
                        sourceMap: true
                    }
                }
            ]
        };
    }
};

We use PostC­SS to process all of our CSS, includ­ing Tail­wind CSS. I think of it as the Babel of CSS, in that it com­piles all sorts of advanced CSS func­tion­al­i­ty down to plain old CSS that your browsers can understand.

It’s impor­tant to note that for web­pack load­ers, they are processed in reverse order that they are listed:

  • postc­ss-loader — Loads and process­es files as PostCSS
  • resolve-url-loader — Rewrites any url()s in our CSS to pub­lic path relative
  • css-loader — Resolves all of our CSS @import and url()s
  • vue-style-loader — Injects all of our CSS from .vue Sin­gle File Com­po­nents linline
  • style-loader — Injects all of our CSS into the doc­u­ment inline in <style></style> tags

Remem­ber, since this is what we do in local devel­op­ment, we don’t need to do any­thing fan­cy in terms of extract­ing all of our CSS out into a min­i­mized file. Instead, we just let the style-loader inline it all in our document.

The webpack-dev-server will use Hot Mod­ule Replace­ment (HMR) for our CSS, so any time we change any­thing, it rebuilds our CSS and re-injects it auto­mat­i­cal­ly. It’s some­what magical.

We tell web­pack about our CSS by includ­ing it:

import styles from '../css/app.pcss';

This is dis­cussed in detail in the Load­ing CSS sec­tion of the web­pack docs.

We do this from our App.js entry point; think of this as the PostC­SS entry point. The app.pcss file @imports all of the CSS that our project uses; this will be cov­ered in detail lat­er on. 

MODULE.EXPORTS

Final­ly, the module.exports uses the web­pack-merge pack­age to merge the common.legacyConfig from the webpack.common.js with our dev lega­cy con­fig, and the common.modernConfig with our dev mod­ern config:

// Development module exports
module.exports = [
    merge(
        common.legacyConfig,
        {
            output: {
                filename: path.join('./js', '[name]-legacy.[hash].js'),
                publicPath: settings.devServerConfig.public() + '/',
            },
            mode: 'development',
            devtool: 'inline-source-map',
            devServer: configureDevServer(LEGACY_CONFIG),
            module: {
                rules: [
                    configurePostcssLoader(LEGACY_CONFIG),
                    configureImageLoader(LEGACY_CONFIG),
                ],
            },
            plugins: [
                new webpack.HotModuleReplacementPlugin(),
            ],
        }
    ),
    merge(
        common.modernConfig,
        {
            output: {
                filename: path.join('./js', '[name].[hash].js'),
                publicPath: settings.devServerConfig.public() + '/',
            },
            mode: 'development',
            devtool: 'inline-source-map',
            devServer: configureDevServer(MODERN_CONFIG),
            module: {
                rules: [
                    configurePostcssLoader(MODERN_CONFIG),
                    configureImageLoader(MODERN_CONFIG),
                ],
            },
            plugins: [
                new webpack.HotModuleReplacementPlugin(),
                new DashboardPlugin(dashboard.setData),
            ],
        }
    ),
];

By return­ing an array in our module.exports, we’re telling web­pack that we have more than one com­pile that needs to be done: one for our lega­cy build, and anoth­er for our mod­ern build.

Note that for the lega­cy build, we out­put processed JavaScript as [name]-legacy.[hash].js, where­as the mod­ern build out­puts it as [name].[hash].js.

By set­ting the mode to 'development' we’re telling web­pack that this is a devel­op­ment build.

By set­ting devtool to 'inline-source-map' we’re ask­ing for our .maps for our CSS/​JavaScript to be inlined into the files them­selves. This makes the files huge, but it’s con­ve­nient for debug­ging purposes.

The webpack.HotModuleReplacementPlugin enables sup­port for Hot Mod­ule Replace­ment (HMR) on the web­pack side of things.

The Dash­board­Plu­g­in plu­g­in lets us feel like an astro­naut with a fan­cy web­pack build HUD:

web­pack DashboardPlugin

I’ve found the Dash­board­Plu­g­in devel­op­ment HUD to be sig­nif­i­cant­ly more use­ful than the default web­pack progress scroll.

And that’s it, we now have a nice devel­op­ment build for our projects; check out the Hot Mod­ule Replace­ment video for an exam­ple of this in action:

Link Annotated webpack.prod.js

Now let’s have a look at our webpack.prod.js con­fig file that has all of the set­tings that are used for pro­duc­tion builds while we’re work­ing on the project. It gets merged with the set­tings in webpack.common.js to form a com­plete web­pack configuration.

// webpack.prod.js - production builds
const LEGACY_CONFIG = 'legacy';
const MODERN_CONFIG = 'modern';

// node modules
const git = require('git-rev-sync');
const glob = require('glob-all');
const merge = require('webpack-merge');
const moment = require('moment');
const path = require('path');
const webpack = require('webpack');

// webpack plugins
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const CleanWebpackPlugin = require('clean-webpack-plugin');
const CreateSymlinkPlugin = require('create-symlink-webpack-plugin');
const CriticalCssPlugin = require('critical-css-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ImageminWebpWebpackPlugin = require('imagemin-webp-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const PurgecssPlugin = require('purgecss-webpack-plugin');
const SaveRemoteFilePlugin = require('save-remote-file-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const WebappWebpackPlugin = require('webapp-webpack-plugin');
const WhitelisterPlugin = require('purgecss-whitelister');
const WorkboxPlugin = require('workbox-webpack-plugin');

// config files
const common = require('./webpack.common.js');
const pkg = require('./package.json');
const settings = require('./webpack.settings.js');

In the pre­am­ble we again pull in the Node pack­ages we need, and the web­pack plu­g­ins we use. We then import our webpack.settings.js as settings so we can access the set­tings there, and also import our package.json as pkg to access a few set­tings there as well. 

We also import our webpack.common.js com­mon web­pack con­fig that we’ll merge our dev set­tings with.

TAIL­WIND EXTRACTOR

This class is a cus­tom PurgeC­SS extrac­tor for Tail­wind CSS that allows spe­cial char­ac­ters in class names.

// Custom PurgeCSS extractor for Tailwind that allows special characters in
// class names.
//
// https://github.com/FullHuman/purgecss#extractor
class TailwindExtractor {
    static extract(content) {
        return content.match(/[A-Za-z0-9-_:\/]+/g) || [];
    }
}

This is tak­en from the Remov­ing unused CSS with PurgeC­SS sec­tion of the Tail­wind CSS docs. See below for details on how this extrac­tor works with PurgeC­SS to mag­i­cal­ly make your CSS svelte and tidy.

CON­FIG­U­RA­TION FUNCTIONS

Here’s what the configureBanner() looks like:

// Configure file banner
const configureBanner = () => {
    return {
        banner: [
            '/*!',
            ' * @project        ' + settings.name,
            ' * @name           ' + '[filebase]',
            ' * @author         ' + pkg.author.name,
            ' * @build          ' + moment().format('llll') + ' ET',
            ' * @release        ' + git.long() + ' [' + git.branch() + ']',
            ' * @copyright      Copyright (c) ' + moment().format('YYYY') + ' ' + settings.copyright,
            ' *',
            ' */',
            ''
        ].join('\n'),
        raw: true
    };
};

This sim­ply adds a ban­ner with project name, file name, author, and git infor­ma­tion for each file we build.

Next up is the configureBundleAnalyzer():

// Configure Bundle Analyzer
const configureBundleAnalyzer = (buildType) => {
    if (buildType === LEGACY_CONFIG) {
        return {
            analyzerMode: 'static',
            reportFilename: 'report-legacy.html',
        };
    }
    if (buildType === MODERN_CONFIG) {
        return {
            analyzerMode: 'static',
            reportFilename: 'report-modern.html',
        };
    }
};

This uses the Web­pack­Bundle­An­a­lyz­er plu­g­in to gen­er­ate a report for both our mod­ern and lega­cy bun­dle builds that results in a self-con­tained inter­ac­tive HTML page that allows you to explore what exact­ly is in the bun­dle that has been gen­er­at­ed by webpack.

Web­pack Bun­dle Ana­lyz­er output

I’ve found it to be very use­ful to help me keep my bun­dle sizes down, and under­stand exact­ly what web­pack is build­ing, so I’ve made it part of my pro­duc­tion build process.

Next up is the configureCriticalCss():

// Configure Critical CSS
const configureCriticalCss = () => {
    return (settings.criticalCssConfig.pages.map((row) => {
            const criticalSrc = settings.urls.critical + row.url;
            const criticalDest = settings.criticalCssConfig.base + row.template + settings.criticalCssConfig.suffix;
            let criticalWidth = settings.criticalCssConfig.criticalWidth;
            let criticalHeight = settings.criticalCssConfig.criticalHeight;
            // Handle Google AMP templates
            if (row.template.indexOf(settings.criticalCssConfig.ampPrefix) !== -1) {
                criticalWidth = settings.criticalCssConfig.ampCriticalWidth;
                criticalHeight = settings.criticalCssConfig.ampCriticalHeight;
            }
            console.log("source: " + criticalSrc + " dest: " + criticalDest);
            return new CriticalCssPlugin({
                base: './',
                src: criticalSrc,
                dest: criticalDest,
                extract: false,
                inline: false,
                minify: true,
                width: criticalWidth,
                height: criticalHeight,
            })
        })
    );
};

This uses the Crit­i­calC­ss­Plu­g­in to gen­er­ate Crit­i­calC­SS for our web­site by chunk­ing through the settings.criticalCssConfig.pages from our webpack.settings.js.

Note that if the page passed in has settings.criticalCssConfig.ampPrefix any­where in its name, it gen­er­ates Crit­i­calC­SS for the entire web­page (not just the above the fold con­tent) by pass­ing in a very large height.

I won’t go into too much detail on Crit­i­calC­SS here; check out the Imple­ment­ing Crit­i­cal CSS on your web­site arti­cle for more infor­ma­tion on CriticalCSS.

Next up is the configureCleanWebpack():

// Configure Clean webpack
const configureCleanWebpack = () => {
    return {
        root: path.resolve(__dirname, settings.paths.dist.base),
        verbose: true,
        dry: false
    };
};

This just uses the Clean­Web­pack­Plu­g­in to delete the build direc­to­ry in settings.paths.dist.base from our webpack.settings.js.

Next up is configureHtml():

// Configure Html webpack
const configureHtml = () => {
    return {
        templateContent: '',
        filename: 'webapp.html',
        inject: false,
    };
};

This uses the Html­Web­pack­Plu­g­in in con­junc­tion with the Webap­p­Web­pack­Plu­g­in (see below) to gen­er­ate the HTML for our fav­i­cons. Note that we pass in an emp­ty string in templateContent so that the out­put is just the raw out­put from the WebappWebpackPlugin.

Next up is the configureImageLoader():

// Configure Image loader
const configureImageLoader = (buildType) => {
    if (buildType === LEGACY_CONFIG) {
        return {
            test: /\.(png|jpe?g|gif|svg|webp)$/i,
            use: [
                {
                    loader: 'file-loader',
                    options: {
                        name: 'img/[name].[hash].[ext]'
                    }
                }
            ]
        };
    }
    if (buildType === MODERN_CONFIG) {
        return {
            test: /\.(png|jpe?g|gif|svg|webp)$/i,
            use: [
                {
                    loader: 'file-loader',
                    options: {
                        name: 'img/[name].[hash].[ext]'
                    }
                },
                {
                    loader: 'img-loader',
                    options: {
                        plugins: [
                            require('imagemin-gifsicle')({
                                interlaced: true,
                            }),
                            require('imagemin-mozjpeg')({
                                progressive: true,
                                arithmetic: false,
                            }),
                            require('imagemin-optipng')({
                                optimizationLevel: 5,
                            }),
                            require('imagemin-svgo')({
                                plugins: [
                                    {convertPathData: false},
                                ]
                            }),
                        ]
                    }
                }
            ]
        };
    }
};

We pass in the buildType so that we can return dif­fer­ent results depend­ing on whether it is a lega­cy or mod­ern build. In this case, we run images through a vari­ety of image opti­miza­tions via img-loader for the mod­ern build.

We only do this for the mod­ern build, because there’s no sense in spend­ing the time to opti­mize the images for both the mod­ern and the lega­cy builds (the images are the same for both).

It’s impor­tant to note that this is only for images that are includ­ed in our web­pack build; many oth­er images will be com­ing from else­where (a CMS sys­tem, an asset man­age­ment sys­tem, etc.).

To let web­pack know about an image, you import it into your JavaScript:

import Icon from './icon.png';

Check out the Load­ing Images sec­tion of the web­pack docs for more details on this.

Next up is our configureOptimization():

// Configure optimization
const configureOptimization = (buildType) => {
    if (buildType === LEGACY_CONFIG) {
        return {
            splitChunks: {
                cacheGroups: {
                    default: false,
                    common: false,
                    styles: {
                        name: settings.vars.cssName,
                        test: /\.(pcss|css|vue)$/,
                        chunks: 'all',
                        enforce: true
                    }
                }
            },
            minimizer: [
                new TerserPlugin(
                    configureTerser()
                ),
                new OptimizeCSSAssetsPlugin({
                    cssProcessorOptions: {
                        map: {
                            inline: false,
                            annotation: true,
                        },
                        safe: true,
                        discardComments: true
                    },
                })
            ]
        };
    }
    if (buildType === MODERN_CONFIG) {
        return {
            minimizer: [
                new TerserPlugin(
                    configureTerser()
                ),
            ]
        };
    }
};

This is where we con­fig­ure the web­pack pro­duc­tion opti­miza­tion. For the lega­cy build only (there’s no sense in doing it twice), we use the MiniC­s­sEx­tract­Plu­g­in to extract all of the CSS used project-wide into a sin­gle file. If you’ve used web­pack before, you might have used the Extract­TextPlu­g­in to do this in the past; no more.

We then also use the Opti­mizeC­SSAs­set­sPlu­g­in to opti­mize the result­ing CSS by remov­ing dupli­cate rules, and min­i­miz­ing the CSS via cssnano.

Final­ly, we set the JavaScript min­i­miz­er to be the Terser­Plu­g­in; this is because the Ugli­fyJs­Plu­g­in no longer sup­ports min­i­miz­ing ES2015+ JavaScript. And since we’re gen­er­at­ing mod­ern ES2015+ bun­dles, we need it.

Next up is the configurePostcssLoader():

// Configure Postcss loader
const configurePostcssLoader = (buildType) => {
    if (buildType === LEGACY_CONFIG) {
        return {
            test: /\.(pcss|css)$/,
            use: [
                MiniCssExtractPlugin.loader,
                {
                    loader: 'css-loader',
                    options: {
                        importLoaders: 2,
                        sourceMap: true
                    }
                },
                {
                    loader: 'resolve-url-loader'
                },
                {
                    loader: 'postcss-loader',
                    options: {
                        sourceMap: true
                    }
                }
            ]
        };
    }
    // Don't generate CSS for the modern config in production
    if (buildType === MODERN_CONFIG) {
        return {
            test: /\.(pcss|css)$/,
            loader: 'ignore-loader'
        };
    }
};

This looks very sim­i­lar to the dev ver­sion of configurePostcssLoader(), except that for our final loader, we use the MiniCssExtractPlugin.loader to extract all of our CSS into a sin­gle file.

We do this only for the lega­cy build, since there’s no sense in doing it for each build (the CSS is the same). We use the ignore-loader for mod­ern builds, so a loader exists for our .css & .pcss files, but it does nothing.

As men­tioned ear­li­er, we use PostC­SS to process all of our CSS, includ­ing Tail­wind CSS. I think of it as the Babel of CSS, in that it com­piles all sorts of advanced CSS func­tion­al­i­ty down to plain old CSS that your browsers can understand.

Again, it’s impor­tant to note that for web­pack load­ers, they are processed in reverse order that they are listed:

Since this is a pro­duc­tion build, we pull out all of the CSS used every­where with the MiniCssExtractPlugin.loader, and save it to a sin­gle .css file. The CSS also gets min­i­mized, and opti­mized for production.

We tell web­pack about our CSS by includ­ing it:

import styles from '../css/app.pcss';

This is dis­cussed in detail in the Load­ing CSS sec­tion of the web­pack docs.

We do this from our App.js entry point; think of this as the PostC­SS entry point. The app.pcss file @imports all of the CSS that our project uses; this will be cov­ered in detail lat­er on.

Next up is the configurePurgeCss():

// Configure PurgeCSS
const configurePurgeCss = () => {
    let paths = [];
    // Configure whitelist paths
    for (const [key, value] of Object.entries(settings.purgeCssConfig.paths)) {
        paths.push(path.join(__dirname, value));
    }

    return {
        paths: glob.sync(paths),
        whitelist: WhitelisterPlugin(settings.purgeCssConfig.whitelist),
        whitelistPatterns: settings.purgeCssConfig.whitelistPatterns,
        extractors: [
            {
                extractor: TailwindExtractor,
                extensions: settings.purgeCssConfig.extensions
            }
        ]
    };
};

Tail­wind CSS is a fan­tas­tic util­i­ty-first CSS frame­work that allows for rapid pro­to­typ­ing because in local devel­op­ment, you rarely have to actu­al­ly write any CSS. Instead, you just use the pro­vid­ed util­i­ty CSS classes.

The down­side is that the result­ing CSS can be a lit­tle large. This is where PurgeC­SS comes in. It will parse through all of your HTML/​template/​Vue/​whatever files, and strip out any unused CSS.

The sav­ings can be dra­mat­ic; Tail­wind CSS and PurgeC­SS are a match made in heav­en. We talked about this in depth on the Tail­wind CSS util­i­ty-first CSS with Adam Wathan podcast.

It iter­ates through all of the path globs in settings.purgeCssConfig.paths look­ing for CSS rules to keep; any CSS rules not found get stripped out of our result­ing CSS build.

We also use the Whitelis­ter­Plu­g­in to make it easy to whitelist entire files or even globs when we know we don’t want cer­tain CSS stripped. The CSS rules in all of the files that match our settings.purgeCssConfig.whitelist are whitelist­ed, and nev­er stripped from the result­ing build.

Next up is configureTerser():

// Configure terser
const configureTerser = () => {
    return {
        cache: true,
        parallel: true,
        sourceMap: true
    };
};

This just con­fig­ures some set­tings used by the Terser­Plu­g­in that min­i­mizes both our lega­cy and mod­ern JavaScript code.

Next up is the configureWebApp():

// Configure Webapp webpack
const configureWebapp = () => {
    return {
        logo: settings.webappConfig.logo,
        prefix: settings.webappConfig.prefix,
        cache: false,
        inject: 'force',
        favicons: {
            appName: pkg.name,
            appDescription: pkg.description,
            developerName: pkg.author.name,
            developerURL: pkg.author.url,
            path: settings.paths.dist.base,
        }
    };
};

This uses the Webap­p­Web­pack­Plu­g­in to gen­er­ate all of our site fav­i­cons in a myr­i­ad of for­mats, as well as our webapp manifest.json and oth­er PWA niceties.

It works in con­junc­tion with the Html­Web­pack­Plu­g­in to also out­put a webapp.html file that con­tains links to all of the gen­er­at­ed fav­i­cons and asso­ci­at­ed files, for inclu­sion in our HTML page’s <head></head>.

Next up is the configureWorkbox():

// Configure Workbox service worker
const configureWorkbox = () => {
    let config = settings.workboxConfig;

    return config;
};

We use Google’s Work­boxWeb­pack­Plu­g­in to gen­er­ate a Ser­vice Work­er for our web­site. It’s beyond the scope of this arti­cle explain what a Ser­vice Work­er is, but you can check out the Going Offline: Ser­vice Work­ers with Jere­my Kei­th pod­cast for a primer.

The con­fig­u­ra­tion all comes from the settings.workboxConfig object in our webpack.settings.js. In addi­tion to pre-caching all of the assets in our mod­ern build manifest.json, we also include a workbox-catch-handler.js to con­fig­ure it to use a fall­back response catch-all route.

// fallback URLs
const FALLBACK_HTML_URL = '/offline.html';
const FALLBACK_IMAGE_URL = '/offline.svg';

// This "catch" handler is triggered when any of the other routes fail to
// generate a response.
// https://developers.google.com/web/tools/workbox/guides/advanced-recipes#provide_a_fallback_response_to_a_route
workbox.routing.setCatchHandler(({event, request, url}) => {
    // Use event, request, and url to figure out how to respond.
    // One approach would be to use request.destination, see
    // https://medium.com/dev-channel/service-worker-caching-strategies-based-on-request-types-57411dd7652c
    switch (request.destination) {
        case 'document':
            return caches.match(FALLBACK_HTML_URL);
            break;

        case 'image':
            return caches.match(FALLBACK_IMAGE_URL);
            break;

        default:
            // If we don't have a fallback, just return an error response.
            return Response.error();
    }
});

// Use a stale-while-revalidate strategy for all other requests.
workbox.routing.setDefaultHandler(
    workbox.strategies.staleWhileRevalidate()
);

MODULE.EXPORTS

Final­ly, the module.exports uses the web­pack-merge to merge the common.legacyConfig from the webpack.common.js with our pro­duc­tion lega­cy con­fig, and the common.modernConfig with our pro­duc­tion mod­ern config:

// Production module exports
module.exports = [
    merge(
        common.legacyConfig,
        {
            output: {
                filename: path.join('./js', '[name]-legacy.[chunkhash].js'),
            },
            mode: 'production',
            devtool: 'source-map',
            optimization: configureOptimization(LEGACY_CONFIG),
            module: {
                rules: [
                    configurePostcssLoader(LEGACY_CONFIG),
                    configureImageLoader(LEGACY_CONFIG),
                ],
            },
            plugins: [
                new CleanWebpackPlugin(settings.paths.dist.clean,
                    configureCleanWebpack()
                ),
                new MiniCssExtractPlugin({
                    path: path.resolve(__dirname, settings.paths.dist.base),
                    filename: path.join('./css', '[name].[chunkhash].css'),
                }),
                new PurgecssPlugin(
                    configurePurgeCss()
                ),
                new webpack.BannerPlugin(
                    configureBanner()
                ),
                new HtmlWebpackPlugin(
                    configureHtml()
                ),
                new WebappWebpackPlugin(
                    configureWebapp()
                ),
                new CreateSymlinkPlugin(
                    settings.createSymlinkConfig,
                    true
                ),
                new SaveRemoteFilePlugin(
                    settings.saveRemoteFileConfig
                ),
                new BundleAnalyzerPlugin(
                    configureBundleAnalyzer(LEGACY_CONFIG),
                ),
            ].concat(
                configureCriticalCss()
            )
        }
    ),
    merge(
        common.modernConfig,
        {
            output: {
                filename: path.join('./js', '[name].[chunkhash].js'),
            },
            mode: 'production',
            devtool: 'source-map',
            optimization: configureOptimization(MODERN_CONFIG),
            module: {
                rules: [
                    configurePostcssLoader(MODERN_CONFIG),
                    configureImageLoader(MODERN_CONFIG),
                ],
            },
            plugins: [
                new webpack.optimize.ModuleConcatenationPlugin(),
                new webpack.BannerPlugin(
                    configureBanner()
                ),
                new ImageminWebpWebpackPlugin(),
                new WorkboxPlugin.GenerateSW(
                    configureWorkbox()
                ),
                new BundleAnalyzerPlugin(
                    configureBundleAnalyzer(MODERN_CONFIG),
                ),
            ]
        }
    ),
];

By return­ing an array in our module.exports, we’re telling web­pack that we have more than one com­pile that needs to be done: one for our lega­cy build, and anoth­er for our mod­ern build.

Note that for the lega­cy build, we out­put processed JavaScript as [name]-legacy.[hash].js, where­as the mod­ern build out­puts it as [name].[hash].js.

By set­ting the mode to 'production' we’re telling web­pack that this is a pro­duc­tion build. This enables a num­ber of set­tings appro­pri­ate for a pro­duc­tion build.

By set­ting devtool to 'source-map' we’re ask­ing for our .maps for our CSS/​JavaScript to be gen­er­at­ed as sep­a­rate .map files. This makes it eas­i­er for us to debug live pro­duc­tion web­sites with­out adding the file size of our assets.

There are a cou­ple of web­pack plu­g­ins used here that we haven’t cov­ered already:

  • Cre­ateSym­linkPlu­g­in — this is a plu­g­in I cre­at­ed to allow for sym­link cre­ation as part of the build process. I use it to sym­link the gen­er­at­ed favicon.ico to /favicon.ico because many web browsers look for in the web root.
  • SaveR­e­mote­File­Plu­g­in — this is a plu­g­in I cre­at­ed to down­load remote files and emit them as part of the web­pack build process. I use this for down­load­ing and serv­ing up Google’s analytics.js locally.
  • Imagem­inWebp­Web­pack­Plu­g­in — this plu­g­in cre­ates .webp vari­ants of all of the JPEG and PNG files that your project imports

And that’s it, we now have a nice pro­duc­tion build for our projects with all of the bells & whistles.

Link Tailwind CSS & PostCSS Config

To make web­pack build Tail­wind CSS and the rest of our CSS prop­er­ly, we need to do a lit­tle set­up. Cred­it to my part­ner in crime, Jonathan Melville, for work­ing this aspect of the build out. First we need a postcss.config.js file:

module.exports = {
    plugins: [
        require('postcss-import'),
        require('postcss-extend'),
        require('postcss-simple-vars'),
        require('postcss-nested-ancestors'),
        require('postcss-nested'),
        require('postcss-hexrgba'),
        require('autoprefixer'),
        require('tailwindcss')('./tailwind.config.js')
    ]
};

This can be stored in the project root; PostC­SS will look for it auto­mat­i­cal­ly as part of the build process, and apply the PostC­SS plu­g­ins we’ve spec­i­fied. Note this is where we include the tailwind.config.js file to make it part of the build process.

Final­ly, our CSS entry point app.pcss looks some­thing like this:

/**
 * app.css
 *
 * The entry point for the css.
 *
 */

/**
 * This injects Tailwind's base styles, which is a combination of
 * Normalize.css and some additional base styles.
 *
 * You can see the styles here:
 * https://github.com/tailwindcss/tailwindcss/blob/master/css/preflight.css
 */
 @import "tailwindcss/preflight";

/**
 * This injects any component classes registered by plugins.
 *
 */
@import 'tailwindcss/components';

/**
 * Here we add custom component classes; stuff we want loaded
 * *before* the utilities so that the utilities can still
 * override them.
 *
 */
@import './components/global.pcss';
@import './components/typography.pcss';
@import './components/webfonts.pcss';

/**
 * This injects all of Tailwind's utility classes, generated based on your
 * config file.
 *
 */
@import 'tailwindcss/utilities';

/**
 * Include styles for individual pages
 *
 */
@import './pages/homepage.pcss';

/**
 * Include vendor css.
 *
 */
 @import 'vendor.pcss';

Obvi­ous­ly, tai­lor it to include what­ev­er components/​pages that you use for your cus­tom CSS.

Link Post-Build Project Tree

Here’s what our project tree looks like post-build:

├── example.env
├── package.json
├── postcss.config.js
├── src
│   ├── css
│   │   ├── app.pcss
│   │   ├── components
│   │   │   ├── global.pcss
│   │   │   ├── typography.pcss
│   │   │   └── webfonts.pcss
│   │   ├── pages
│   │   │   └── homepage.pcss
│   │   └── vendor.pcss
│   ├── fonts
│   ├── img
│   │   └── favicon-src.png
│   ├── js
│   │   ├── app.js
│   │   └── workbox-catch-handler.js
│   └── vue
│       └── Confetti.vue
├── tailwind.config.js
├── templates
├── web
│   ├── dist
│   │   ├── criticalcss
│   │   │   └── index_critical.min.css
│   │   ├── css
│   │   │   ├── styles.d833997e3e3f91af64e7.css
│   │   │   └── styles.d833997e3e3f91af64e7.css.map
│   │   ├── img
│   │   │   └── favicons
│   │   │       ├── android-chrome-144x144.png
│   │   │       ├── android-chrome-192x192.png
│   │   │       ├── android-chrome-256x256.png
│   │   │       ├── android-chrome-36x36.png
│   │   │       ├── android-chrome-384x384.png
│   │   │       ├── android-chrome-48x48.png
│   │   │       ├── android-chrome-512x512.png
│   │   │       ├── android-chrome-72x72.png
│   │   │       ├── android-chrome-96x96.png
│   │   │       ├── apple-touch-icon-114x114.png
│   │   │       ├── apple-touch-icon-120x120.png
│   │   │       ├── apple-touch-icon-144x144.png
│   │   │       ├── apple-touch-icon-152x152.png
│   │   │       ├── apple-touch-icon-167x167.png
│   │   │       ├── apple-touch-icon-180x180.png
│   │   │       ├── apple-touch-icon-57x57.png
│   │   │       ├── apple-touch-icon-60x60.png
│   │   │       ├── apple-touch-icon-72x72.png
│   │   │       ├── apple-touch-icon-76x76.png
│   │   │       ├── apple-touch-icon.png
│   │   │       ├── apple-touch-icon-precomposed.png
│   │   │       ├── apple-touch-startup-image-1182x2208.png
│   │   │       ├── apple-touch-startup-image-1242x2148.png
│   │   │       ├── apple-touch-startup-image-1496x2048.png
│   │   │       ├── apple-touch-startup-image-1536x2008.png
│   │   │       ├── apple-touch-startup-image-320x460.png
│   │   │       ├── apple-touch-startup-image-640x1096.png
│   │   │       ├── apple-touch-startup-image-640x920.png
│   │   │       ├── apple-touch-startup-image-748x1024.png
│   │   │       ├── apple-touch-startup-image-750x1294.png
│   │   │       ├── apple-touch-startup-image-768x1004.png
│   │   │       ├── browserconfig.xml
│   │   │       ├── coast-228x228.png
│   │   │       ├── favicon-16x16.png
│   │   │       ├── favicon-32x32.png
│   │   │       ├── favicon.ico
│   │   │       ├── firefox_app_128x128.png
│   │   │       ├── firefox_app_512x512.png
│   │   │       ├── firefox_app_60x60.png
│   │   │       ├── manifest.json
│   │   │       ├── manifest.webapp
│   │   │       ├── mstile-144x144.png
│   │   │       ├── mstile-150x150.png
│   │   │       ├── mstile-310x150.png
│   │   │       ├── mstile-310x310.png
│   │   │       ├── mstile-70x70.png
│   │   │       ├── yandex-browser-50x50.png
│   │   │       └── yandex-browser-manifest.json
│   │   ├── js
│   │   │   ├── analytics.45eff9ff7d6c7c1e3c3d4184fdbbed90.js
│   │   │   ├── app.30334b5124fa6e221464.js
│   │   │   ├── app.30334b5124fa6e221464.js.map
│   │   │   ├── app-legacy.560ef247e6649c0c24d0.js
│   │   │   ├── app-legacy.560ef247e6649c0c24d0.js.map
│   │   │   ├── confetti.1152197f8c58a1b40b34.js
│   │   │   ├── confetti.1152197f8c58a1b40b34.js.map
│   │   │   ├── confetti-legacy.8e9093b414ea8aed46e5.js
│   │   │   ├── confetti-legacy.8e9093b414ea8aed46e5.js.map
│   │   │   ├── precache-manifest.f774c437974257fc8026ca1bc693655c.js
│   │   │   ├── styles-legacy.d833997e3e3f91af64e7.js
│   │   │   ├── styles-legacy.d833997e3e3f91af64e7.js.map
│   │   │   ├── vendors~confetti~vue.03b9213ce186db5518ea.js
│   │   │   ├── vendors~confetti~vue.03b9213ce186db5518ea.js.map
│   │   │   ├── vendors~confetti~vue-legacy.e31223849ab7fea17bb8.js
│   │   │   ├── vendors~confetti~vue-legacy.e31223849ab7fea17bb8.js.map
│   │   │   └── workbox-catch-handler.js
│   │   ├── manifest.json
│   │   ├── manifest-legacy.json
│   │   ├── report-legacy.html
│   │   ├── report-modern.html
│   │   ├── webapp.html
│   │   └── workbox-catch-handler.js
│   ├── favicon.ico -> dist/img/favicons/favicon.ico
│   ├── index.php
│   ├── offline.html
│   ├── offline.svg
│   └── sw.js
├── webpack.common.js
├── webpack.dev.js
├── webpack.prod.js
├── webpack.settings.js
└── yarn.lock

Link Injecting script & CSS tags in your HTML

With the web­pack con­fig shown here, <script> and <style> tags do not get inject­ed into your HTML as part of the pro­duc­tion build. The set­up uses Craft CMS, which has a tem­plat­ing sys­tem, and we inject the tags using the Twig­pack plu­g­in.

If you’re not using Craft CMS or a sys­tem that has a tem­plat­ing engine, and want these tags inject­ed into your HTML, you’ll want to use the Html­Web­pack­Plu­g­in to do that for you. This plu­g­in is already includ­ed, you’d just need to add a lit­tle con­fig to tell it to inject the tags into your HTML.

Link Craft CMS 3 Integration with the Twigpack plugin

If you’re not using Craft CMS 3, you can safe­ly skip this sec­tion. It just pro­vides some use­ful inte­gra­tion information.

I wrote a free plu­g­in called Twig­pack that makes it easy to inte­grate our fan­cy web­pack build set­up with Craft CMS 3.

It han­dles access­ing the manifest.json files to inject entry points into your Twig tem­plates, and it even han­dles pat­terns for doing the legacy/​modern mod­ule injec­tion, asyn­chro­nous CSS load­ing, and a whole lot more.

It’ll make work­ing with the web­pack 4 con­fig pre­sent­ed here very simple.

To include the CSS, I do:

<!--# if expr="$HTTP_COOKIE=/critical\-css\=1/" -->
    {{ craft.twigpack.includeCssModule("styles.css", false) }}
<!--# else -->
    <script>
        Cookie.set("critical-css", '1', { expires: "7D", secure: true });
    </script>
    {{ craft.twigpack.includeCriticalCssTags() }}

    {{ craft.twigpack.includeCssModule("styles.css", true) }}
    {{ craft.twigpack.includeCssRelPreloadPolyfill() }}
<!--# endif -->

The <!--# --> HTML com­ments are Nginx Servi­er Side Includes direc­tives. The pat­tern is that if the critical-css cook­ie is set, the user has already vis­it­ed our web­site in the last 7 days, so their brows­er should have the site CSS cached, and we just serve up the site CSS normally.

If the critical-css cook­ie is not set, we set the cook­ie via Tiny Cook­ie, include our Crit­i­cal CSS, and load the site CSS asyn­chro­nous­ly. See the Imple­ment­ing Crit­i­cal CSS on your web­site arti­cle for details on Crit­i­cal CSS.

To serve up our JavaScript, we just do:

{{ craft.twigpack.includeSafariNomoduleFix() }}
{{ craft.twigpack.includeJsModule("app.js", true) }}

The sec­ond true para­me­ter tells it to load the JavaScript async as a mod­ule, so the result­ing HTML looks like this:

<script>
!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();
</script>
<script type="module" src="http://example.test/dist/js/app.273e88e73566fecf20de.js"></script>
<script nomodule src="http://example.test/dist/js/app-legacy.95d36ead9190c0571578.js"></script>

See the Twig­pack doc­u­men­ta­tion for details

Here’s my full config/twigpack.php file that I use; note that it has local set­tings for run­ning inside of my Home­stead VM. Your set­tings may differ:

return [
    // Global settings
    '*' => [
        // If `devMode` is on, use webpack-dev-server to all for HMR (hot module reloading)
        'useDevServer' => false,
        // The JavaScript entry from the manifest.json to inject on Twig error pages
        'errorEntry' => '',
        // Manifest file names
        'manifest' => [
            'legacy' => 'manifest-legacy.json',
            'modern' => 'manifest.json',
        ],
        // Public server config
        'server' => [
            'manifestPath' => '/dist/',
            'publicPath' => '/',
        ],
        // webpack-dev-server config
        'devServer' => [
            'manifestPath' => 'http://localhost:8080/',
            'publicPath' => 'http://localhost:8080/',
        ],
        // Local files config
        'localFiles' => [
            'basePath' => '@webroot/',
            'criticalPrefix' => 'dist/criticalcss/',
            'criticalSuffix' => '_critical.min.css',
        ],
    ],
    // Live (production) environment
    'live' => [
    ],
    // Staging (pre-production) environment
    'staging' => [
    ],
    // Local (development) environment
    'local' => [
        // If `devMode` is on, use webpack-dev-server to all for HMR (hot module reloading)
        'useDevServer' => true,
        // The JavaScript entry from the manifest.json to inject on Twig error pages
        'errorEntry' => 'app.js',
        // webpack-dev-server config
        'devServer' => [
            'manifestPath' => 'http://localhost:8080/',
            'publicPath' => 'http://192.168.10.10:8080/',
        ],
    ],
];

Link Wrapping up!

Well, that was quite a deep dive! When I first start­ed delv­ing into web­pack, I soon real­ized that it’s a tremen­dous­ly pow­er­ful tool, with very deep func­tion­al­i­ty. How deep you go depends on how far you want to dive.

For the com­plete source code for every­thing pre­sent­ed here, check out the anno­tat­ed-web­pack-4-con­fig github repo.

Hope­ful­ly this was help­ful to you, enjoy your jour­ney, and go build some­thing awesome!