DEV Community

Andrew Welch
Andrew Welch

Posted on • Originally published at nystudio107.com on

12 5

An Annotated webpack 4 Config for Frontend Web Development

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

Andrew Welch / nystudio107

Webpack 4 Annotated 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.

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.

Buckle Up I Want To Try Something

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

Adopt­ing 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 anyway.
  • 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.

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:

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.

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.

Webpack Black Box

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 Workbox project to gen­er­ate a Ser­vice Work­er for us that will know about all of our pro­jec­t’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.
  • Offline Com­pres­sion of sta­t­ic resources — We can pre-com­press our sta­t­ic resources into .gz files that our web­serv­er can auto­mat­i­cal­ly serve up to clients that accept them

Phew, quite an ambi­tious list!

Ambitious Feature List Webpack

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.

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!

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:

Webpack Config Text Solid Sm

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.

Anno­tat­ed package.json

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


{
    "name": "example-project",
    "version": "1.1.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": {
    "debug": "webpack-dev-server --config webpack.dev.js",
    "dev": "webpack-dashboard -- 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:

  • debug  — used when you need to debug the web­pack build itself; this dis­ables the webpack-dashboard (see below) to make get­ting at the con­sole out­put easier
  • 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",
    "@gfx/zopfli": "^1.0.11",
    "babel-loader": "^8.0.2",
    "clean-webpack-plugin": "^3.0.0",
    "compression-webpack-plugin": "^2.0.0",
    "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": "^2.1.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": "^6.0.0",
    "imagemin-mozjpeg": "^8.0.0",
    "imagemin-optipng": "^6.0.0",
    "imagemin-svgo": "^7.0.0",
    "imagemin-webp": "^5.0.0",
    "imagemin-webp-webpack-plugin": "^3.1.0",
    "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-import": "^12.0.0",
    "postcss-loader": "^3.0.0",
    "postcss-preset-env": "^6.4.0",
    "purgecss-webpack-plugin": "^1.3.0",
    "purgecss-whitelister": "^2.2.0",
    "resolve-url-loader": "^3.0.0",
    "save-remote-file-webpack-plugin": "^1.0.0",
    "stylelint": "^9.9.0",
    "stylelint-config-recommended": "^2.1.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": "^3.0.0",
    "webpack-dev-server": "^3.3.0",
    "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": {
    "axios": "^0.18.0",
    "core-js": "^3.0.0",
    "regenerator-runtime": "^0.13.2",
    "tailwindcss": "^1.0.0",
    "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.

Anno­tat­ed 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.

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: [
                '**/*',
            ]
        },
        templates: "./templates/"
    },
    urls: {
        live: "https://example.com/",
        local: "http://example.test/",
        critical: "http://example.test/",
        publicPath: () => process.env.PUBLIC_PATH || "/dist/",
    },
    vars: {
        cssName: "styles"
    },
    entries: {
        "app": "app.js"
    },
    babelLoaderConfig: {
        exclude: [
            /(node_modules|bower_components)/
        ],
    },
    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}"
        ],
        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/js/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 did­n’t use JSON as a file for­mat for flex­i­bil­i­ty, and also to allow for com­ments to be added.

Com­mon Con­ven­tions for web­pack configs

I’ve adopt­ed a few con­ven­tions for the web­pack con­fig files webpack.common.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.

The webpack.dev.js con­fig does not have a con­cept of lega­cy & mod­ern builds; if we’re work­ing in local dev with webpack-dev-server, we can assume a mod­ern build.

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.

Anno­tat­ed 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: settings.babelLoaderConfig.exclude,
        cacheDirectory: true,
        use: {
            loader: 'babel-loader',
            options: {
                cacheDirectory: true,
                sourceType: 'unambiguous',
                presets: [
                    [
                        '@babel/preset-env', {
                            modules: false,
                            corejs: {
                                version: 2,
                                proposals: true
                            },
                            useBuiltIns: 'usage',
                            targets: {
                                browsers: browserList,
                            },
                        }
                    ],
                ],
                plugins: [
                    '@babel/plugin-syntax-dynamic-import',
                    '@babel/plugin-transform-runtime',
                ],
            },
        },
    };
};

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.

By set­ting useBuiltIns to 'usage' we are also telling babel to apply indi­vid­ual pol­ly­fills on a per-file basis. This can allow for a much small­er bun­dle size, since it includes only what we use. For more on this, check out the Work­ing with Babel 7 and Web­pack article.

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 { default: Vue } = await import(/* webpackChunkName: "vue" */ 'vue');
    // Create our vue instance
    const vm = new Vue({
        el: "#app",
        components: {
            'confetti': () => import(/* webpackChunkName: "confetti" */ '../vue/Confetti.vue'),
        },
    });

    return vm;
};
// Execute async function
main().then( (vm) => {
});
// Accept HMR as per: https://webpack.js.org/api/hot-module-replacement#accept
if (module.hot) {
    module.hot.accept();
}

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.strategy({
        module: 'prepend',
        plugins: 'prepend',
    })(
        baseConfig,
        legacyConfig,
    ),
    'modernConfig': merge.strategy({
        module: 'prepend',
        plugins: 'prepend',
    })(
        baseConfig,
        modernConfig,
    ),
};

