Eliminating Redundant base.js Chunk in Webpack 4 with a Custom Mini‑CSS‑Extract Plugin
This article explains why Webpack 4 generates an unnecessary base.js file when extracting common CSS with mini‑css‑extract‑plugin, analyses the underlying bootstrap dependency issue, and presents a custom plugin that adjusts the compilation graph to move the empty JS module, suppress the extra chunk, and correctly emit the shared base.css file.
Introduction
The author, a front‑end engineer at ByteDance, describes a performance problem caused by the default css‑in‑js approach in Webpack 4, where style files are bundled into JavaScript and loaded asynchronously, leading to delayed style loading and missed cache benefits.
Style Performance Optimization
Two scenarios are identified: (1) delayed CSS loading can cause flash‑of‑unstyled‑content for skeleton screens, and (2) CSS cannot be cached independently when bundled with JS that changes frequently. The solution is to inline critical CSS and extract stable CSS into separate files.
Internal Splitting Convention
The project adopts a directory layout where src/common/styles holds global CSS (rarely changed) and src/pages/*/index.scss holds page‑specific CSS (frequently changed). The build should output common/base.[contenthash].css for global styles and embed page‑specific styles in the page entry JS bundle.
Split Configuration
Using mini-css-extract-plugin to extract global CSS and splitChunks to create a shared base chunk, the following configurations are applied:
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
module: {
rules: [{
test: /\/common\/styles\/.*(css|scss|sass|less)$/i,
use: [MiniCssExtractPlugin.loader, 'css-loader']
}]
},
plugins: [new MiniCssExtractPlugin({
chunkFilename: 'common/[name].[contenthash].css'
})],
optimization: {
splitChunks: {
cacheGroups: {
base: {
name: "base",
test: /\/common\/styles\/.*(css|scss|sass|less)$/,
chunks: "all",
enforce: true
}
}
}
}
};This setup creates base.css and, unexpectedly, a base.js file containing an empty module generated by the loader.
Problem Analysis
Webpack 4 forces every loaded asset to produce a JS module. When splitChunks creates an isolated CSS chunk, the associated empty JS module cannot be attached to any entry chunk, so Webpack emits a separate base.js . The bootstrap code then lists base as a required chunk, causing an extra network request and, if the file is removed, a white‑screen because the entry cannot resolve its dependencies.
Webpack 5 fixes this by skipping JS generation for pure CSS chunks, but many projects remain on Webpack 4.
Solution: Adjusting the Dependency Graph
The strategy is to locate the “problem chunk” (the CSS‑only chunk with an empty JS module), move the empty module into its source entry chunk, remove the chunk from the entry’s deferredModules list, and finally prevent the generation of the redundant .js file while preserving the .css asset.
1. Identify Problem Chunks
const CSS_MODULE_TYPE = 'css/mini-extract';
compilation.hooks.beforeChunkAssets.tap(pluginName, () => {
const splitChunks = compilation.chunks.filter(c => c.chunkReason && c.chunkReason.includes('split chunk'));
splitChunks.forEach(splitChunk => {
const cssModules = [];
const nonCssModules = [];
Array.from(splitChunk.modulesIterable).forEach(mod => {
if (mod.type !== CSS_MODULE_TYPE) nonCssModules.push(mod);
else cssModules.push(mod);
});
const uselessModules = nonCssModules.filter(m => m._source && m._source._value === '// extracted by mini-css-extract-plugin');
if (uselessModules.length && uselessModules.length === nonCssModules.length) {
// move empty JS modules to the original entry chunk
uselessModules.forEach(useless => {
useless.reasons.forEach(reason => {
reason.module.chunksIterable.forEach(originChunk => {
splitChunk.moveModule(useless, originChunk);
});
});
});
// later we will detach the CSS chunk from the entry group
this.problemChunkInfos[splitChunk.id] = { originChunks: [] };
}
});
});2. Detach the Chunk from Entry Dependencies
compilation.hooks.beforeChunkAssets.tap(pluginName, () => {
Object.values(this.problemChunkInfos).forEach(info => {
// each problem chunk knows its origin chunks from the previous step
// we remove the chunk from its groups
const chunk = compilation.chunks.find(c => c.id === info.id);
chunk.groupsIterable.forEach(group => group.removeChunk(chunk));
// remember origin chunks for later CSS injection
info.originChunks = Array.from(chunk.groupsIterable).map(g => g.chunks[0]);
});
});3. Suppress JS Asset Generation and Re‑inject CSS Asset
compilation.hooks.additionalChunkAssets.tap(pluginName, chunks => {
chunks.forEach(chunk => {
if (this.problemChunkInfos[chunk.id]) {
chunk.files.forEach(file => {
if (path.extname(file) === '.js') {
// remove the empty JS file
chunk.files = chunk.files.filter(f => f !== file);
delete compilation.assets[file];
} else if (path.extname(file) === '.css') {
// attach the generated CSS to the original entry chunk so html‑webpack‑plugin can emit a
this.problemChunkInfos[chunk.id].originChunks.forEach(origin => {
origin.files.push(file);
});
}
});
}
});
});After these steps, the build produces only base.css (placed in common/ ) and the entry bundle no longer references a non‑existent base.js . The HTML generated by html-webpack-plugin now contains a proper <link rel="stylesheet" href=".../common/base.[hash].css"> tag, and the page renders correctly.
Final Result
The directory structure matches the intended design: a shared base.css for global styles, page‑specific JS bundles that include their own CSS, and no superfluous JS chunks. The custom plugin restores correct dependency handling in Webpack 4 without upgrading to Webpack 5.
TikTok Frontend Technology Team
We are the TikTok Frontend Technology Team, serving TikTok and multiple ByteDance product lines, focused on building frontend infrastructure and exploring community technologies.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.