Setting Up A Modern Webdev Toolchain

This guide has seen a few revisions, originally it only covered babel and webpack, I've since expanded it to include more tools

Why?

We often want to make use of other JavaScript code in our own, and organize our code into separate files. Webpack helps bundle all of your code into a single minified file, as well as help split into into different chunks you can dynamically load at runtime.

Imagine you also want to write your JavaScript using ES2015 or newer syntax, but want to support older browsers or other runtimes that may not have it. Babel is built for this, it is a tool specifically designed to translate various forms of JavaScript.

These tools, and a few others, can help simplify your workflow while giving you a lot of power over how to manage your code.

How?

Zero-config Webpack

Let's create a project, and install Webpack and for the sake of example use jquery as a dependency.

terminal

mkdir first-bundle
cd first-bundle
npm init -y
npm i -D webpack{,-cli}
npm i -S jquery

Create a simple file at src/index.js

src/index.js

const $ = require('jquery')
$('body').prepend('<h1>hello world</h1>')

Now let's use Webpack to compile it, and check the output:

terminal

npx webpack -d # -d is a shorthand for a few different development flags
ls dist
less dist/main.js

  • if npx webpack is not available, ensure you installed the webpack-cli package, try ./node_modules/.bin/webpack, or upgrade to a newer version of node.

Note the difference when using production mode:

terminal

npx webpack -p # -p is a shorthand for a few different production flags
less dist/main.js

Webpack starts with it's given entrypoint[s], and parses for any import or require tokens that point to other files or packages, and then recursively works through those files to build up a dependency tree it calls a manifest.

These two commands alone can be very helpful on their own, and the Webpack CLI has many options to alter it's behavior, you can read them with npx webpack --help or on their website.

Babel

Now let's add Babel into the mix!

terminal

npm i -D @babel/{core,preset-env} babel-loader

A small config for Babel:

babel.config.js

module.exports = {
presets: ['@babel/preset-env'],
}

And now we need to make a small Webpack configuration to tell Webpack how to use Babel:

webpack.config.js

module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: 'babel-loader',
},
],
},
}

Now let's make a new smaller test case just to test Babel

src/index.js

const test = () => console.log('hello world')
test()

And build it one more time and check the output

terminal

npx webpack -p
less dist/main.js

Note that Webpack has a small overhead in the bundle size, just under 1KB.


Since the Webpack config is a regular JavaScript file, we're not limited to cramming everything into a single object, we can move things around and extract pieces into variables:

webpack.config.js

const babel = {
test: /\.js$/,
exclude: /node_modules/,
use: 'babel-loader',
}
module.exports = {
module: {
rules: [babel],
},
}

Webpack uses a default input file, or "entry point", of src/index.js, which we can override:

webpack.config.js

module.exports = {
entry: { main: './src/app.js' },
module: {
rules: [babel],
},
}

Changing the output path isn't much different:

webpack.config.js

const path = require('path')
module.exports = {
entry: { main: './src/app.js' },
output: {
path: path.resolve(__dirname, 'public'),
},
module: {
rules: [babel],
},
}

Renaming the file is also relatively straight-forward:

webpack.config.js

module.exports = {
entry: { app: './src/app.js' },
output: {
path: path.resolve(__dirname, 'public'),
filename: '[name]-bundle.js', // will now emit app-bundle.js
},
module: {
rules: [babel],
},
}

We can also introduce "automagic" file extension resolution, so that when we import files in our code we can omit the file extension:

webpack.config.js

module.exports = {
- entry: { app: './src/app.js' },
+ entry: { app: './src/app' },
+ resolve: { extensions: ['.js'] },
output: {
path: path.resolve(__dirname, 'public'),
filename: '[name]-bundle.js',
},
module: {
rules: [babel],
},
}

Configuring Webpack for React

Install React and the Babel preset:

terminal

npm i -S react react-dom
npm i -D @babel/preset-react

Add the new preset to the babel config:

babel.config.js

module.exports = {
presets: ['@babel/preset-env', '@babel/preset-react'],
}

This should work as-is, but to use .jsx file extensions we need a couple of small changes:

webpack.config.js

const babel = [
{
- test: /\.js$/,
+ test: /\.jsx?$/,
exclude: /node_modules/,
use: 'babel-loader',
},
]
module.exports = {
entry: { app: './src/app' },
+ resolve: { extensions: ['.js', '.jsx'] },
output: {
path: path.resolve(__dirname, 'public'),
filename: '[name]-bundle.js',
},
module: {
rules: [babel],
},
}

A small React example:

src/app.jsx

import React, { useState } from 'react'
import ReactDOM from 'react-dom'
function Hello() {
const [name, setName] = useState('world')
return (
<>
<h3>Hello {name}!</h3>
<input value={name} onChange={e) => setName(e.currentTarget.value)} />
</>
)
}
ReactDOM.render(<Hello />, document.querySelector('body'))

