DEV Community

Andrew Welch
Andrew Welch

Posted on • Originally published at nystudio107.com on

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.