CSS
There is no single "standard" way of dealing with CSS in webpack. But there is no confusion about this fact either. Webpack knows nothing about CSS and there is no ECMAScript rule that describes how a CSS file should/could be referenced from JavaScript. Yet, there are plenty of tools to handle CSS processing for the sake of feature support, browser support, maintainability, etc. We'll cover one example from every main area. That should be enough to extend the setup for any particular situation.
The setup contains 4 different ways to use CSS within a webpack project: plain CSS, CSS processors, CSS modules and CSS-in-JS. Chances are only one or two options are needed for the particular web app. Pick a preferred option and extend as needed.
Plain CSS #
Let's start with the simplest option — support for plain CSS files. For this option we are going to need the following packages:
Dependencies #
npm i css-loader style-loader mini-css-extract-plugin @types/mini-css-extract-plugin -D --save-exact
css-loader- provides support for theimportfunctionality for CSS files from JavaScript filesstyle-loader- provides support for injecting CSS sources into the DOM (head tag)mini-css-extract-plugin- provides support for extracting CSS sources into separate files@types/mini-css-extract-plugin- mini-css-extract-plugin type definitions
Webpack configuration #
First, we need to extend parts file to make sure the rest of the webpack config files can reuse it.
webpack/parts.ts #
import path from 'path'
import webpack, { Entry, Output, Node, Resolve, Plugin, RuleSetRule, Options } from 'webpack'
import HtmlWebpackPlugin from 'html-webpack-plugin';
import HtmlWebpackHarddiskPlugin from 'html-webpack-harddisk-plugin';
import { CleanWebpackPlugin } from 'clean-webpack-plugin'
export const distFolder = () => path.resolve(__dirname, '../dist')
export const getParts = () => ({
context: path.join(__dirname, '../src'),
entry: {
main: './index',
utils: './utils/index'
} as Entry,
output: {
path: path.resolve(distFolder(), 'js'),
filename: '[name].bundle.js',
publicPath: '/js/'
} as Output,
node: {
fs: 'empty'
} as Node,
resolve: {
extensions: ['.ts', '.js', '.json']
} as Resolve,
rules: [{
// Include ts/js files.
test: /\.(ts)|(js)$/,
exclude: [ // https://github.com/webpack/webpack/issues/6544
/node_modules/,
],
loader: 'babel-loader',
}] as RuleSetRule[] ,
plugins: [
new webpack.EnvironmentPlugin(['NODE_ENV']),
new HtmlWebpackPlugin({
chunksSortMode: 'dependency',
filename: '../index.html',
alwaysWriteToDisk: true
}),
new HtmlWebpackHarddiskPlugin(),
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: [
path.resolve(__dirname, '../dist/css/**/*'),
path.resolve(__dirname, '../dist/js/**/*')
],
verbose: true
})
] as Plugin[],
optimization: (cacheGroups?: { [key: string]: Options.CacheGroupsOptions }): Options.Optimization => ({
splitChunks: {
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/i,
name: 'vendors',
chunks: 'all'
},
...cacheGroups || {}
}
},
runtimeChunk: {
name: 'vendors'
}
})
})
We removed the module key in favor of a plain rules array. We also made CleanWebpackPlugin delete content from an explicitly specified folder list. Also, we changed optimization to be a function that takes an optional object of key-value pairs for potential cacheGroups extensions.
webpack/webpack.config.dev.ts #
For the webpack/webpack.config.dev.ts, just some minor customizations are needed.
// ...
module: {
rules: [
...parts.rules,
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
},
// ...
optimization: parts.optimization()
// ...
We changed the module key to be explicit and made sure all the rules from the parts file are provided. As for CSS itself, we have a new rule. It applies to all files with the css extension. Those files are handled by two loaders. In webpack, loaders are executed from right to left. css-loader implements standard JavaScript import and parses CSS file content, while style-loader moves the content to the DOM. It creates a style tag inside the head tag.
webpack/webpack.config.ts #
Change webpack/webpack.config.ts:
import webpack, { Configuration } from 'webpack'
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
import Environments from './environments.js'
import { getParts } from './parts'
const environments = Environments()
console.log(`Running webpack config for environment: ${environments.current}`)
const parts = getParts()
const config: Configuration = {
context: parts.context,
mode: 'production',
entry: parts.entry,
output: parts.output,
resolve: parts.resolve,
module: {
rules: [
...parts.rules,
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader']
}
]
},
node: parts.node,
plugins: [
...parts.plugins,
new webpack.SourceMapDevToolPlugin({
filename: '[name].js.map',
lineToLine: true
}),
new MiniCssExtractPlugin({
filename: '../css/[name].css',
chunkFilename: '../css/[name].css',
}),
],
optimization: {
...parts.optimization({
styles: {
name: 'styles',
test: /\.css$/,
chunks: 'initial',
enforce: true,
minSize: Infinity
}
}),
minimize: true,
removeAvailableModules: true,
}
}
export default config
Instead of the style-loader used for the development flow, we substitute it with the MiniCssExtractPlugin. This plugin is used to extract all CSS content into separate files, according to how CSS is imported from the JavaScript code. It also accounts for the code split files as well, so that CSS content will be loaded only when corresponding JavaScript chunk is loaded. Another change is around the optimization. We are adding another cache group for CSS files that represents a separate bundle.
name- represents a chunk name (gets translated into the file name for a chunk file)test- regular expression to filter out files included in a chunk (*.css file only)chunks- specifies what chunks are included into the bundle (initial- static chunks,all- static and dynamic chunks)enforce- enforce bundle ruleminSize- minimum size for the bundle to be considered a separate file
minSizeis set to Infinity. That means the min-size rule will never be satisfied and a separate bundle file will never be created. Thestylecache group config is here only for demo purposes. It shows an example of the technique to enforce chunk rules for CSS or any other content type. Removing theminSizevalue creates a separate chunk file specifically for CSS content loaded from files rendered during the first application load.
Example #
src/app/index.css #
body {
background-color: azure;
}
src/app/index.ts
import './index.css'
import { toCapital } from '@utils'
export type DynamicImport = Promise<{ default: () => string }>
///...
CSS Processors #
CSS processors let you generate CSS code based on an enhanced CSS or alternative CSS syntax. There are many different processors and the actual choice depends on the team’s level of experience and comfort, and the feature set provided by the processor itself. For the rest of the explanation, PostCSS is used, but the steps to configure a different processor should be very similar, as long as an appropriate webpack loader exists such as sass-loader for SASS and less-loader for LESS.
Dependencies #
npm i postcss-loader postcss-preset-env cssnano -D --save-exact
postcss-loader- webpack loader for PostCSS processorpostcss-preset-env- collection of PostCSS polyfills determined by the browser supportcssnano- CSS compressor/optimizer
Webpack configuration #
webpack/webpack.config.dev.ts #
Alter webpack/webpack.config.dev.ts content:
//...
rules: {
//...
{
test: /\.pcss$/,
use: ['style-loader', 'postcss-loader']
}
//..
}
//...
For the dev workflow, we introduced the rule for handling *.pcss files with two loaders. The first loader, postcss-loader, is similar to css-loader in that it knows how to interpret JavaScript module imports and content parsing for PostCSS files. From there, just as before, the content is passed to style-loader for further processing.
webpack/webpack.config.ts #
Alter webpack/webpack.config.ts content:
//...
rules: {
//...
{
test: /\.pcss$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']
}
//..
}
//...
For production, we use postcss-loader as well, but to place processed CSS content in separate files, the MiniCssExtractPlugin loader is needed. MiniCssExtractPlugin does not work directly with the PostCSS loader, so we have to use css-loader as an intermediary step.
PostCSS configuration #
postcss.config.js #
const postcssPresetEnv = require('postcss-preset-env');
const cssnano = require('cssnano');
const Environments = require('./webpack/environments');
const prodPlugins = [
cssnano({
preset: 'default'
})
];
module.exports = function(ctx) {
const environments = Environments(ctx.env);
return {
plugins: [
postcssPresetEnv({
features: {
'nesting-rules': true,
'color-mod-function': { unresolved: 'warn' }
}
})]
.concat(environments.isProduction ? prodPlugins : [])
};
}
PostCSS configuration mostly consists of plugin setup. In our case, we configured postcss-preset-env to support CSS rules for nesting and the color-mod function for color value definitions. We also included the CSS content optimizer cssnano, but only for production usage.
Example #
src/app/feature/index.pcss #
root {
--font-size: 30px;
}
li {
font-size: var(--font-size);
}
src/app/feature/index.ts
import './index.pcss'
export default (): string => 'feature'
CSS modules #
Another useful technique of managing CSS content is called CSS Modules. A CSS Module is nothing but a CSS file with all the class names applicable to a local scope only. It's like a namespace for that particular file. It is achieved via a library support and a webpack build step. During webpack compilation, CSS file content is proceeded in the way that all the class names are substituted with unique randomly generated values scoped to the current file. Later, those values could be used within the JavaScript code as a way to refer to a particular CSS style definition.
Dependencies #
So far, we have been using two flavors of CSS: plain and PostCSS‑enhanced. We can configure CSS Modules for CSS or PostCSS files only, or for both. For plain CSS, modules are supported by css-loader; for PostCSS, by postcss-loader. There is only one additional dependency to include.
npm i css-modules-typescript-loader -D --save-exact
css-modules-typescript-loader- webpack loader that creates TypeScript definition files based on CSS Module file content.
By default, the TypeScript compiler does not know what content of the CSS Module file is referenced by the code. This breaks the TypeScript compilation step. We could fall back to the require method instead of ECMAScript import syntax for CSS Modules, but that is more of a workaround. To make TypeScript compilation go smoothly, we introduce another loader that reads CSS Module file content and generates a TypeScript definition file with all the exported class names, hence making the compiler step pass.
Webpack configuration #
We already configured webpack to process *.css and *.pcss files. For CSS modules, different file extension is needed to prevent loaders collision. As a convention, we can use *.module.css extension for plain CSS modules and *.module.pcss for PostCSS modules.
webpack/webpack.config.dev.ts #
Final content change for webpack/webpack.config.dev.ts:
//...
module: {
rules: [
...parts.rules,
{
test: /\.css$/,
exclude: /\.module\.css$/,
use: ['style-loader', 'css-loader']
},
{
test: /\.pcss$/,
exclude: /\.module\.pcss$/,
use: ['style-loader', 'postcss-loader']
},
{
test: /\.module\.css$/,
use: [
'style-loader',
'css-modules-typescript-loader',
{
loader: 'css-loader',
options: {
modules: true,
}
}
]
},
{
test: /\.module\.pcss$/,
use: [
'style-loader',
'css-modules-typescript-loader',
{
loader: 'css-loader', options: { modules: true, importLoaders: 1 }
},
'postcss-loader'
]
}
]
}
//...
We introduced two additional rules, one for CSS modules - /\.module\.css$/, another for PostCSS modules - /\.module\.pcss$/. Webpack applies rules from top to bottom, so to prevent a rule from being applied to a different file type due to file extension collisions (ex. regex for CSS file includes searches for the regex for CSS module files), we have to extend existing rules. For CSS, we introduced exclude clause to make sure it is not applied to CSS modules (exclude: /\.module\.css$/). Same config change we applied to PostCSS rule. As for the CSS/PostCSS module rules, we use an appropriate loader that pipes content to the css-modules-typescript-loader. css-modules-typescript-loader uses css-loader underneath, so all the options provided will be transferred to it. We use module: true option for CSS modules and an additional importLoaders: 1 for PostCSS module.
importLoadersoption is a necessity for playing nicely with how loaders cooperate within webpack: https://github.com/webpack-contrib/css-loader/issues/228
webpack/webpack.config.ts #
Final content for webpack/webpack.config.ts:
//...
module: {
rules: [
...parts.rules,
{
test: /\.css$/,
exclude: /\.module\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader']
},
{
test: /\.pcss$/,
exclude: /\.module\.pcss$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']
},
{
test: /\.module\.css$/,
use: [
MiniCssExtractPlugin.loader,
'css-modules-typescript-loader',
{
loader: 'css-loader',
options: {
modules: true,
}
}
]
},
{
test: /\.module\.pcss$/,
use: [
MiniCssExtractPlugin.loader,
'css-modules-typescript-loader',
{
loader: 'css-loader', options: { modules: true, importLoaders: 1 }
},
'postcss-loader'
]
}
]
}
//...
For the production setup, the situation is very similar. But instead of style-loader we use the loader provided by MiniCssExtractPlugin to extract generated CSS content into separate files.
Example #
src/index.module.css #
item-regular {
background-color: azure;
}
src/index.module.css.d.ts (auto-generated) #
interface CssExports {
'item-regular': string;
}
declare var cssExports: CssExports;
export = cssExports;
src/index.ts #
//...
import* as cssStyles from './index.module.css'
import * as pcssStyles from './index.module.pcss'
import * as styles from './index.style'
const f = func()
f.next()
let count = 0
const list = createList(styles.list)
list.className = styles.list
//...
CSS-in-JS #
The CSS Modules technique was about generating random names for CSS classes and being able to refer to them from JS code. The CSS‑in‑JS pattern takes this approach to the next level. Instead of composing CSS by manipulating CSS code itself, it is composed using JavaScript code. From now on, CSS styles are defined in JavaScript as strings or objects with key‑value pairs simulating CSS rules. So how does the final CSS get created? It depends on when the library providing CSS‑in‑JS functionality executes the transformation to plain CSS code. If that happens at run time, we get a style tag in the head of the page attached dynamically as components get rendered. If the transformation happens during the webpack compilation, generated code becomes part of a separate CSS file loaded by the browser during application execution.
For our setup, a library that provides dynamic CSS code generation during application runtime is used. If a different approach is preferred, refer to the following list of libraries.
Dependencies #
npm i typestyle -D --save-exact
We will be using typestyle for the sake of TypeScript support and zero configuration.
Example #
src/index.style.ts #
import { style } from 'typestyle'
export const list = style({
borderBottom: 'black 1px solid'
})
Linting #
Just like we applied linting to TypeScript files, it can be applied to .css/.pcss files as well.
Dependencies #
npm i stylelint stylelint-config-standard -D --save-exact
stylelint- linting library for CSS codestylelint-config-standard- standard ruleset for stylelint
Configuration #
.stylelintrc #
{
"extends": "stylelint-config-standard",
"rules": {
"indentation": [4]
}
}
Add linting scripts to package.json:
"lint": "npm run lint:sources && npm run lint:styles",
"lint:sources": "eslint './src/**/*.ts' --max-warnings=0",
"lint:fix:sources": "npm run lint:sources -- --fix",
"lint:styles": "stylelint 'src/**/*.css' 'src/**/*.pcss'",
"lint:fix:styles": "npm run lint:styles -- --fix",