Bundle it all up

terminal

npx webpack -p
less public/app-bundle.js

Importing CSS

A popular trick with Webpack is to import .css files from your .js files. By default, this will bundle it inside the .js bundle, but we can use MiniCssExtractPlugin to extract the CSS into it's own file.

terminal

npm i -D css-loader mini-css-extract-plugin

webpack.config.js

const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const css = {
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader'],
}
module.exports = {
entry: { app: './src/app' },
resolve: { extensions: ['.js', '.jsx'] },
output: {
path: path.resolve(\_\_dirname, 'public'),
filename: '[name]-bundle.js',
},
module: {
rules: [babel, css],
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name]-bundle.css',
}),
],
}

Vendor Bundles

we can split our main bundle into separate files, such that one file contains the our application code, and the other is the dependencies:

webpack.config.js

module.exports = {
// ....
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]()[\\/]/,
name: 'vendor',
chunks: 'all',
},
},
},
},
}

this should now generate a vendor-bundle.js file, as well as our, now smaller, app-bundle.js.

Generating an HTML file

Now that you have some CSS and JS files, wouldn't it be nice to generate an HTML file to tie them together? html-webpack-plugin does just that:

terminal

npm i -D html-webpack-plugin

webpack.config.js

const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
plugins: [
new HtmlWebpackPlugin({
title: 'hello world',
}),
new MiniCssExtractPlugin({
filename: '[name]-bundle.css',
}),
],
}

The plugin offers several options to tune how the html file is generated, as well as templating in various formats. I personally like use it with html-webpack-template which is basically just a big .ejs file you can configure.

Easy cache-busting

since we now have a generated html file dynamically creating <script> and <link> tags based on our build, it's also very easy to add hashes into the filename, so that when the build changes, a new html file is generated pointing to different files:

webpack.config.js

module.exports = {
output: {
path: path.resolve(__dirname, 'public'),
- filename: '[name]-bundle.js',
+ filename: '[name]-[contentHash:8].js',
},
plugins: [
new MiniCssExtractPlugin({
- filename: '[name]-bundle.css',
+ filename: '[name]-[contentHash:8].css',
}),
]
}

TypeScript

It's never too late or early to introduce type checking into your JavaScript project!

You can run TypeScript standalone to transform your code, but it turns out when compiling it's faster to let Babel just strip them, which is great since we were already using it.

terminal

npm i -D typescript @babel/preset-typescript

TypeScript has a generator for it's configuration, via npx typescript --init, which works great if you're using it to compile as well, but we're using Babel for that. Here's the configuration that has worked best for me:

tsconfig.json

