Merge pull request #12 from GoogleChromeLabs/css-inlining

CSS Inlining
This commit is contained in:
Jason Miller
2018-04-17 17:10:43 -04:00
committed by GitHub
6 changed files with 375 additions and 21 deletions

View File

@@ -0,0 +1,322 @@
const fs = require('fs');
const { promisify } = require('util');
const path = require('path');
const parse5 = require('parse5');
const nwmatcher = require('nwmatcher');
const css = require('css');
const prettyBytes = require('pretty-bytes');
const readFile = promisify(fs.readFile);
const treeAdapter = parse5.treeAdapters.htmlparser2;
const PLUGIN_NAME = 'critters-webpack-plugin';
const PARSE5_OPTS = {
treeAdapter
};
/** Critters: Webpack Plugin Edition!
* @class
* @param {Object} options
* @param {Boolean} [options.external=true] Fetch and inline critical styles from external stylesheets
* @param {Boolean} [options.async=false] Convert critical-inlined external stylesheets to load asynchronously (via link rel="preload" - see https://filamentgroup.com/lab/async-css.html)
* @param {Boolean} [options.preload=false] (requires `async` option) Append a new <link rel="stylesheet"> into <body> instead of swapping the preload's rel attribute
* @param {Boolean} [options.compress=true] Compress resulting critical CSS
*/
module.exports = class CrittersWebpackPlugin {
constructor (options) {
this.options = options || {};
this.urlFilter = this.options.filter;
if (this.urlFilter instanceof RegExp) {
this.urlFilter = this.urlFilter.test.bind(this.urlFilter);
}
}
/** Invoked by Webpack during plugin initialization */
apply (compiler) {
const outputPath = compiler.options.output.path;
// hook into the compiler to get a Compilation instance...
compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => {
// ... which is how we get an "after" hook into html-webpack-plugin's HTML generation.
compilation.hooks.htmlWebpackPluginAfterHtmlProcessing.tapAsync(PLUGIN_NAME, (htmlPluginData, callback) => {
// Parse the generated HTML in a DOM we can mutate
const document = parse5.parse(htmlPluginData.html, PARSE5_OPTS);
makeDomInteractive(document);
let externalStylesProcessed = Promise.resolve();
// `external:false` skips processing of external sheets
if (this.options.external !== false) {
const externalSheets = document.querySelectorAll('link[rel="stylesheet"]');
externalStylesProcessed = Promise.all(externalSheets.map(
link => this.embedLinkedStylesheet(link, compilation, outputPath)
));
}
externalStylesProcessed
.then(() => {
// go through all the style tags in the document and reduce them to only critical CSS
const styles = document.querySelectorAll('style');
return Promise.all(styles.map(style => this.processStyle(style, document)));
})
.then(() => {
// serialize the document back to HTML and we're done
const html = parse5.serialize(document, PARSE5_OPTS);
callback(null, { html });
})
.catch(callback);
});
});
}
/** Inline the target stylesheet referred to by a <link rel="stylesheet"> (assuming it passes `options.filter`) */
embedLinkedStylesheet (link, compilation, outputPath) {
const href = link.getAttribute('href');
const document = link.ownerDocument;
// skip filtered resources, or network resources if no filter is provided
if (this.urlFilter ? this.urlFilter(href) : href.match(/^(https?:)?\/\//)) return Promise.resolve();
// path on disk
const filename = path.resolve(outputPath, href.replace(/^\//, ''));
// try to find a matching asset by filename in webpack's output (not yet written to disk)
const asset = compilation.assets[path.relative(outputPath, filename).replace(/^\.\//, '')];
// wait for a disk read if we had to go to disk
const promise = asset ? Promise.resolve(asset.source()) : readFile(filename, 'utf8');
return promise.then(sheet => {
// the reduced critical CSS gets injected into a new <style> tag
const style = document.createElement('style');
style.appendChild(document.createTextNode(sheet));
link.parentNode.insertBefore(style, link.nextSibling);
// drop a reference to the original URL onto the tag (used for reporting to console later)
style.$$name = href;
// the `async` option changes any critical'd <link rel="stylesheet"> tags to async-loaded equivalents
if (this.options.async) {
link.setAttribute('rel', 'preload');
link.setAttribute('as', 'style');
if (this.options.preload) {
const bodyLink = document.createElement('link');
bodyLink.setAttribute('rel', 'stylesheet');
bodyLink.setAttribute('href', href);
document.body.appendChild(bodyLink);
} else {
link.setAttribute('onload', "this.rel='stylesheet'");
}
}
});
}
/** Parse the stylesheet within a <style> element, then reduce it to contain only rules used by the document. */
processStyle (style) {
const done = Promise.resolve();
const document = style.ownerDocument;
// basically `.textContent`
let sheet = style.childNodes.length > 0 && style.childNodes.map(node => node.nodeValue).join('\n');
// store a reference to the previous serialized stylesheet for reporting stats
const before = sheet;
// Skip empty stylesheets
if (!sheet) return done;
const ast = css.parse(sheet);
// Walk all CSS rules, transforming unused rules to comments (which get removed)
visit(ast, rule => {
if (rule.type === 'rule') {
// Filter the selector list down to only those matche
rule.selectors = rule.selectors.filter(sel => {
// Strip pseudo-elements and pseudo-classes, since we only care that their associated elements exist.
// This means any selector for a pseudo-element or having a pseudo-class will be inlined if the rest of the selector matches.
sel = sel.replace(/::?(?:[a-z-]+)([.[#~&^:*]|\s|\n|$)/gi, '$1');
return document.querySelector(sel, document) != null;
});
// If there are no matched selectors, remove the rule:
if (rule.selectors.length === 0) {
return false;
}
}
// If there are no remaining rules, remove the whole rule.
return !rule.rules || rule.rules.length !== 0;
});
sheet = css.stringify(ast, { compress: this.options.compress !== false });
return done.then(() => {
// If all rules were removed, get rid of the style element entirely
if (sheet.trim().length === 0) {
sheet.parentNode.removeChild(sheet);
} else {
// replace the inline stylesheet with its critical'd counterpart
while (style.lastChild) {
style.removeChild(style.lastChild);
}
style.appendChild(document.createTextNode(sheet));
}
// output some stats
const name = style.$$name ? style.$$name.replace(/^\//, '') : 'inline CSS';
const percent = (before.length - sheet.length) / before.length * 100 | 0;
console.log('\u001b[32mCritters: inlined ' + prettyBytes(sheet.length) + ' (' + percent + '% of original ' + prettyBytes(before.length) + ') of ' + name + '.\u001b[39m');
});
}
};
/** Recursively walk all rules in a stylesheet.
* The iterator can explicitly return `false` to remove the current node.
*/
function visit (node, fn) {
if (node.stylesheet) return visit(node.stylesheet, fn);
node.rules = node.rules.filter(rule => {
if (rule.rules) {
visit(rule, fn);
}
return fn(rule) !== false;
});
}
/** Enhance an htmlparser2-style DOM with basic manipulation methods. */
function makeDomInteractive (document) {
defineProperties(document, DocumentExtensions);
// Find the first <html> element within the document
// document.documentElement = document.childNodes.filter( child => String(child.tagName).toLowerCase()==='html' )[0];
// Extend Element.prototype with DOM manipulation methods.
// Note: document.$$scratchElement is also used by createTextNode()
const scratch = document.$$scratchElement = document.createElement('div');
const elementProto = Object.getPrototypeOf(scratch);
defineProperties(elementProto, ElementExtensions);
elementProto.ownerDocument = document;
// nwmatcher is a selector engine that happens to work with Parse5's htmlparser2 DOM (they form the base of jsdom).
// It is exposed to the document so that it can be used within Element.prototype methods.
document.$match = nwmatcher({ document });
document.$match.configure({
CACHING: false,
USE_QSAPI: false,
USE_HTML5: false
});
}
/** Essentially Object.defineProperties() except any functions are assigned as values rather than descriptors. */
function defineProperties (obj, properties) {
for (const i in properties) {
const value = properties[i];
Object.defineProperty(obj, i, typeof value === 'function' ? { value } : value);
}
}
/** {document,Element}.getElementsByTagName() is the only traversal method required by nwmatcher.
* Note: if perf issues arise, 2 faster but more verbose implementations are benchmarked here:
* https://esbench.com/bench/5ac3b647f2949800a0f619e1
*/
function getElementsByTagName (tagName) {
// Only return Element/Document nodes
if ((this.nodeType !== 1 && this.nodeType !== 9) || this.type === 'directive') return [];
return Array.prototype.concat.apply(
// Add current element if it matches tag
(tagName === '*' || (this.tagName && (this.tagName === tagName || this.nodeName === tagName.toUpperCase()))) ? [this] : [],
// Check children recursively
this.children.map(child => getElementsByTagName.call(child, tagName))
);
}
/** Methods and descriptors to mix into Element.prototype */
const ElementExtensions = {
nodeName: {
get () {
return this.tagName.toUpperCase();
}
},
insertBefore (child, referenceNode) {
if (!referenceNode) return this.appendChild(child);
treeAdapter.insertBefore(this, child, referenceNode);
return child;
},
appendChild (child) {
treeAdapter.appendChild(this, child);
return child;
},
removeChild (child) {
treeAdapter.detachNode(child);
},
setAttribute (name, value) {
if (this.attribs == null) this.attribs = {};
if (value == null) value = '';
this.attribs[name] = value;
},
removeAttribute (name) {
if (this.attribs != null) {
delete this.attribs[name];
}
},
getAttribute (name) {
return this.attribs != null && this.attribs[name];
},
hasAttribute (name) {
return this.attribs != null && this.attribs[name] != null;
},
getAttributeNode (name) {
const value = this.getAttribute(name);
if (value != null) return { specified: true, value };
},
getElementsByTagName
};
/** Methods and descriptors to mix into the global document instance */
const DocumentExtensions = {
// document is just an Element in htmlparser2, giving it a nodeType of ELEMENT_NODE.
// nwmatcher requires that it at least report a correct nodeType of DOCUMENT_NODE.
nodeType: {
get () {
return 9;
}
},
nodeName: {
get () {
return '#document';
}
},
documentElement: {
get () {
// Find the first <html> element within the document
return this.childNodes.filter(child => String(child.tagName).toLowerCase() === 'html')[0];
}
},
body: {
get () {
return this.querySelector('body');
}
},
createElement (name) {
return treeAdapter.createElement(name, null, []);
},
createTextNode (text) {
// there is no dedicated createTextNode equivalent in htmlparser2's DOM, so
// we have to insert Text and then remove and return the resulting Text node.
const scratch = this.$$scratchElement;
treeAdapter.insertText(scratch, text);
const node = scratch.lastChild;
treeAdapter.detachNode(node);
return node;
},
querySelector (sel) {
return this.$match.first(sel, this.documentElement);
},
querySelectorAll (sel) {
return this.$match.select(sel, this.documentElement);
},
getElementsByTagName,
// nwmatcher uses inexistence of `document.addEventListener` to detect IE:
// https://github.com/dperini/nwmatcher/blob/3edb471e12ce7f7d46dc1606c7f659ff45675a29/src/nwmatcher.js#L353
addEventListener: Object
};

View File

@@ -9,13 +9,20 @@
"lint": "eslint src" "lint": "eslint src"
}, },
"eslintConfig": { "eslintConfig": {
"extends": "eslint-config-developit", "extends": [
"standard",
"standard-jsx"
],
"rules": { "rules": {
"indent": [ "indent": [
2, 2,
2 2
], ],
"react/prefer-stateless-function": 0 "semi": [
2,
"always"
],
"prefer-const": 1
} }
}, },
"eslintIgnore": [ "eslintIgnore": [
@@ -34,12 +41,20 @@
"babel-plugin-transform-react-remove-prop-types": "^0.4.13", "babel-plugin-transform-react-remove-prop-types": "^0.4.13",
"babel-preset-env": "^1.6.1", "babel-preset-env": "^1.6.1",
"babel-register": "^6.26.0", "babel-register": "^6.26.0",
"chalk": "^2.3.2",
"clean-webpack-plugin": "^0.1.19", "clean-webpack-plugin": "^0.1.19",
"copy-webpack-plugin": "^4.5.1", "copy-webpack-plugin": "^4.5.1",
"css": "^2.2.1",
"css-loader": "^0.28.11", "css-loader": "^0.28.11",
"ejs-loader": "^0.3.1", "ejs-loader": "^0.3.1",
"eslint": "^4.18.2", "eslint": "^4.18.2",
"eslint-config-developit": "^1.1.1", "eslint-config-standard": "^11.0.0",
"eslint-config-standard-jsx": "^5.0.0",
"eslint-plugin-import": "^2.10.0",
"eslint-plugin-node": "^6.0.1",
"eslint-plugin-promise": "^3.7.0",
"eslint-plugin-react": "^7.7.0",
"eslint-plugin-standard": "^3.0.1",
"extract-text-webpack-plugin": "^4.0.0-beta.0", "extract-text-webpack-plugin": "^4.0.0-beta.0",
"fork-ts-checker-notifier-webpack-plugin": "^0.4.0", "fork-ts-checker-notifier-webpack-plugin": "^0.4.0",
"fork-ts-checker-webpack-plugin": "^0.4.1", "fork-ts-checker-webpack-plugin": "^0.4.1",
@@ -48,6 +63,8 @@
"jsdom": "^11.6.2", "jsdom": "^11.6.2",
"mini-css-extract-plugin": "^0.3.0", "mini-css-extract-plugin": "^0.3.0",
"node-sass": "^4.7.2", "node-sass": "^4.7.2",
"nwmatcher": "^1.4.4",
"parse5": "^4.0.0",
"preact-render-to-string": "^3.7.0", "preact-render-to-string": "^3.7.0",
"preload-webpack-plugin": "github:GoogleChromeLabs/preload-webpack-plugin", "preload-webpack-plugin": "github:GoogleChromeLabs/preload-webpack-plugin",
"progress-bar-webpack-plugin": "^1.11.0", "progress-bar-webpack-plugin": "^1.11.0",

View File

@@ -1,16 +1,23 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Squoosh</title> <title>Squoosh</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#673ab8"> <meta name="theme-color" content="#673ab8">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
</head> </head>
<body> <body>
<div id="app" prerender></div> <div id="app" prerender></div>
<!-- <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" /> --> <script>
</body> (function(style){
style.rel='stylesheet'
style.href='https://fonts.googleapis.com/icon?family=Material+Icons'
document.head.appendChild(style)
})(document.createElement('link'));
</script>
<!-- <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" /> -->
</body>
</html> </html>

View File

@@ -3,17 +3,17 @@ import { options } from 'preact';
const classNameDescriptor = { const classNameDescriptor = {
enumerable: false, enumerable: false,
configurable: true, configurable: true,
get() { get () {
return this.class; return this.class;
}, },
set(value) { set (value) {
this.class = value; this.class = value;
} }
}; };
let old = options.vnode; const old = options.vnode;
options.vnode = vnode => { options.vnode = vnode => {
let a = vnode.attributes; const a = vnode.attributes;
if (a != null) { if (a != null) {
if ('className' in a) { if ('className' in a) {
a.class = a.className; a.class = a.className;

View File

@@ -1,7 +1,7 @@
// @import './material-icons.scss'; // @import './material-icons.scss';
// @import 'material-components-web/material-components-web'; // @import 'material-components-web/material-components-web';
@import './reset.scss'; @import './reset.scss';
@import url('https://fonts.googleapis.com/icon?family=Material+Icons'); // @import url('https://fonts.googleapis.com/icon?family=Material+Icons');
html, body { html, body {
height: 100%; height: 100%;

View File

@@ -9,6 +9,7 @@ const PreloadWebpackPlugin = require('preload-webpack-plugin');
const ReplacePlugin = require('webpack-plugin-replace'); const ReplacePlugin = require('webpack-plugin-replace');
const CopyPlugin = require('copy-webpack-plugin'); const CopyPlugin = require('copy-webpack-plugin');
const WorkboxPlugin = require('workbox-webpack-plugin'); const WorkboxPlugin = require('workbox-webpack-plugin');
const CrittersPlugin = require('./config/critters-webpack-plugin');
const WatchTimestampsPlugin = require('./config/watch-timestamps-plugin'); const WatchTimestampsPlugin = require('./config/watch-timestamps-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
@@ -175,6 +176,13 @@ module.exports = function(_, env) {
// Inject <link rel="preload"> for resources // Inject <link rel="preload"> for resources
isProd && new PreloadWebpackPlugin(), isProd && new PreloadWebpackPlugin(),
isProd && new CrittersPlugin({
// convert critical'd <link rel="stylesheet"> to <link rel="preload" as="style">:
async: true,
// copy original <link rel="stylesheet"> to the end of <body>:
preload: true
}),
// Inline constants during build, so they can be folded by UglifyJS. // Inline constants during build, so they can be folded by UglifyJS.
new webpack.DefinePlugin({ new webpack.DefinePlugin({
// We set node.process=false later in this config. // We set node.process=false later in this config.