Anno­tat­ed 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

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

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

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

In the webpack.dev.js con­fig, there isn’t a con­cept of mod­ern & lega­cy builds, because in local dev when we’re using webpack-dev-server, we can assume a mod­ern build.

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 = () => {
    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()),
        disableHostCheck: true,
        hot: true,
        overlay: true,
        watchContentBase: true,
        watchOptions: {
            poll: !!parseInt(settings.devServerConfig.poll()),
            ignored: /node_modules/,
        },
        headers: {
            'Access-Control-Allow-Origin': '*'
        },
    };
};

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.

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 an option­al .env file for your own par­tic­u­lar devServer config:


# webpack example settings for Homestead/Vagrant
PUBLIC_PATH="/dist/"
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,
},
urls: {
    live: "https://example.com/",
    local: "http://example.test/",
    critical: "http://example.test/",
    publicPath: () => process.env.PUBLIC_PATH || "/dist/",
},

We also use the PUBLIC_PATH .env vari­able (if present) to allow for per-envi­ron­ment builds of the pro­duc­tion build. This is so that we can do a local pro­duc­tion build, or we can do a dis­tri­b­u­tion pro­duc­tion build in a Dock­er con­tain­er that builds with URLs ready for dis­tri­b­u­tion via a CDN.

Next up is the configureImageLoader():


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

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 = () => {
    return {
        test: /\.(pcss|css)$/,
        use: [
            {
                loader: 'style-loader',
            },
            {
                loader: 'vue-style-loader',
            },
            {
                loader: 'css-loader',
                options: {
                    url: false,
                    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.modernConfig with our dev config:


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

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:

Webpack Dashboard Plugin

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.

If you find that you need to debug the web­pack con­fig itself, you can use yarn run debug or npm run debug to run the local devel­op­ment build but bypass the webpack-dashboard.

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:

Anno­tat­ed 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.

Webpack Bundle Analyzer

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 {
        cleanOnceBeforeBuildPatterns: settings.paths.dist.clean,
        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 configureCompression():


// Configure Compression webpack plugin
const configureCompression = () => {
    return {
        filename: '[path].gz[query]',
        test: /\.(js|css|html|svg)$/,
        threshold: 10240,
        minRatio: 0.8,
        deleteOriginalAssets: false,
        compressionOptions: {
            numiterations: 15,
            level: 9
        },
        algorithm(input, compressionOptions, callback) {
            return zopfli.gzip(input, compressionOptions, callback);
        }
    };
};

This uses the Com­pres­sion­Plu­g­in to pre-com­press our sta­t­ic resources into .gz files so we can serve them up pre-com­pressed via a sim­ple web­serv­er con­fig.

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 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 CleanWebpackPlugin(
                    configureCleanWebpack()
                ),
                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.

Tail­wind CSS & PostC­SS 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')({
            plugins: [
                require('stylelint')
            ]
        }),
        require('tailwindcss')('./tailwind.config.js'),
        require('postcss-preset-env')({
            autoprefixer: { grid: true },
            features: {
                'nesting-rules': true
            }
        })
    ]
};

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.

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

Inject­ing 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.

Craft CMS 3 Inte­gra­tion with the Twig­pack 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.

Twigpack Plugin Logo Lg

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 Serv­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/',
        ],
    ],
];

Wrap­ping 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.

Webpack Deep 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!

Further Reading

If you want to be notified about new articles, follow nystudio107 on Twitter.

Copyright ©2020 nystudio107. Designed by nystudio107

Top comments (1)

Collapse
 
nazimudheen_ti profile image
NAZIMUDHEEN TI

thanks bro,

Some comments may only be visible to logged-in visitors. Sign in to view all comments.

AWS Security LIVE!

Join us for AWS Security LIVE!

Discover the future of cloud security. Tune in live for trends, tips, and solutions from AWS and AWS Partners.

Learn More

Instrument, monitor, fix: a hands-on debugging session

Join Lazar for a hands-on session where you’ll build it, break it, debug it, and fix it. You’ll set up Sentry, track errors, use Session Replay and Tracing, and leverage some good ol’ AI to find and fix issues fast.

Tune in to the full event

DEV is partnering to bring live events to the community. Join us or dismiss this billboard if you're not interested. ❤️