Separating CSS

Even though we have a nice build set up now, where did all the CSS go? As per our configuration, it has been inlined to JavaScript! Even though this can be convenient during development, it doesn't sound ideal. The current solution doesn't allow us to cache CSS. In some cases we might suffer from a Flash of Unstyled Content (FOUC).

It just so happens that Webpack provides a means to generate a separate CSS bundle. We can achieve this using the ExtractTextPlugin. It comes with overhead during the compilation phase, and it won't work with Hot Module Replacement (HMR) by design. Given we are using it only for production, that won't be a problem.

T> This same technique can be used with other assets, like templates, too.

W> It can be potentially dangerous to use inline styles in production as it represents an attack vector! Favor ExtractTextPlugin and similar solutions in production usage.

Setting Up extract-text-webpack-plugin

It will take some configuration to make it work. Install the plugin:

npm i extract-text-webpack-plugin --save-dev

The plugin operates in two parts. There's a loader, ExtractTextPlugin.extract, that marks the assets to be extracted. The plugin itself will then use that information to write the file. In a function form the idea looks like this:

libs/parts.js

const webpack = require('webpack');
const CleanWebpackPlugin = require('clean-webpack-plugin');
leanpub-start-insert
const ExtractTextPlugin = require('extract-text-webpack-plugin');
leanpub-end-insert

...

leanpub-start-insert
exports.extractCSS = function(paths) {
  return {
    module: {
      loaders: [
        // Extract CSS during build
        {
          test: /\.css$/,
          loader: ExtractTextPlugin.extract('style', 'css'),
          include: paths
        }
      ]
    },
    plugins: [
      // Output extracted CSS to a file
      new ExtractTextPlugin('[name].[chunkhash].css')
    ]
  };
}
leanpub-end-insert

Connect the function with our configuration:

webpack.config.js

...

// Detect how npm is run and branch based on that
switch(process.env.npm_lifecycle_event) {
  case 'build':
    config = merge(
      ...
      parts.minify(),
leanpub-start-insert
      parts.extractCSS(PATHS.app)
leanpub-end-insert
leanpub-start-delete
      parts.setupCSS(PATHS.app)
leanpub-end-delete
    );
    break;
  default:
    config = merge(
      ...
    );
}

module.exports = validate(config);

Using this setup, we can still benefit from the HMR during development. For a production build, we generate a separate CSS, though. html-webpack-plugin will pick it up automatically and inject it into our index.html.

W> Definitions, such as loaders: [ExtractTextPlugin.extract('style', 'css')], won't work and will cause the build to error instead! So when using ExtractTextPlugin, use the loader form instead.

W> If you want to pass more loaders to the ExtractTextPlugin, you should use ! syntax. Example: ExtractTextPlugin.extract('style', 'css!postcss').

After running npm run build, you should see output similar to the following:

[webpack-validator] Config is valid.
clean-webpack-plugin: .../webpack-demo/build has been removed.
Hash: 27832e316f572a80ce4f
Version: webpack 1.13.0
Time: 3084ms
                               Asset       Size  Chunks             Chunk Names
         app.c3162186fdfffbe6bbed.js  277 bytes    0, 2  [emitted]  app
      vendor.21dc91b20c0b1e6e16a1.js    21.4 kB    1, 2  [emitted]  vendor
    manifest.149335ad7c6634496b11.js  821 bytes       2  [emitted]  manifest
        app.c3162186fdfffbe6bbed.css   80 bytes    0, 2  [emitted]  app
     app.c3162186fdfffbe6bbed.js.map    1.77 kB    0, 2  [emitted]  app
    app.c3162186fdfffbe6bbed.css.map  105 bytes    0, 2  [emitted]  app
  vendor.21dc91b20c0b1e6e16a1.js.map     274 kB    1, 2  [emitted]  vendor
manifest.149335ad7c6634496b11.js.map    8.78 kB       2  [emitted]  manifest
                          index.html  347 bytes          [emitted]
   [0] ./app/index.js 123 bytes {0} [built]
   [0] multi vendor 28 bytes {1} [built]
  [36] ./app/component.js 136 bytes {0} [built]
    + 35 hidden modules
Child html-webpack-plugin for "index.html":
        + 3 hidden modules
