This is the first of hopefully a series of articles on new advanced usage available when moving all assets to Webpacker. In the first part, we will look into optimizing your CSS size.
We all want fast & reliable web pages. When doing a Page Speed Audit what often comes up as a recommendation is critical CSS. Critical CSS and especially above the fold critical CSS is the ability to inline (in the HTML) the minimal CSS required to render the top of your page (above the fold). I have for some time looked into an easy solution to achieve this in a Rails app but I was never really successful at it.
One of the great thing with Webpack (ie Webpacker in Rails) is all of the echo systems around it. While the JS side in Rails is largely documented they are also lots of tools available for CSS & images.
A few months ago I discovered a great video from GoRails for using PurgeCss in a Rails application.
Understanding PurgeCSS
The global concept of PurgeCSS is that on one side you feed PurgeCSS with all of your files where you would have some CSS class used (usually .html
, html.erb
, .js
). PurgesCSS create a list of all token that could be CSS selectors.
On the other side Webpacker create a CSS bundle using the mini-css-extract-plugin
. PurgeCSS extract a list of tokens
The result is the intersection of those two lists of tokens.
Multiple Pack with Multiple Rules
With Webpacker it is easy to have multiple packs. You just need to create a new some-pack.js
file in app/javascript/packs
directory.
The global idea of what we are going to do is:
- Define a second pack
critical.js
with only some CSS import in it. - Split our PurgesCss process in PostCss to apply much stricter rules for
critical.css
. - Inline our Critical CSS in the HTML as
Dev.to
is doing by the way. - Lazy load our main
application.css
.
Our critical.js entrypoint
Given an application.js that would look something like this:
// app/javascript/packs/application.js
require("@rails/ujs").start();
require("local-time").start();
require("turbolinks").start();
window.Rails = Rails;
// import CSS
import "stylesheets/application.scss";
// import Stimulus controllers
import "controllers/index";
// import vendor JS
import "bootstrap";
Our main entry point, import our main application.scss that usually look something like that:
// app/javascript/stylesheets/application.scss
// Fonts
@import "config/fonts";
// Graphical variables
@import "config/colors";
// Vendor
@import "~bootstrap/scss/functions";
@import "config/bootstrap_variables";
@import "~bootstrap/scss/bootstrap";
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
@import "~@fortawesome/fontawesome-free/scss/fontawesome";
@import "~@fortawesome/fontawesome-free/scss/brands";
// Components
@import "components/index";
// layouts
@import "layouts/sticky-footer";
We can create a very basic critical.js
, the only thing it will do is to import a new critical.scss stylesheet.
// app/javascript/packs/critical.js
import "stylesheets/critical.scss";
In our critical.scss
file we can start to be a little more selective as what we put inside to help PurgeCSS make a better job. (it does make a small difference)
// colors
@import "config/colors";
// vendor
@import "~bootstrap/scss/functions";
@import "config/bootstrap_variables";
@import "config/bootstrap_critical"; // pick only the Bootstrap module you need
// Components
@import "components/banner"; //just pick the components you need for the homepage
PostCSS / PurgeCSS configuration
Then this is the important part. We need to tell PurgeCSS to apply different rules per files. Luckily we have a context full of information in PostCSS.
So we can pass our information context to the environment:
module.exports = ctx => environment(ctx);
Add a context variable to our envrionment
const environment = ctx => ({
plugins: [
require("postcss-import"),
require("postcss-flexbugs-fixes"),
require("postcss-preset-env")({
autoprefixer: {
flexbox: "no-2009"
},
stage: 3
}),
purgeCss(ctx)
]
});
Call our PurgeCss plugin with this context
const purgeCss = ({ file }) => {
return require("@fullhuman/postcss-purgecss")({
content: htmlFilePatterns(file.basename),
defaultExtractor: content => content.match(/[A-Za-z0-9-_:/]+/g) || []
});
};
And now that we have the filename in PurgeCss, we can specify different rules for each file. For my critical CSS, I specify only pages related to the home page and the usual set of patterns for all other files.
const htmlFilePatterns = filename => {
switch (filename) {
case "critical.scss":
return [
"./app/views/pages/index.html.erb",
"./app/views/shared/_navbar.html.erb",
"./app/views/layouts/application.html.erb"
];
default:
return [
"./app/**/*.html.erb",
"./config/initializers/simple_form_bootstrap.rb",
"./app/helpers/**/*.rb",
"./app/javascript/**/*.js"
];
}
};
So in full, it looks like that
// postcss.config.js
const environment = ctx => ({
plugins: [
require("postcss-import"),
require("postcss-flexbugs-fixes"),
require("postcss-preset-env")({
autoprefixer: {
flexbox: "no-2009"
},
stage: 3
}),
purgeCss(ctx)
]
});
const purgeCss = ({ file }) => {
return require("@fullhuman/postcss-purgecss")({
content: htmlFilePatterns(file.basename),
defaultExtractor: content => content.match(/[A-Za-z0-9-_:/]+/g) || [],
});
};
const htmlFilePatterns = filename => {
switch (filename) {
case "critical.scss":
return [
"./app/views/pages/index.html.erb",
"./app/views/shared/_navbar.html.erb",
"./app/views/layouts/application.html.erb"
];
default:
return [
"./app/**/*.html.erb",
"./config/initializers/simple_form_bootstrap.rb",
"./app/helpers/**/*.rb",
"./app/javascript/**/*.js"
];
}
};
module.exports = ctx => environment(ctx);
Results
In a small test I did I had those results
- Initial bundle size of 32kb
- With purge CSS this dropped to 9kb
- My critical.css only 3kb!
BAMMMMM 🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉
Inline CSS from Webpacker
I did scratch my head a bit to inline my CSS file in the HTML. Thanks to Stackoverflow I could get some help here
<% if current_page?(root_path) %>
<!-- Inline the critical CSS -->
<style>
<%= File.read(File.join(Rails.root, 'public', Webpacker.manifest.lookup('critical.css'))).html_safe %>
</style>
<!-- Lazy load the rest with loadCSS -->
<link rel="preload" href="<%= Webpacker.manifest.lookup('application.css') %>" as="style" onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="<%= Webpacker.manifest.lookup('application.css') %>"></noscript>
<% else %>
<%= stylesheet_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
<% end %>
Et voila!!!!
Demo: https://sprockets-less-rails6.herokuapp.com
Source code: https://github.com/adrienpoly/sprockets-less-rails6
Top comments (8)
Hi Andrien,
Thanks for this.
How to whitelist specific folder in Purgecss config?
Say I have
Now I want to whitelist all SCSS/CSS files from below folder
Thanks!
Hi Adrien
Thanks for this. I've added the critical.js and critical.scss which then appear in my manifest.json. I've set extract_css: true in wepbacker.yml.
However i never get any files written to my public/packs directory so the inline read call can't open the extracted file name.
I assume i need to run "rake webpacker:compile" on every change to write to the packs directory? Did you ever find a way around this?
Thanks again
Hi Chris
I did run into issues in development when using overmind to start my Procfil.dev.
Starting my server with a basic rails s did solve this issue.
In production I never had any issue (I deploy to Heroku).
I never do a rake webpacker:compile here on my side
Thanks Adrien
I'm using foreman so perhaps thats something to do with it
currently my public/packs folder is empty
well in development it is normal to have public/packs empty.
Assets are lives served by webpack-dev-server
Hey nice article do you know by any chance how I could import .less files into the critical.scss
Hello
I don't really use less. but anyhow if you are able to get less files into webpacker (this could help github.com/rails/webpacker/issues/...). Then what I describred will work exactly the same. PostCSS gets the output of Webpacker so at this point it is a CSS file (not anymore Less, Sass, Scss etc)
Thank you, I try it out
Some comments may only be visible to logged-in visitors. Sign in to view all comments.