Buy

Ever since we started requiring CSS from JS, we've had this annoying problem: when the page loads... just for a second... there's no CSS! Oof! Ugly!

This is because the CSS is packaged inside our JavaScript... so we need to wait for it to download and be executed.

This is ok for development, but we cannot have this on production. The fix involves one of the most important plugins in all of Webpack: the extract-text-webpack-plugin. It has a weird name.... but has one simple job: it outputs a real CSS file, instead of embedding CSS in JavaScript.

Let's get it rocking! Find your terminal and run

yarn add extract-text-webpack-plugin --dev

Current Setup: style-loader

Now open up webpack.config.js. Remember: CSS is processed via loaders. If a file ends in .css, it goes through the css-loader and then the style-loader:

125 lines webpack.config.js
... lines 1 - 7
const styleLoader = {
loader: 'style-loader',
options: {
sourceMap: true
}
};
const cssLoader = {
loader: 'css-loader',
options: {
sourceMap: true
}
};
... lines 20 - 32
module.exports = {
... lines 34 - 43
module: {
rules: [
... lines 46 - 55
{
test: /\.css$/,
use: [
styleLoader,
cssLoader,
]
},
... lines 63 - 93
]
},
... lines 96 - 123
};

For Sass, it's basically the same: the sass-loader, resolve-url-loader, then the same css-loader and style-loader:

125 lines webpack.config.js
... lines 1 - 7
const styleLoader = {
loader: 'style-loader',
options: {
sourceMap: true
}
};
const cssLoader = {
loader: 'css-loader',
options: {
sourceMap: true
}
};
const sassLoader = {
loader: 'sass-loader',
options: {
sourceMap: true
}
};
const resolveUrlLoader = {
loader: 'resolve-url-loader',
options: {
sourceMap: true
}
};
module.exports = {
... lines 34 - 43
module: {
rules: [
... lines 46 - 62
{
test: /\.scss$/,
use: [
styleLoader,
cssLoader,
resolveUrlLoader,
sassLoader,
]
},
... lines 72 - 93
]
},
... lines 96 - 123
};

The style-loader is the key: it embeds the CSS so that it's added to the page as a style tag. Basically, we need to replace the style-loader with something that, instead, outputs a real CSS file. That's exactly what the extract-text-webpack-plugin does!

Bring in the package with const ExtractTextPlugin = require('extract-text-webpack-plugin'):

133 lines webpack.config.js
... lines 1 - 3
const ExtractTextPlugin = require('extract-text-webpack-plugin');
... lines 5 - 133

The Loaders: ExtractTextPlugin.extract()

Now, under the loaders for .css files, remove the two loaders and, instead, add ExtractTextPlugin.extract() and pass that some options:

133 lines webpack.config.js
... lines 1 - 3
const ExtractTextPlugin = require('extract-text-webpack-plugin');
... lines 5 - 33
module.exports = {
... lines 35 - 44
module: {
rules: [
... lines 47 - 56
{
test: /\.css$/,
use: ExtractTextPlugin.extract({
... lines 60 - 64
}),
},
... lines 67 - 99
]
},
... lines 102 - 131
};

First, use set to cssLoader. And then, fallback set to styleLoader:

133 lines webpack.config.js
... lines 1 - 3
const ExtractTextPlugin = require('extract-text-webpack-plugin');
... lines 5 - 33
module.exports = {
... lines 35 - 44
module: {
rules: [
... lines 47 - 56
{
test: /\.css$/,
use: ExtractTextPlugin.extract({
use: [
cssLoader
],
... line 63
fallback: styleLoader
}),
},
... lines 67 - 99
]
},
... lines 102 - 131
};

Ignore the fallback key for a moment. Basically, the extract() function is a fancy way to prepend the loaders in use with a special extract-text-webpack-plugin loader. Thanks to this, CSS files will be processed through css-loader and then through this new extract text loader.

The styleLoader, which is set on fallback, is not used at all anymore:

133 lines webpack.config.js
... lines 1 - 3
const ExtractTextPlugin = require('extract-text-webpack-plugin');
... lines 5 - 33
module.exports = {
... lines 35 - 44
module: {
rules: [
... lines 47 - 56
{
test: /\.css$/,
use: ExtractTextPlugin.extract({
use: [
cssLoader
],
// use this, if CSS isn't extracted
fallback: styleLoader
}),
},
... lines 67 - 99
]
},
... lines 102 - 131
};

Well, actually, this is one of the least important, but most confusing parts about the plugin. Later, when we talk about code splitting, I'll explain what the fallback option really does. But for now, the styleLoader is no longer used. So, no more style tags!