Child extract-text-webpack-plugin:
        + 2 hidden modules

T> If you are getting Module build failed: CssSyntaxError: error, make sure your common configuration doesn't have a CSS related section set up!

Now our styling has been pushed to a separate CSS file. As a result, our JavaScript bundles have become slightly smaller. We also avoid the FOUC problem. The browser doesn't have to wait for JavaScript to load to get styling information. Instead, it can process CSS separately avoiding flash of unstyled content (FOUC).

The current setup is fairly nice. There's one problem, though. If you try to modify either index.js or main.css, the hash of both files (app.js and app.css) will change! This is because they belong to the same entry chunk due to that require at app/index.js. The problem can be avoided by separating chunks further.

T> If you have a complex project with a lot of dependencies, it is likely a good idea to use the DedupePlugin. It will find possible duplicate files and deduplicate them. Use new webpack.optimize.DedupePlugin() in your plugins definition to enable it.

Separating Application Code and Styling

A logical way to solve our chunk issue is to push application code and styling to separate entry chunks. This breaks the dependency and fixes caching. To achieve this we need to decouple styling from its current chunk and define a custom chunk for it through configuration:

app/index.js

require('react');

leanpub-start-delete
require('./main.css');
leanpub-end-delete

...

In addition, we need to define a separate entry for styling:

webpack.config.js

...

const PATHS = {
  app: path.join(__dirname, 'app'),
leanpub-start-insert
  style: path.join(__dirname, 'app', 'main.css'),
leanpub-end-insert
  build: path.join(__dirname, 'build')
};

const common = {
  // Entry accepts a path or an object of entries.
  // We'll be using the latter form given it's
  // convenient with more complex configurations.
  entry: {
leanpub-start-insert
    style: PATHS.style,
leanpub-end-insert
    app: PATHS.app
  },
  ...
};

// Detect how npm is run and branch based on that
switch(process.env.npm_lifecycle_event) {
  case 'build':
    config = merge(
      ...
      parts.minify(),
leanpub-start-insert
      parts.extractCSS(PATHS.style)
leanpub-end-insert
leanpub-start-delete
      parts.extractCSS(PATHS.app)
leanpub-end-delete
    );
    break;
  default:
    config = merge(
      ...
leanpub-start-insert
      parts.setupCSS(PATHS.style),
leanpub-end-insert
leanpub-start-delete
      parts.setupCSS(PATHS.app),
leanpub-end-delete
      parts.devServer({
        // Customize host/port here if needed
        host: process.env.HOST,
        port: process.env.PORT
      })
    );
}

module.exports = validate(config);

If you build the project now through npm run build, you should see something like this:

[webpack-validator] Config is valid.
clean-webpack-plugin: .../webpack-demo/build has been removed.
Hash: e6e6cecdefbb54c610c1
Version: webpack 1.13.0
Time: 2788ms
                               Asset       Size  Chunks             Chunk Names
     app.a51c1a5cde933b81dc3e.js.map    1.57 kB    0, 3  [emitted]  app
         app.a51c1a5cde933b81dc3e.js  252 bytes    0, 3  [emitted]  app
      vendor.6947db44af2e47a304eb.js    21.4 kB    2, 3  [emitted]  vendor
    manifest.c2487fa71892504eb968.js  846 bytes       3  [emitted]  manifest
      style.e5eae09a78b3efd50e73.css   82 bytes    1, 3  [emitted]  style
       style.e5eae09a78b3efd50e73.js   93 bytes    1, 3  [emitted]  style
   style.e5eae09a78b3efd50e73.js.map  430 bytes    1, 3  [emitted]  style
  style.e5eae09a78b3efd50e73.css.map  107 bytes    1, 3  [emitted]  style
  vendor.6947db44af2e47a304eb.js.map     274 kB    2, 3  [emitted]  vendor
manifest.c2487fa71892504eb968.js.map    8.86 kB       3  [emitted]  manifest
                          index.html  402 bytes          [emitted]
   [0] ./app/index.js 100 bytes {0} [built]
   [0] multi vendor 28 bytes {2} [built]
  [32] ./app/component.js 136 bytes {0} [built]
    + 35 hidden modules
Child html-webpack-plugin for "index.html":
        + 3 hidden modules
