Not so secret tips and examples on how to write good webpack configs.
When no config is provided webpack assumes the following default config:
const {resolve} = require("path");
module.exports = {
entry: resolve(process.cwd(), "src", "index.js"),
output: {
path: resolve(process.cwd(), "dist"),
filename: "main.js",
},
};
You will still see an error like this in your console:
This means that webpack used production defaults because you haven't specified a mode
.
The mode
has been introduced with webpack 4 and can be set to development
, production
and none
. It allows webpack to choose the best default configuration for the given environment.
It's strongly recommended to set the mode
and let webpack do the rest. 😎
You can set the mode
via the command line...
webpack --mode development
...or inside your webpack config:
module.exports = {
mode: "development",
};
- Step 1: Rename your
webpack.config.js
towebpack.config.ts
. - Step 2: Install
@types/webpack
andts-node
. - Step 3: Create a variable using the
webpack.Configuration
type:
import webpack from "webpack";
const config: webpack.Configuration = {
// ...
};
export default config;
You also need to set the esModuleInterop
flag in your tsconfig.json
:
{
"compilerOptions": {
"esModuleInterop": true
}
}
This gives you nice intellisense in your config:
Note:
- This is a webpack-cli feature. It won't work if you're using webpack's node API.
ts-node
needs to be available in yournode_modules
- webpack-cli uses interpret which maintains a dictionary of file extensions and associated module loaders.
- This means that you can also use Babel to precompile your
webpack.config.js
. Rename it towebpack.config.babel.js
and make sure that there is a Babel config. - Devon Zuegel has written a comprehensive guide on using TypeScript in your webpack config.
The function should return the webpack config:
module.exports = () => {
return {
mode: "development",
};
};
Where is that useful?
Now you can pass arguments to your config via webpack's CLI:
webpack --env.mode=production --env.debug
module.exports = (env = {}) => {
const {
mode = "development",
debug = false,
} = env;
return {
mode,
output: {
pathinfo: debug === true,
},
};
};
This feature is called Environment Options.
You can also return an array of configurations. This switches webpack into multi-compiler mode:
module.exports = [
{
name: "web",
target: "web",
output: {
path: resolve(__dirname, "dist", "web"),
},
},
{
name: "node",
target: "node",
output: {
path: resolve(__dirname, "dist", "node"),
},
},
];
This is useful for isomorphic apps that need to create two bundles, one for the browser and one for Node.
It's also useful for creating localized bundles:
const I18nPlugin = require("i18n-webpack-plugin");
module.exports = Object
.entries({
en: require("./en.json"),
de: require("./de.json"),
})
.map(([language, translations]) => ({
name: language,
output: {
path: resolve(__dirname, "dist", language),
},
plugins: [new I18nPlugin(translations)],
}));
In multi-compiler mode webpack builds each config concurrently in the same process while re-using the file system cache.
Note:
Multi-compiler mode does not execute each build in parallel. Often you will see significant longer build times.
Use parallel-webpack from Trivago, one of our Platinum sponsors, if you want to execute each config in parallel.
Just put an async
before your config function:
module.exports = async () => {
// load some config values from database
return {
// ...
};
};
With the language example from the previous tip:
module.exports = async () => {
const languages = await loadLanguagesFromProvider();
return Object
.entries(languages)
.map(([language, translations]) => ({
/* ...*/
}));
};
Question: Is the following config valid?
module.exports = {
entry: async () => loadEntriesFromDb(),
};
Yes or no?
Answer: Yes! The entry
option can also be an async function.
This can be useful if you're building a static site generator that pulls all entries from a database.
It's not the most important feature but still good to know 😉.
Question: Is the following config valid?
module.exports = {
output: {
path: fs.createWriteStream("/some/path"),
},
};
Yes or no?
Answer: No 😝
Running webpack --watch
tells webpack to watch all source files that contribute to the bundle. But what if you made changes to the webpack config itself? webpack does not notice any changes to files that are required by webpack itself.
With nodemon you can restart webpack everytime you make changes to the webpack config:
nodemon --watch webpack.config.js --exec "webpack --watch"
Make sure to only watch the webpack.config.js
otherwise you might loose webpack's incremental build feature.
When writing your own webpack config it can happen easily that a file path is not correct. Although webpack will report some errors like a missing entry file, it will not complain about all errors.
For instance: when using resolve.alias
it can happen easily that the path to be aliased is not correct anymore.
In this case it's good to use Node's require.resolve
which will throw an error as soon as the file can not be found.
This is the easiest and safest way to replace one module with another using resolve.alias
:
module.exports = {
resolve: {
alias: {
// Each require.resolve() will throw an error if the module cannot be found
[require.resolve("./src/index.js")]: require.resolve("./src/replaced.js"),
},
},
};
Also useful if you want to execute a loader just on a certain file:
module.exports = {
module: {
rules: [
{
include: [require.resolve("./src/replaced.js")],
use: "raw-loader",
},
],
},
};
Now everytime you move the file but don't update the path in your webpack config, require.resolve
will complain about it.
Adding .filter(Boolean)
at the end of an array removes all falsy values from an array:
module.exports = {
module: {
rules: [
isDev && {
test: /\.css$/,
loader: "style-loader",
},
].filter(Boolean),
},
plugins: [
analyze && new BundleAnalyzerPlugin(),
].filter(Boolean),
};
Since the webpack config is just JavaScript, people tend to treat it as regular code. They create abstractions as soon as they spot repetition.
While there are legitimate use-cases for this—such as sharing configs across projects—it's often better to keep everything in one file.
For instance, with the previous tip it's not necessary to split your webpack config into env-specific configs:
plugins: [
isBrowser &&
new CopyPlugin(),
isBrowser && new WriteAssetsJsonPlugin(),
isBrowser &&
new MiniCssExtractPlugin(),
isAnalysis &&
isBrowser &&
new BundleAnalyzerPlugin(),
isProd &&
isBrowser &&
new webpack.optimize.UglifyJsPlugin(),
isProd &&
isBrowser &&
isAnalysis === false &&
new CompressionPlugin(),
].filter(Boolean)
When it comes to configuring loaders there are three important things to remember:
- The order of rules is important
- Loaders are executed from bottom to top and from right to left
- Every rule that matches gets applied
Question: Given this rules
config, what loaders will be executed on a .js
module and in which order?
rules: [{
use: ["a-loader", "b-loader"],
}, {
use: ["c-loader"],
}],
- b a
- a b c
- c b a
- none
Answer: 3. All matching rules will be applied from bottom to top and from right to left. The test
property is not required.
Since this behavior is confusing for a lot of people, we recommend to use the oneOf
condition. This tells webpack to bail out on the first matching rule:
rules: [{
oneOf: [{
use: ["a-loader", "b-loader"],
}, {
use: ["c-loader"],
}],
}],
- b a
- a
- c b a
- none
Answer: 1. Only the first rule is applied. Since the first rule contains two loaders, both loaders are executed from right to left.
There are a lot of ways—probably too many 😔—to configure rules and when they should be applied.
The good news is: You only need to remember 2. In 99.99995437% of all cases it's sufficient to use:
test
for file extensionsinclude
for directories or absolute paths
rules: [{
include: [path.resolve(__dirname, "src")],
test: [/\.ts$/, /\.tsx$/],
use: ["ts-loader"],
}, {
include: [path.resolve(__dirname, "src")],
test: [/\.css$/],
use: ["css-loader"],
}],
Technically, test
and include
(and resource
) work all the same way. But most people use include
for absolute paths and test
for regular expressions.
Be careful: Only one condition in the array needs to match.
rules: [{
// Matches any module that is inside "src" OR that ends on ".ts"
include: [
path.resolve(__dirname, "src"),
/\.ts$/,
],
use: ["ts-loader"],
}],
Imagine you want to use the react-svg-loader. The loader turns an SVG file into a React component:
<svg height="100" width="100">
<circle cx="50" cy="50" r="40" fill="red" />
</svg>
...becomes...
export default () => (
<svg height="100" width="100">
<circle cx="50" cy="50" r="40" fill="red" />
</svg>
);
This allows you to import the SVG right into your React app without creating a custom wrapper:
import CircleComponent from "./circle.svg";
const component = <CircleComponent />;
But the problem arises when you're also using the same SVG in a CSS file like this:
body {
background: url("./circle.svg");
}
Should webpack transform the module with the react-svg-loader or with the file-loader?
One possible solution: Use the react-svg-loader chain only if there is an ?component
query.
import CircleComponent from "./circle.svg?component";
const component = <CircleComponent />;
You can achieve this using the resourceQuery
condition:
rules: [{
oneOf: [{
test: /\.svg$/,
resourceQuery: "?component",
use: ["react-svg-loader"],
},
{
test: /\.svg$/,
use: ["file-loader"],
}]
}],
Same situation, but different solution: Use the react-svg-loader chain only if the importing module is a .js
module:
rules: [{
oneOf: [{
test: /\.svg$/,
// issuer matches on the importing module
issuer: /\.jsx?$/,
use: ["react-svg-loader"],
},
{
test: /\.svg$/,
use: ["file-loader"],
}]
}],
Now you can just write:
import CircleComponent from "./circle.svg";
const component = <CircleComponent />;
This can be quite challenging in bigger webpack projects 😔.
First you need to add
stats: {
maxModules: Infinity, // show all modules
}
to your webpack config so that webpack doesn't hide modules in the output.
Then run webpack --profile --progress
to get an idea which modules took long to build.
This output is from an example project where I've delayed each step artificially using setTimeout
. The example project consists of 3 modules:
a.js
is the entrya.js
depends onb.js
b.js
depends onc.js
The output is full of information:
- building all modules took 5 seconds
- writing them to disk (emitting) took 3 seconds
- resolving each module (factory) took 1 second
- module
a.js
- took 1 second to resolve (factory)
- took 6ms to build
- was finished after 1 second
- module
b.js
- was discovered after 1 second (
a.js
had to be built) - took 1 second to resolve
- took 2 seconds to build
- was finished after 4 seconds
- was discovered after 1 second (
- module
c.js
- was discovered after 4 seconds (
b.js
had to be built) - took 1 second to resolve
- took 1ms to build
- was finished after 5 seconds
- was discovered after 4 seconds (
You can also try out the excellent speed-measure-webpack-plugin.
For the given example it would print out the following stats:
You will get the best insights by profiling the webpack process itself:
- Run
node --inspect-brk ./node_modules/.bin/webpack
- Open Chrome and go to chrome://inspect
- Click on
inspect
- Record a profile
You will get a flame graph that looks like this:
In our example it would show a flame graph like this:
which would be really bad in a real-world application because it shows that the process was idling most of the time 😉.
Once you know what's slow, you can apply specific improvements.
But there are also general tips that will keep your webpack process fast.
By running webpack --watch
or using the webpack-dev-server
, webpack switches to incremental builds. When a file changes only the necessary changes are rebuild.
A common error is to use a different tool for watching files (like gulp) to restart the webpack process. In that case each build is started from scratch.
Use include
to run loaders only on the src
folder:
rules: [{
include: resolve(__dirname, "src"),
test: /\.js$/,
use: ["babel-loader"],
}],
Anyone with a config like this deserves a slow build 😝:
rules: [{
test: /\.js$/,
use: ["babel-loader"],
}],
This will transform every module in node_modules
using Babel.
If a module is not compatible with your browser targets, use include
to only transpile files from that particular module:
rules: [{
include: [
resolve(__dirname, "src"),
resolve(__dirname, "node_modules", "super-modern", "src"),
],
test: /\.js$/,
use: ["babel-loader"],
}],
Ask yourself, is it really necessary...
- ...to run the linter or
- ...to do type-checking
...during a webpack build?
Better move these two steps to posttest
.
Secret protip for a fast webpack build: Do as less as possible 😎
The cache-loader caches the result of the following loader chain.
Given this configuration:
rules: [{
include: [
resolve(__dirname, "src"),
],
test: /\.less$/,
use: [
"style-loader", // not cached
"cache-loader",
"css-loader", // cached
"less-loader",
],
}],
It would cache the result of the css-loader
(and thus also the result of the less-loader
) on disk.
When webpack gets executed again and the given module hasn't changed, the cache-loader restores the result from disk.
The cache-loader is not a silver bullet. Sometimes you won't see any improvement. This is because restoring the result from disk can sometimes be just as expensive as building it from scratch.
Some loaders—like the babel-loader—already use a persistent file cache.
The thread-loader moves the following loader chain to a separate thread.
Given this configuration:
rules: [{
include: [
resolve(__dirname, "src"),
],
test: /\.less$/,
use: [
"style-loader",
"thread-loader",
"css-loader", // on a separate thread
"less-loader", // on a separate thread
],
}],
It would run the css-loader and the less-loader in a worker pool using child processes.
Spawning child processes is rather expensive (~600ms). Only use the thread-loader for very expensive transformations that would otherwise block the event loop.
As a last resort you can tell webpack to not parse prebuilt libraries:
module.exports = {
module: {
noParse: [
require.resolve("react")
],
}
};
This might speed up the build but it also disables other optimizations such as tree-shaking.
- webpack-bundle-analyzer
maxSize
with HTTP2- Webpack's defaults are usually good enough for most people
- If you've already done your homework (reduce JS payload), you can optimize here
- The goal: create smaller chunks than can be cached individually
- Why? HTTP2 has no head-of-line blocking
- The problem: find a good algorithm that selects modules that change often together (you could group by folder structure)
- Also problem: GZIP works better with larger files
output.jsonpScriptType: "module"
- Loads async chunks as modules instead of oldschool script tags
- Be careful with dependencies: All code is interpreted in strict mode.
module.strictExportPresence
- true turns missing export warnings into errors
- Could be true for production builds in the future
output.crossOriginLoading: "anonymous"
- Uses the crossorigin attribute when loading chunks
- Useful when the bundle is served via a CDN with a different origin
- With no attribute, the global error handler will only report "Script error"
- https://stackoverflow.com/a/7778424
stats.errorDetails: true
adds more information to errors like "Module not found"stats.logging: "verbose"
stats.optimizationBailout
shows which optimization didn't work for which module- Example: Concatenation
output.pathinfo: true
- Enabled in development by default
- Disabled in production
- Useful to debug production build