new-audit(unminified-css): identifies savings from unminified CSS (#4127)
This commit is contained in:
Родитель
10aeb28dc1
Коммит
622b9c25f0
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* @license Copyright 2017 Google Inc. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const ByteEfficiencyAudit = require('./byte-efficiency-audit');
|
||||
|
||||
const IGNORE_THRESHOLD_IN_PERCENT = 5;
|
||||
const IGNORE_THRESHOLD_IN_BYTES = 2048;
|
||||
|
||||
/**
|
||||
* @fileOverview
|
||||
*/
|
||||
class UnminifiedCSS extends ByteEfficiencyAudit {
|
||||
/**
|
||||
* @return {!AuditMeta}
|
||||
*/
|
||||
static get meta() {
|
||||
return {
|
||||
name: 'unminified-css',
|
||||
description: 'Minify CSS',
|
||||
informative: true,
|
||||
helpText: 'Minifying CSS files can reduce network payload sizes.' +
|
||||
'[Learn more](https://developers.google.com/speed/docs/insights/MinifyResources).',
|
||||
requiredArtifacts: ['Styles', 'devtoolsLogs'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the total length of the meaningful tokens (CSS excluding comments and whitespace).
|
||||
*
|
||||
* @param {string} content
|
||||
* @return {number}
|
||||
*/
|
||||
static computeTokenLength(content) {
|
||||
let totalTokenLength = 0;
|
||||
let isInComment = false;
|
||||
let isInLicenseComment = false;
|
||||
let isInString = false;
|
||||
let stringOpenChar = null;
|
||||
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const twoChars = content.substr(i, 2);
|
||||
const char = twoChars.charAt(0);
|
||||
|
||||
const isWhitespace = char === ' ' || char === '\n' || char === '\t';
|
||||
const isAStringOpenChar = char === `'` || char === '"';
|
||||
|
||||
if (isInComment) {
|
||||
if (isInLicenseComment) totalTokenLength++;
|
||||
|
||||
if (twoChars === '*/') {
|
||||
if (isInLicenseComment) totalTokenLength++;
|
||||
isInComment = false;
|
||||
i++;
|
||||
}
|
||||
} else if (isInString) {
|
||||
totalTokenLength++;
|
||||
if (char === '\\') {
|
||||
totalTokenLength++;
|
||||
i++;
|
||||
} else if (char === stringOpenChar) {
|
||||
isInString = false;
|
||||
}
|
||||
} else {
|
||||
if (twoChars === '/*') {
|
||||
isInComment = true;
|
||||
isInLicenseComment = content.charAt(i + 2) === '!';
|
||||
if (isInLicenseComment) totalTokenLength += 2;
|
||||
i++;
|
||||
} else if (isAStringOpenChar) {
|
||||
isInString = true;
|
||||
stringOpenChar = char;
|
||||
totalTokenLength++;
|
||||
} else if (!isWhitespace) {
|
||||
totalTokenLength++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the content contained unbalanced comments, it's either invalid or we had a parsing error.
|
||||
// Report the token length as the entire string so it will be ignored.
|
||||
if (isInComment || isInString) {
|
||||
return content.length;
|
||||
}
|
||||
|
||||
return totalTokenLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} stylesheet
|
||||
* @return {{minifiedLength: number, contentLength: number}}
|
||||
*/
|
||||
static computeWaste(stylesheet, networkRecord) {
|
||||
const content = stylesheet.content;
|
||||
const totalTokenLength = UnminifiedCSS.computeTokenLength(content);
|
||||
|
||||
const totalBytes = ByteEfficiencyAudit.estimateTransferSize(networkRecord, content.length,
|
||||
'stylesheet');
|
||||
const wastedRatio = 1 - totalTokenLength / content.length;
|
||||
const wastedBytes = Math.round(totalBytes * wastedRatio);
|
||||
|
||||
return {
|
||||
url: networkRecord.url,
|
||||
totalBytes,
|
||||
wastedBytes,
|
||||
wastedPercent: 100 * wastedRatio,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {!Artifacts} artifacts
|
||||
* @return {!Audit.HeadingsResult}
|
||||
*/
|
||||
static audit_(artifacts, networkRecords) {
|
||||
const results = [];
|
||||
for (const stylesheet of artifacts.Styles) {
|
||||
const networkRecord = networkRecords
|
||||
.find(record => record.url === stylesheet.header.sourceURL);
|
||||
if (!networkRecord || !stylesheet.content) continue;
|
||||
|
||||
const result = UnminifiedCSS.computeWaste(stylesheet, networkRecord);
|
||||
|
||||
// If the ratio is minimal, the file is likely already minified, so ignore it.
|
||||
// If the total number of bytes to be saved is quite small, it's also safe to ignore.
|
||||
if (result.wastedPercent < IGNORE_THRESHOLD_IN_PERCENT ||
|
||||
result.wastedBytes < IGNORE_THRESHOLD_IN_BYTES) continue;
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
return {
|
||||
results,
|
||||
headings: [
|
||||
{key: 'url', itemType: 'url', text: 'URL'},
|
||||
{key: 'totalKb', itemType: 'text', text: 'Original'},
|
||||
{key: 'potentialSavings', itemType: 'text', text: 'Potential Savings'},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = UnminifiedCSS;
|
|
@ -18,6 +18,7 @@ module.exports = {
|
|||
},
|
||||
],
|
||||
audits: [
|
||||
'byte-efficiency/unminified-css',
|
||||
'byte-efficiency/unused-css-rules',
|
||||
'byte-efficiency/unused-javascript',
|
||||
'dobetterweb/no-old-flexbox',
|
||||
|
@ -25,6 +26,7 @@ module.exports = {
|
|||
categories: {
|
||||
'performance': {
|
||||
audits: [
|
||||
{id: 'unminified-css', weight: 0, group: 'perf-hint'},
|
||||
{id: 'unused-css-rules', weight: 0, group: 'perf-hint'},
|
||||
{id: 'unused-javascript', weight: 0, group: 'perf-hint'},
|
||||
],
|
||||
|
|
|
@ -0,0 +1,205 @@
|
|||
/**
|
||||
* @license Copyright 2017 Google Inc. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const KB = 1024;
|
||||
const UnminifiedCssAudit = require('../../../audits/byte-efficiency/unminified-css');
|
||||
const assert = require('assert');
|
||||
|
||||
/* eslint-env mocha */
|
||||
|
||||
const _resourceType = {_name: 'stylesheet'};
|
||||
describe('Page uses optimized css', () => {
|
||||
describe('#computeTokenLength', () => {
|
||||
it('should compute length of meaningful content', () => {
|
||||
const full = `
|
||||
/*
|
||||
* a complicated comment
|
||||
* that is
|
||||
* several
|
||||
* lines
|
||||
*/
|
||||
.my-class {
|
||||
/* a simple comment */
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
`;
|
||||
|
||||
const minified = '.my-class{width:100px;height:100px;}';
|
||||
assert.equal(UnminifiedCssAudit.computeTokenLength(full), minified.length);
|
||||
});
|
||||
|
||||
it('should handle string edge cases', () => {
|
||||
const pairs = [
|
||||
['.my-class { content: "/*"; }', '.my-class{content:"/*";}'],
|
||||
['.my-class { content: \'/* */\'; }', '.my-class{content:\'/* */\';}'],
|
||||
['.my-class { content: "/*\\\\a"; }', '.my-class{content:"/*\\\\a";}'],
|
||||
['.my-class { content: "/*\\"a"; }', '.my-class{content:"/*\\"a";}'],
|
||||
['.my-class { content: "hello }', '.my-class { content: "hello }'],
|
||||
['.my-class { content: "hello" }', '.my-class{content:"hello"}'],
|
||||
];
|
||||
|
||||
for (const [full, minified] of pairs) {
|
||||
assert.equal(
|
||||
UnminifiedCssAudit.computeTokenLength(full),
|
||||
minified.length,
|
||||
`did not handle ${full} properly`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle comment edge cases', () => {
|
||||
const full = `
|
||||
/* here is a cool "string I found" */
|
||||
.my-class {
|
||||
content: "/*";
|
||||
}
|
||||
`;
|
||||
|
||||
const minified = '.my-class{content:"/*";}';
|
||||
assert.equal(UnminifiedCssAudit.computeTokenLength(full), minified.length);
|
||||
});
|
||||
|
||||
it('should handle license comments', () => {
|
||||
const full = `
|
||||
/*!
|
||||
* @LICENSE
|
||||
* Apache 2.0
|
||||
*/
|
||||
.my-class {
|
||||
width: 100px;
|
||||
}
|
||||
`;
|
||||
|
||||
const minified = `/*!
|
||||
* @LICENSE
|
||||
* Apache 2.0
|
||||
*/.my-class{width:100px;}`;
|
||||
assert.equal(UnminifiedCssAudit.computeTokenLength(full), minified.length);
|
||||
});
|
||||
|
||||
it('should handle unbalanced comments', () => {
|
||||
const full = `
|
||||
/*
|
||||
.my-class {
|
||||
width: 100px;
|
||||
}
|
||||
`;
|
||||
|
||||
assert.equal(UnminifiedCssAudit.computeTokenLength(full), full.length);
|
||||
});
|
||||
|
||||
it('should handle data URIs', () => {
|
||||
const uri = 'data:image/jpeg;base64,asdfadiosgjwiojasfaasd';
|
||||
const full = `
|
||||
.my-other-class {
|
||||
background: data("${uri}");
|
||||
height: 100px;
|
||||
}
|
||||
`;
|
||||
|
||||
const minified = `.my-other-class{background:data("${uri}");height:100px;}`;
|
||||
assert.equal(UnminifiedCssAudit.computeTokenLength(full), minified.length);
|
||||
});
|
||||
|
||||
it('should handle reeally long strings', () => {
|
||||
let hugeCss = '';
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
hugeCss += `.my-class-${i} { width: 100px; height: 100px; }\n`;
|
||||
}
|
||||
|
||||
assert.ok(UnminifiedCssAudit.computeTokenLength(hugeCss) < 0.9 * hugeCss.length);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when given unminified stylesheets', () => {
|
||||
const auditResult = UnminifiedCssAudit.audit_(
|
||||
{
|
||||
Styles: new Map([
|
||||
[
|
||||
'123.1',
|
||||
{
|
||||
header: {sourceURL: 'foo.css'},
|
||||
content: `
|
||||
/*
|
||||
* a complicated comment
|
||||
* that is
|
||||
* several
|
||||
* lines
|
||||
*/
|
||||
.my-class {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
`.replace(/\n\s+/g, '\n'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'123.2',
|
||||
{
|
||||
header: {sourceURL: 'other.css'},
|
||||
content: `
|
||||
.my-other-class {
|
||||
background: data("data:image/jpeg;base64,asdfadiosgjwiojasfaasd");
|
||||
height: 100px;
|
||||
}
|
||||
`.replace(/\n\s+/g, '\n'),
|
||||
},
|
||||
],
|
||||
]).values(),
|
||||
},
|
||||
[
|
||||
{url: 'foo.css', _transferSize: 20 * KB, _resourceType},
|
||||
{url: 'other.css', _transferSize: 50 * KB, _resourceType},
|
||||
]
|
||||
);
|
||||
|
||||
assert.equal(auditResult.results.length, 2);
|
||||
assert.equal(auditResult.results[0].url, 'foo.css');
|
||||
assert.equal(Math.round(auditResult.results[0].wastedPercent), 65);
|
||||
assert.equal(Math.round(auditResult.results[0].wastedBytes / 1024), 13);
|
||||
assert.equal(auditResult.results[1].url, 'other.css');
|
||||
assert.equal(Math.round(auditResult.results[1].wastedPercent), 8);
|
||||
assert.equal(Math.round(auditResult.results[1].wastedBytes / 1024), 4);
|
||||
});
|
||||
|
||||
it('passes when stylesheets are already minified', () => {
|
||||
const auditResult = UnminifiedCssAudit.audit_(
|
||||
{
|
||||
Styles: new Map([
|
||||
['123.1', {header: {sourceURL: 'foo.css'}, content: '#id{width:100px;}'}],
|
||||
[
|
||||
'123.2',
|
||||
{
|
||||
header: {sourceURL: 'other.css'},
|
||||
content: `
|
||||
/* basically just one comment */
|
||||
.the-class {
|
||||
display: block;
|
||||
}
|
||||
`.replace(/\n\s+/g, '\n'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'123.3',
|
||||
{
|
||||
header: {sourceURL: 'invalid.css'},
|
||||
content: '/* a broken comment .clasz { width: 0; }',
|
||||
},
|
||||
],
|
||||
]).values(),
|
||||
},
|
||||
[
|
||||
{url: 'foo.css', _transferSize: 20 * KB, _resourceType},
|
||||
{url: 'other.css', _transferSize: 512, _resourceType},
|
||||
{url: 'invalid.css', _transferSize: 20 * KB, _resourceType},
|
||||
]
|
||||
);
|
||||
|
||||
assert.equal(auditResult.results.length, 0);
|
||||
});
|
||||
});
|
Загрузка…
Ссылка в новой задаче