Child extract-text-webpack-plugin:
        + 2 hidden modules

After this step we have managed to separate styling from JavaScript. Changes made to it shouldn't affect JavaScript chunk hashes or vice versa. The approach comes with a small glitch, though.

If you look closely, you can see a file named style.e5eae09a78b3efd50e73.js in the output. Yours might be different. It is a file generated by Webpack and it looks like this:

webpackJsonp([1,3],[function(n,c){}]);

Technically it's redundant. It would be safe to exclude the file through a check at HtmlWebpackPlugin template. But this solution is good enough for the project. Ideally Webpack shouldn't generate these files at all.

T> In the future we might be able to avoid this problem by using [contenthash] placeholder. It's generated based on file content (i.e., CSS in this case). Unfortunately it doesn't work as expected when the file is included in a chunk as in our original setup. This issue has been reported as Webpack issue #672.

Eliminating Unused CSS

Frameworks like Bootstrap tend to come with a lot of CSS. Often you use only a small part of it. Normally you just bundle even the unused CSS. It is possible, however, to eliminate the portions you aren't using. A tool known as purifycss can achieve this by analyzing our files. It also works with single page applications.

Using purifycss can lead to great savings. In their example they purify and minify Bootstrap (140 kB) in an application using ~40% of its selectors to mere ~35 kB. That's a big difference.

Webpack plugin known as purifycss-webpack-plugin allows us to achieve results like this. It is preferable to use the ExtractTextPlugin with it. Install it first:

npm i purifycss-webpack-plugin --save-dev

To make our demo more realistic, let's install a little CSS framework known as Pure.css as well and refer to it from our project so that we can see purifycss in action:

npm i purecss --save-dev

We also need to refer to it from our configuration:

webpack.config.js

...

const PATHS = {
  app: path.join(__dirname, 'app'),
leanpub-start-insert
  style: [
    path.join(__dirname, 'node_modules', 'purecss'),
    path.join(__dirname, 'app', 'main.css')
  ],
leanpub-end-insert
leanpub-start-delete
  style: path.join(__dirname, 'app', 'main.css'),
leanpub-end-delete
  build: path.join(__dirname, 'build')
};

...

Thanks to our path setup we don't need to tweak the remainder of the code. If you execute npm run build, you should see something like this:

[webpack-validator] Config is valid.
clean-webpack-plugin: .../webpack-demo/build has been removed.
Hash: adc32c7f82a388002a6e
Version: webpack 1.13.0
Time: 3656ms
                               Asset       Size  Chunks             Chunk Names
     app.a51c1a5cde933b81dc3e.js.map    1.57 kB    0, 3  [emitted]  app
         app.a51c1a5cde933b81dc3e.js  252 bytes    0, 3  [emitted]  app
      vendor.6947db44af2e47a304eb.js    21.4 kB    2, 3  [emitted]  vendor
    manifest.86e8bb3f3a596746a1a6.js  846 bytes       3  [emitted]  manifest
      style.e6624bc802ded7753823.css    16.7 kB    1, 3  [emitted]  style
       style.e6624bc802ded7753823.js  156 bytes    1, 3  [emitted]  style
   style.e6624bc802ded7753823.js.map  834 bytes    1, 3  [emitted]  style
  style.e6624bc802ded7753823.css.map  107 bytes    1, 3  [emitted]  style
  vendor.6947db44af2e47a304eb.js.map     274 kB    2, 3  [emitted]  vendor
manifest.86e8bb3f3a596746a1a6.js.map    8.86 kB       3  [emitted]  manifest
                          index.html  402 bytes          [emitted]
   [0] ./app/index.js 100 bytes {0} [built]
   [0] multi vendor 28 bytes {2} [built]
   [0] multi style 40 bytes {1} [built]
  [32] ./app/component.js 136 bytes {0} [built]
    + 37 hidden modules
Child html-webpack-plugin for "index.html":
        + 3 hidden modules
Child extract-text-webpack-plugin:
        + 2 hidden modules
Child extract-text-webpack-plugin:
        + 2 hidden modules

As you can see, style.e6624bc802ded7753823.css grew from 82 bytes to 16.7 kB as it should have. Also the hash changed because the file contents changed as well.