Repeat this for Sass. I'll copy the three loaders and then say ExtractTextPlugin.extract(), passing that use set to those 3 loaders and fallback set again to styleLoader:

133 lines webpack.config.js
... lines 1 - 3
const ExtractTextPlugin = require('extract-text-webpack-plugin');
... lines 5 - 33
module.exports = {
... lines 35 - 44
module: {
rules: [
... lines 47 - 66
{
test: /\.scss$/,
use: ExtractTextPlugin.extract({
use: [
cssLoader,
resolveUrlLoader,
sassLoader,
],
fallback: styleLoader
}),
},
... lines 78 - 99
]
},
... lines 102 - 131
};

Adding the Plugin

The last step is to activate all of this down in the plugins section. Add new ExtractTextPlugin() and pass it a special name: [name].css:

133 lines webpack.config.js
... lines 1 - 33
module.exports = {
... lines 35 - 101
plugins: [
... lines 103 - 123
new ExtractTextPlugin('[name].css'),
],
... lines 127 - 131
};

Let's see what this does! Find your webpack tab and restart the dev server:

./node_modules/.bin/webpack-dev-server --hot

When it finishes, scroll up to the output. In addition to login.js, there is a login.css! And a layout.css and a rep_log.css. Our CSS is no longer packaged inside JavaScript: each entry now has its own CSS file!

Tip

If a JavaScript entry file does not require any CSS, no .css file will be output by Webpack.

This will fix our CSS flashing problem! But... with a downside: we now need to manually include link tags along with our script tags. In base.html.twig, add a link tag for build/layout.css:

106 lines app/Resources/views/base.html.twig
... lines 1 - 2
<head>
... lines 4 - 10
{% block stylesheets %}
<link rel="stylesheet" href="{{ asset('build/layout.css') }}">
{% endblock %}
... lines 14 - 15
</head>
... lines 17 - 106

Copy that. We need to do this again on our two pages: app/Resources/FOSUserBundle/views/Security/login.html.twig. Override the block stylesheets, call parent() and add the link tag to login.css:

72 lines app/Resources/FOSUserBundle/views/Security/login.html.twig
... lines 1 - 10
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('build/login.css') }}">
{% endblock %}
... lines 16 - 72

Do all of this again in index.html.twig. This time we need to point to rep_log.css:

68 lines app/Resources/views/lift/index.html.twig
... lines 1 - 62
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('build/rep_log.css') }}">
{% endblock %}

I think we're ready! Refresh the page. Yes! No CSS delay! View the source and click on rep_log.css. Yep! It's a beautiful, traditional CSS file.

We Killed HMR!

But... I have some bad news. We just killed hot module replacement! If you make a change and move over to your browser... it says "Nothing hot updated" and "App is up to date".

Yep, extract-text-webpack-plugin and HMR are incompatible. Boooo! There is a plugin to make this all work - css-hot-loader, but it's pretty young and I haven't tested it yet. If you like HMR, try it out!

But, how is it possible that two super important features like extract text and HMR don't work together?! Well, the official answer is that extract-text-webpack-plugin should only be used for your production build - not during development. We'll talk about production builds next.

But I don't like that! If we don't use extract-text-webpack-plugin while developing, then my site will look great... even if I completely forget to add my link tag! I might only discover a page is ugly after deploying it to production. That's why I always enable the plugin. But, if you like HMR and you can't get it to work with that other plugin, disabling it during development is a valid option.

For us, I'm going to stop using the webpack-dev-server. At the top of webpack.config.js, set useDevServer to false:

133 lines webpack.config.js
... lines 1 - 5
const useDevServer = false;
... lines 7 - 133

And then, in app/config/config.yml, comment out the base_url stuff:

37 lines app/config/config_dev.yml
... lines 1 - 3
framework:
... lines 5 - 8
# assets:
# base_url: 'http://localhost:8080'
... lines 11 - 37

Yep, we'll use the tried and true webpack --watch:

./node_modules/.bin/webpack --watch

Leave a comment!

  • 2017-09-11 weaverryan

    Hey Thomas Talbot!

    Yes! This is the one I still want to try! Even a few months ago, it was VERY young and messy. But it looks better now. I would love to include it in Webpack Encore :).

    Cheers!

  • 2017-09-10 Thomas Talbot

    Hi,
    Thanks for the course. :)

    There is a plugin for HMR with extract-text-webpack-plugin : https://www.npmjs.com/packa....
    Apparently, it works nicely. :)