{
"compilerOptions": {
"target": "ESNEXT",
"module": "none",
"allowJs": true,
"checkJs": true, // perhaps controversial, and definitely not required
"jsx": "preserve",
"rootDir": "./src",
"noEmit": true,
"strict": true, // also not required but I don't see the point in using TS without it
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"exclude": ["public", "dist"]
}

Make sure to add the TypeScript preset to the Babel config:

babel.config.js

module.exports = {
presets: [
'@babel/preset-env',
'@babel/preset-react',
'@babel/preset-typescript',
],
}

We also need to add .ts and .tsx extensions to our Webpack config:

webpack.config.js

const babel = {
test: /\.[tj]sx?$/,
exclude: /node_modules/,
use: 'babel-loader',
}
module.exports = {
resolve: { extensions: ['.ts', '.tsx', '.js', '.jsx'] },
}

Now that we have type checking configured, we can install types for each package via npm i -D @types/... or we can use npx typesync to install them for us. Even better yet, we can automatically install them with a package.json hook:

terminal

npm i -D typesync

package.json

{
"scripts": {
"postinstall": "typesync"
}
}

We can even sprinkle some jsdoc comments in our webpack config and get typescript hints about the configuration without having to compile it:

webpack.config.js

/** @typedef {import('webpack').Configuration} WebpackConfig */
/** @type WebpackConfig */
module.exports = {

Linting

Linters are tools to help catch errors besides type mismatches, as well as enforce stylistic/formatting preferences in your code.

eslint includes tons of rules, as well as plugins to add more rules, as well as presets of pre-configured groups of rules.

terminal

npx eslint --init

This is a great way to get started, it will run an interactive "wizard" that asks a few questions, installs dependencies, and creates a config file for you:

But instead of the wizard, let's set up eslint manually.

terminal

npm i -D eslint

.eslintrc.js

const extensions = ['.js', '.jsx']
module.exports = {
env: {
node: ['current'],
browser: true,
},
extends: [
'eslint:recommended',
'plugin:import/recommended',
'plugin:jsx-a11y/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
],
plugins: ['import', 'jsx-a11y', 'react', 'react-hooks'],
settings: {
'import/extensions': extensions,
'import/resolver': { node: { extensions } },
},
rules: {
// all your custom rules and overrides here
},
}

If you're not using TypeScript, you should use the Babel parser and add it to the eslint config:

terminal

npm i -D @babel/eslint-parser

.eslintrc.js

module.exports = {
parser: '@babel/eslint-parser',
env: {

otherwise you'll want to add the TypeScript parser and plugin as well as some TS-specific rules:

terminal

npm i -D @typescript-eslint/{parser,eslint-plugin}

And add them to the configuration:

.eslintrc.js

const extensions = ['.ts', '.tsx', '.js', '.jsx']
module.exports = {
parser: '@typescript-eslint/parser',
env: {
node: ['current'],
browser: true,
},
extends: [
'eslint:recommended',
'plugin:import/recommended',
'plugin:jsx-a11y/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
],
plugins: ['import', 'jsx-a11y', 'react', 'react-hooks', '@typescript-eslint'],
parserOptions: {
project: './tsconfig.json',
},
settings: {
'import/parsers': { '@typescript-eslint/parser': extensions },
'import/extensions': extensions,
'import/resolver': { node: { extensions } },
},
}

Linting CSS

You can also bring the power of linting to your CSS using stylelint!

terminal

npm i -D stylelint{,-config-standard}

stylelint.config.js

module.exports = {
extends: 'stylelint-config-standard',
}

Transforming CSS

Just like Babel does transformations over JavaScript files, PostCSS does for CSS files. Alone, also in the same way as Babel, PostCSS doesn't actually do anything, it only provides a way to parse and transform files via plugins. There are tons of individual specialized plugins for PostCSS, you can basically build your own preprocessor that does the same things as other popular tools like Sass, Less, and Stylus, with or without other features you do or don't want.

We first need the main PostCSS package and then a loader for Webpack for it to process them. After that you can add whatever you like. I suggest postcss-import to help with CSS @imports and postcss-preset-env for adding autoprefixer as well as several other "future" features, and cssnano for minifying. There are still so many more on npm worth checking out.

terminal

npm i -D postcss{,-loader,-import,-preset-env} cssnano

webpack.config.js

const css = {
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
{ loader: 'css-loader', options: { importLoaders: 1 } },
'postcss-loader',
],
}

And a configuration file for PostCSS:

postcss.config.js

module.exports = ({ env }) => ({
plugins: {
'postcss-import': {},
'postcss-preset-env': { stage: 0 },
cssnano: env === 'production' ? { preset: 'default' } : false,
},
})

The postcss-loader docs have a lot more info on the cool things you can do with PostCSS and Webpack.

If you're specifically interested in the low-noise Stylus-like syntax, sugarss provides a great alternative.

Also note, that postcss-preset-env, as well as babel-preset-env, both transform your code based on your browserslist definition.

Task running

We have several tools set up to do different tasks, and we can save the different commands for working with them in the package.json for easy re-use.

package.json

"scripts": {
"postinstall": "typesync",
"watch:client": "webpack -d --watch",
"build:client": "NODE_ENV=production webpack -p",
"typecheck": "tsc -p .",
"lint:js": "eslint --ext=.ts,.tsx,.js,.jsx --ignore-path=.gitignore .",
"lint:css": "stylelint 'src/**/*.css'"
}

Now that we've grouped tasks into specialized scripts we can easily execute them together with npm-run-all:

terminal

npm i npm-run-all

npm-run-all provides two (or 4 if you count shorthands) ways to run scripts defined in your package.json: sequentially (one after the other) with npm-run-all -s (or run-s), or in parallel with npm-run-all -p (or run-p). It also provides a way to "glob" tasks with similar names, for example we can run all scripts starting with "lint:"

package.json

"scripts": {
"postinstall": "typesync",
"start": "run-p watch:client",
"lint": "run-p -c 'lint:*'",
"test": "run-p -c lint typecheck",
"watch:client": "webpack -d --watch",
"build:client": "NODE_ENV=production webpack -p",
"typecheck": "tsc -p .",
"lint:js": "eslint --ext=.ts,.tsx,.js,.jsx --ignore-path=.gitignore .",
"lint:css": "stylelint 'src/**/*.css'"
}

  • the quotes around lint:* are making sure that * is passed literally to the command rather than being expanded by the shell as a file list
  • The -c switch passed to run-p makes sure that the process continues even if one task fails.

We can also force Webpack to restart if any of the configuration is changed using nodemon:

terminal

npm i nodemon

package.json

"scripts": {
"watch:client": "nodemon -w '*.config.js' -x 'webpack -d --watch'"
}

Comments