In order to give purifycss a chance to work and not eliminate whole PureCSS, we'll need to refer to it from our code. Add a className to our demo component like this:

app/component.js

module.exports = function () {
  var element = document.createElement('h1');

leanpub-start-insert
  element.className = 'pure-button';
leanpub-end-insert
  element.innerHTML = 'Hello world';

  return element;
};

If you run the application (npm start), our "Hello world" should look like a button.

We need one more bit, the configuration needed to make purifycss work. Expand parts like this:

libs/parts.js

const webpack = require('webpack');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
leanpub-start-insert
const PurifyCSSPlugin = require('purifycss-webpack-plugin');
leanpub-end-insert

...

leanpub-start-insert
exports.purifyCSS = function(paths) {
  return {
    plugins: [
      new PurifyCSSPlugin({
        basePath: process.cwd(),
        // `paths` is used to point PurifyCSS to files not
        // visible to Webpack. You can pass glob patterns
        // to it.
        paths: paths
      }),
    ]
  }
}
leanpub-end-insert

Next we need to connect this part to our configuration. It is important the plugin is used after the ExtractTextPlugin as otherwise it won't work!

webpack.config.js

...

// Detect how npm is run and branch based on that
switch(process.env.npm_lifecycle_event) {
  case 'build':
    config = merge(
      ...
      parts.minify(),
leanpub-start-insert
      parts.extractCSS(PATHS.style),
      parts.purifyCSS([PATHS.app])
leanpub-end-insert
leanpub-start-delete
      parts.extractCSS(PATHS.style)
leanpub-end-delete
    );
  default:
    ...

module.exports = validate(config);

Given Webpack is aware of PATHS.app through an entry, we could skip passing it to parts.purifyCSS. As explicit is often nicer than implicit, having it here doesn't hurt. We'll get the same result either way.

If you execute npm run build now, you should see something like this:

[webpack-validator] Config is valid.
clean-webpack-plugin: .../webpack-demo/build has been removed.
Hash: 7eaf3b6bae4156774447
Version: webpack 1.13.0
Time: 8703ms
                               Asset       Size  Chunks             Chunk Names
     app.a26b058bec8ce4d237ff.js.map    1.57 kB    0, 3  [emitted]  app
         app.a26b058bec8ce4d237ff.js  252 bytes    0, 3  [emitted]  app
      vendor.6947db44af2e47a304eb.js    21.4 kB    2, 3  [emitted]  vendor
    manifest.79745ac6c18fa88e9d61.js  846 bytes       3  [emitted]  manifest
      style.e6624bc802ded7753823.css    13.1 kB    1, 3  [emitted]  style
       style.e6624bc802ded7753823.js  156 bytes    1, 3  [emitted]  style
   style.e6624bc802ded7753823.js.map  834 bytes    1, 3  [emitted]  style
  style.e6624bc802ded7753823.css.map  107 bytes    1, 3  [emitted]  style
  vendor.6947db44af2e47a304eb.js.map     274 kB    2, 3  [emitted]  vendor
manifest.79745ac6c18fa88e9d61.js.map    8.86 kB       3  [emitted]  manifest
                          index.html  402 bytes          [emitted]
   [0] ./app/index.js 100 bytes {0} [built]
   [0] multi vendor 28 bytes {2} [built]
   [0] multi style 40 bytes {1} [built]
  [32] ./app/component.js 137 bytes {0} [built]
    + 37 hidden modules
Child html-webpack-plugin for "index.html":
        + 3 hidden modules
Child extract-text-webpack-plugin:
        + 2 hidden modules
Child extract-text-webpack-plugin:
        + 2 hidden modules

The size of our style went from 16.7 kB to 13.1 kB. It is not a huge difference in this case, but it is still something. It is interesting to note that processing time went from three seconds to eight so there is a cost involved! The technique is useful to know as it will likely come in handy with heavier CSS frameworks.

PurifyCSS supports additional options. You could for example enable additional logging by setting purifyOptions: {info: true} when instantiating the plugin.

Conclusion

Build-wise our little project is starting to get there. Now our CSS is separate and pure. In the next chapter I'll show you how to analyze Webpack build statistics so you understand better what the generated bundles actually contain.

results matching ""

    No results matching ""