Comments
- Loading...
This article is outdated. The tooling covered here (Webpack, Babel, etc.) has been largely superseded. See the updated version which covers Vite, Rolldown, tsgo, and oxc.
This guide has seen a few revisions, originally it only covered babel and webpack, I've since expanded it to include more tools
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.
Let's create a project, and install Webpack and for the sake of example use jquery as a dependency.
mkdir first-bundlecd first-bundlenpm init -ynpm i -D webpack{,-cli}npm i -S jquery
Create a simple file at src/index.js
const $ = require('jquery')$('body').prepend('<h1>hello world</h1>')
Now let's use Webpack to compile it, and check the output:
npx webpack -d # -d is a shorthand for a few different development flagsls distless dist/main.js
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:
npx webpack -p # -p is a shorthand for a few different production flagsless 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.
Now let's add Babel into the mix!
npm i -D @babel/{core,preset-env} babel-loader
A small config for Babel:
module.exports = {presets: ['@babel/preset-env'],}
And now we need to make a small Webpack configuration to tell Webpack how to use Babel:
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
const test = () => console.log('hello world')test()
And build it one more time and check the output
npx webpack -pless 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:
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:
module.exports = {entry: { main: './src/app.js' },module: {rules: [babel],},}
Changing the output path isn't much different:
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:
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:
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],},}
Install React and the Babel preset:
npm i -S react react-domnpm i -D @babel/preset-react
Add the new preset to the babel config:
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:
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:
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
npx webpack -pless public/app-bundle.js
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.
npm i -D css-loader mini-css-extract-plugin
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',}),],}
we can split our main bundle into separate files, such that one file contains the our application code, and the other is the dependencies:
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.
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:
npm i -D html-webpack-plugin
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.
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:
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',}),]}
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.
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:
{"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:
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:
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:
npm i -D typesync
{"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:
/** @typedef {import('webpack').Configuration} WebpackConfig *//** @type WebpackConfig */module.exports = {
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.
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.
npm i -D eslint
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:
npm i -D @babel/eslint-parser
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:
npm i -D @typescript-eslint/{parser,eslint-plugin}
And add them to the configuration:
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 } },},}
You can also bring the power of linting to your CSS using stylelint!
npm i -D stylelint{,-config-standard}
module.exports = {extends: 'stylelint-config-standard',}
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.
npm i -D postcss{,-loader,-import,-preset-env} cssnano
const css = {test: /\.css$/,use: [MiniCssExtractPlugin.loader,{ loader: 'css-loader', options: { importLoaders: 1 } },'postcss-loader',],}
And a configuration file for PostCSS:
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.
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.
"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:
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:"
"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'"}
lint:* are making sure that * is passed literally to the command rather than being expanded by the shell as a file list-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:
npm i nodemon
"scripts": {"watch:client": "nodemon -w '*.config.js' -x 'webpack -d --watch'"}