Audit: DOM stats (total nodes, depth, width) and hero card formatter (#1673)

* Audit: num DOM nodes created by the page

* update gatherer

* More useful audit

* Add card formatter

* Cleanup

* audit file rename

* Update audit tests. Add pretty print support for card formatter

* css tweaks

* Smoketests, card formatter tests

* Ignore in page functions in istanbul

* feedback

* feedback
This commit is contained in:
Eric Bidelman 2017-02-16 21:14:25 -08:00 коммит произвёл GitHub
Родитель f79adc793c
Коммит 220b9f0171
12 изменённых файлов: 611 добавлений и 1 удалений

94
lighthouse-cli/test/fixtures/dobetterweb/domtester.html поставляемый Normal file
Просмотреть файл

@ -0,0 +1,94 @@
<!doctype html>
<!--
* 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.
-->
<html>
<head>
<title>DoBetterWeb - DOM size tester</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
</head>
<body>
<main>
<section>
<div>
<div>6</div>
</div>
<div>
<div>
<div id="attachshadow">7</div>
</div>
</div>
<div>
<template>
<div><div><div><div>10, but noop in template</div></div></div></div>
</template>
</div>
</section>
</main>
<footer>
<div id="attachshadow-big" class="test this out">4</div>
<div>3</div>
<div></div>
<div></div>
<div></div>
<div></div>
</footer>
<script>
function withShadowDOMTest() {
const el = document.querySelector('#attachshadow');
el.innerHTML = `<div>7</div>`;
el.attachShadow({mode: 'open'}).innerHTML = `
<div>
<div class="here">9</div>
</div>
`;
const el2 = document.querySelector('#attachshadow-big');
el2.attachShadow({mode: 'open'}).innerHTML = `
<div>
<div>6</div>
<div>6</div>
<div>6</div>
<div>6</div>
<div>6</div>
<div>6</div>
<div>6</div>
<div>6</div>
<div>6</div>
</div>
`;
}
function domTest(numNodes=1500) {
const frag = new DocumentFragment();
for (let i = 0; i < numNodes; ++i) {
frag.appendChild(document.createElement('span'));
}
document.body.appendChild(frag);
}
const params = new URLSearchParams(location.search);
if (params.has('smallDOM')) {
domTest(1300);
} else if (params.has('largeDOM')) {
domTest(6000);
}
if (params.has('withShadowDOM')) {
withShadowDOMTest();
}
</script>
</body>
</html>

Просмотреть файл

@ -148,6 +148,51 @@ module.exports = [
}
}
}
}, {
initialUrl: 'http://localhost:10200/dobetterweb/domtester.html?smallDOM',
url: 'http://localhost:10200/dobetterweb/domtester.html?smallDOM',
audits: {
'dom-size': {
score: 100,
extendedInfo: {
value: {
0: {value: '1,323'},
1: {value: '7'},
2: {value: '1,303'}
}
}
}
}
}, {
initialUrl: 'http://localhost:10200/dobetterweb/domtester.html?largeDOM&withShadowDOM',
url: 'http://localhost:10200/dobetterweb/domtester.html?largeDOM&withShadowDOM',
audits: {
'dom-size': {
score: 0,
extendedInfo: {
value: {
0: {value: '6,024'},
1: {value: '9'},
2: {value: '6,003'}
}
}
}
}
}, {
initialUrl: 'http://localhost:10200/dobetterweb/domtester.html?withShadowDOM',
url: 'http://localhost:10200/dobetterweb/domtester.html?withShadowDOM',
audits: {
'dom-size': {
score: 100,
extendedInfo: {
value: {
0: {value: '24'},
1: {value: '9'},
2: {value: '9'}
}
}
}
}
}, {
initialUrl: 'http://localhost:10200/online-only.html',
url: 'http://localhost:10200/online-only.html',

Просмотреть файл

@ -0,0 +1,112 @@
/**
* @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.
*/
/**
* @fileoverview Audits a page to see how the size of DOM it creates. Stats like
* tree depth, # children, and total nodes are returned. The score is calculated
* based solely on the total number of nodes found on the page.
*/
'use strict';
const Audit = require('../audit');
const TracingProcessor = require('../../lib/traces/tracing-processor');
const Formatter = require('../../formatters/formatter');
const MAX_DOM_NODES = 1500;
const MAX_DOM_TREE_WIDTH = 60;
const MAX_DOM_TREE_DEPTH = 32;
// Parameters for log-normal CDF scoring. See https://www.desmos.com/calculator/9cyxpm5qgp.
const SCORING_POINT_OF_DIMINISHING_RETURNS = 2400;
const SCORING_MEDIAN = 3000;
class DOMSize extends Audit {
static get MAX_DOM_NODES() {
return MAX_DOM_NODES;
}
/**
* @return {!AuditMeta}
*/
static get meta() {
return {
category: 'Performance',
name: 'dom-size',
description: 'Avoids an excessive DOM size',
optimalValue: DOMSize.MAX_DOM_NODES.toLocaleString() + ' nodes',
helpText: 'Browser engineers recommend pages contain fewer than ' +
`~${DOMSize.MAX_DOM_NODES.toLocaleString()} DOM nodes. The sweet spot is a tree depth < ` +
`${MAX_DOM_TREE_DEPTH} elements and fewer than ${MAX_DOM_TREE_WIDTH} ` +
'children/parent element. A large DOM can increase memory, cause longer ' +
'[style calculations](https://developers.google.com/web/fundamentals/performance/rendering/reduce-the-scope-and-complexity-of-style-calculations), ' +
'and produce costly [layout reflows](https://developers.google.com/speed/articles/reflow). [Learn more](https://developers.google.com/web/fundamentals/performance/rendering/).',
requiredArtifacts: ['DOMStats']
};
}
/**
* @param {!Artifacts} artifacts
* @return {!AuditResult}
*/
static audit(artifacts) {
const stats = artifacts.DOMStats;
/**
* html >
* body >
* div >
* span
*/
const depthSnippet = stats.depth.pathToElement.reduce((str, curr, i) => {
return `${str}\n` + ' '.repeat(i) + `${curr} >`;
}, '').replace(/>$/g, '').trim();
const widthSnippet = 'Element with most children:\n' +
stats.width.pathToElement[stats.width.pathToElement.length - 1];
// Use the CDF of a log-normal distribution for scoring.
// <= 1500: score≈100
// 3000: score=50
// >= 5970: score≈0
const distribution = TracingProcessor.getLogNormalDistribution(
SCORING_MEDIAN, SCORING_POINT_OF_DIMINISHING_RETURNS);
let score = 100 * distribution.computeComplementaryPercentile(stats.totalDOMNodes);
// Clamp the score to 0 <= x <= 100.
score = Math.max(0, Math.min(100, score));
const cards = [
{title: 'Total DOM Nodes', value: stats.totalDOMNodes.toLocaleString()},
{title: 'DOM Depth', value: stats.depth.max.toLocaleString(), snippet: depthSnippet},
{title: 'Maximum Children', value: stats.width.max.toLocaleString(), snippet: widthSnippet}
];
return DOMSize.generateAuditResult({
rawValue: stats.totalDOMNodes,
optimalValue: this.meta.optimalValue,
score: Math.round(score),
displayValue: `${stats.totalDOMNodes.toLocaleString()} nodes`,
extendedInfo: {
formatter: Formatter.SUPPORTED_FORMATS.CARD,
value: cards
}
});
}
}
module.exports = DOMSize;

Просмотреть файл

@ -43,6 +43,7 @@
"dobetterweb/document-write",
"dobetterweb/geolocation-on-start",
"dobetterweb/notification-on-start",
"dobetterweb/domstats",
"dobetterweb/optimized-images",
"dobetterweb/tags-blocking-first-paint",
"dobetterweb/websql"
@ -87,8 +88,9 @@
"byte-efficiency/unused-css-rules",
"byte-efficiency/uses-optimized-images",
"byte-efficiency/uses-responsive-images",
"dobetterweb/external-anchors-use-rel-noopener",
"dobetterweb/appcache-manifest",
"dobetterweb/dom-size",
"dobetterweb/external-anchors-use-rel-noopener",
"dobetterweb/geolocation-on-start",
"dobetterweb/link-blocking-first-paint",
"dobetterweb/no-console-time",
@ -380,6 +382,10 @@
"categorizable": false,
"items": [{
"audits": {
"dom-size": {
"expectedValue": 100,
"weight": 1
},
"critical-request-chains": {
"expectedValue": 0,
"weight": 1

Просмотреть файл

@ -0,0 +1,50 @@
/**
* @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 Formatter = require('./formatter');
const path = require('path');
const fs = require('fs');
const html = fs.readFileSync(path.join(__dirname, 'partials/cards.html'), 'utf8');
class Card extends Formatter {
static getFormatter(type) {
switch (type) {
case 'pretty':
return result => {
if (!result || !Array.isArray(result)) {
return '';
}
let output = '';
result.forEach(item => {
output += ` - ${item.title}: ${item.value}\n`;
});
return output;
};
case 'html':
// Returns a handlebars string to be used by the Report.
return html;
default:
throw new Error('Unknown formatter type');
}
}
}
module.exports = Card;

Просмотреть файл

@ -40,6 +40,7 @@ class Formatter {
static _getFormatters() {
this._formatters = {
accessibility: require('./accessibility'),
card: require('./cards'),
criticalRequestChains: require('./critical-request-chains'),
urllist: require('./url-list'),
null: require('./null-formatter'),

Просмотреть файл

@ -0,0 +1,40 @@
.cards__container {
--padding: 16px;
display: flex;
flex-wrap: wrap;
}
.scorecard {
display: flex;
align-items: center;
justify-content: center;
flex: 1 1 150px;
flex-direction: column;
padding: calc(var(--padding) / 2);
padding-top: calc(32px + var(--padding) / 2);
border-radius: 3px;
margin-right: var(--padding);
position: relative;
color: var(--secondary-text-color);
line-height: inherit;
border: 1px solid #ebebeb;
}
.scorecard-title {
/*text-transform: uppercase;*/
font-size: var(--subitem-font-size);
line-height: var(--heading-line-height);
background-color: #eee;
position: absolute;
top: 0;
right: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
border-bottom: 1px solid #ebebeb;
}
.scorecard-value {
font-size: 24px;
}
.scorecard-summary {
margin-top: calc(var(--padding) / 2);
}

Просмотреть файл

@ -0,0 +1,8 @@
<ul class="subitem__details cards__container">
{{#each this}}
<div class="subitem__detail scorecard" {{#if snippet}}title="{{snippet}}"{{/if}}>
<div class="scorecard-title">{{title}}</div>
<div class="scorecard-value">{{value}}</div>
</div>
{{/each}}
</ul>

Просмотреть файл

@ -0,0 +1,132 @@
/**
* @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.
*/
/**
* @fileoverview Gathers stats about the max height and width of the DOM tree
* and total number of nodes used on the page.
*/
/* global document */
'use strict';
const Gatherer = require('../gatherer');
/**
* Constructs a pretty label from element's selectors. For example, given
* <div id="myid" class="myclass">, returns 'div#myid.myclass'.
* @param {!HTMLElement} element
* @return {!string}
*/
/* istanbul ignore next */
function createSelectorsLabel(element) {
let name = element.localName;
const idAttr = element.getAttribute && element.getAttribute('id');
if (idAttr) {
name += `#${idAttr}`;
}
const className = element.className;
if (className) {
name += `.${className.replace(/\s+/g, '.')}`;
}
return name;
}
/**
* @param {!HTMLElement} element
* @return {!Array<string>}
*/
/* istanbul ignore next */
function elementPathInDOM(element) {
const path = [createSelectorsLabel(element)];
let node = element;
while (node) {
node = node.parentNode && node.parentNode.host ? node.parentNode.host : node.parentElement;
if (node) {
path.unshift(createSelectorsLabel(node));
}
}
return path;
}
/**
* Calculates the maximum tree depth of the DOM.
* @param {!HTMLElement} element Root of the tree to look in.
* @param {boolean=} deep True to include shadow roots. Defaults to true.
* @return {!number}
*/
/* istanbul ignore next */
function getDOMStats(element, deep=true) {
let deepestNode = null;
let maxDepth = 0;
let maxWidth = 0;
let parentWithMostChildren = null;
const _calcDOMWidthAndHeight = function(element, depth=1) {
if (depth > maxDepth) {
deepestNode = element;
maxDepth = depth;
}
if (element.children.length > maxWidth) {
parentWithMostChildren = element;
maxWidth = element.children.length;
}
let child = element.firstElementChild;
while (child) {
_calcDOMWidthAndHeight(child, depth + 1);
// If node has shadow dom, traverse into that tree.
if (deep && child.shadowRoot) {
_calcDOMWidthAndHeight(child.shadowRoot, depth + 1);
}
child = child.nextElementSibling;
}
return {maxDepth, maxWidth};
};
const result = _calcDOMWidthAndHeight(element);
return {
totalDOMNodes: document.querySelectorAll('html, html /deep/ *').length,
depth: {
max: result.maxDepth,
pathToElement: elementPathInDOM(deepestNode),
},
width: {
max: result.maxWidth,
pathToElement: elementPathInDOM(parentWithMostChildren)
}
};
}
class DOMStats extends Gatherer {
/**
* @param {!Object} options
* @return {!Promise<!Array<!Object>>}
*/
afterPass(options) {
const expression = `(function() {
${createSelectorsLabel.toString()};
${elementPathInDOM.toString()};
return (${getDOMStats.toString()}(document.documentElement));
})()`;
return options.driver.evaluateAsync(expression);
}
}
module.exports = DOMStats;

Просмотреть файл

@ -64,6 +64,7 @@ class ReportGenerator {
// Cannot DRY this up and dynamically create paths because fs.readdirSync
// doesn't browserify well with a variable path. See https://github.com/substack/brfs/issues/36.
const partialStyles = [
fs.readFileSync(__dirname + '/../formatters/partials/cards.css', 'utf8'),
fs.readFileSync(__dirname + '/../formatters/partials/critical-request-chains.css', 'utf8'),
fs.readFileSync(__dirname + '/../formatters/partials/table.css', 'utf8'),
fs.readFileSync(__dirname + '/../formatters/partials/url-list.css', 'utf8'),

Просмотреть файл

@ -0,0 +1,62 @@
/**
* 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 DOMSize = require('../../../audits/dobetterweb/dom-size.js');
const assert = require('assert');
/* eslint-env mocha */
describe('Num DOM nodes audit', () => {
const numNodes = DOMSize.MAX_DOM_NODES;
const artifact = {
DOMStats: {
totalDOMNodes: numNodes,
depth: {max: 1, pathToElement: ['html', 'body', 'div', 'span']},
width: {max: 2, pathToElement: ['html', 'body']}
}
};
const snippet = 'html >\n' +
' body >\n' +
' div >\n' +
' span';
it('calculates score hitting top of distribution', () => {
const auditResult = DOMSize.audit(artifact);
assert.equal(auditResult.score, 100);
assert.equal(auditResult.rawValue, numNodes);
assert.equal(auditResult.optimalValue, `${DOMSize.MAX_DOM_NODES.toLocaleString()} nodes`);
assert.equal(auditResult.displayValue, `${numNodes.toLocaleString()} nodes`);
assert.equal(auditResult.extendedInfo.value[0].title, 'Total DOM Nodes');
assert.equal(auditResult.extendedInfo.value[0].value, numNodes.toLocaleString());
assert.equal(auditResult.extendedInfo.value[1].title, 'DOM Depth');
assert.equal(auditResult.extendedInfo.value[1].value, 1);
assert.equal(auditResult.extendedInfo.value[1].snippet, snippet, 'generates snippet');
assert.equal(auditResult.extendedInfo.value[2].title, 'Maximum Children');
assert.equal(auditResult.extendedInfo.value[2].value, 2);
});
it('calculates score hitting mid distribution', () => {
artifact.DOMStats.totalDOMNodes = 3100;
assert.equal(DOMSize.audit(artifact).score, 43);
});
it('calculates score hitting bottom of distribution', () => {
artifact.DOMStats.totalDOMNodes = 5970;
assert.equal(DOMSize.audit(artifact).score, 0);
});
});

Просмотреть файл

@ -0,0 +1,59 @@
/**
* 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';
/* eslint-env mocha */
const CardsFormatter = require('../../formatters/cards.js');
const Handlebars = require('handlebars');
const assert = require('assert');
describe('CardsFormatter', () => {
const extendedInfo = {
value: [
{title: 'Total DOM Nodes', value: 3500},
{title: 'DOM Depth', value: 10, snippet: 'snippet'},
{title: 'Maximum Children', value: 20, snippet: 'snippet2'}
]
};
it('handles invalid input', () => {
const pretty = CardsFormatter.getFormatter('pretty');
assert.equal(pretty(), '');
assert.equal(pretty(null), '');
assert.equal(pretty({}), '');
assert.equal(pretty({results: 'blah'}), '');
});
it('generates valid pretty output', () => {
const pretty = CardsFormatter.getFormatter('pretty');
const output = pretty(extendedInfo.value);
const str = ` - ${extendedInfo.value[0].title}: ${extendedInfo.value[0].value}\n` +
` - ${extendedInfo.value[1].title}: ${extendedInfo.value[1].value}\n` +
` - ${extendedInfo.value[2].title}: ${extendedInfo.value[2].value}\n`;
assert.equal(output, str);
});
it('generates valid html output', () => {
const formatter = CardsFormatter.getFormatter('html');
const template = Handlebars.compile(formatter);
const output = template(extendedInfo.value).split('\n').join('');
assert.ok(output.match('title="snippet"'), 'adds title attribute for snippet');
assert.ok(output.match('class="subitem__details cards__container"'), 'adds wrapper class');
assert.equal(output.match(/class=\"[^"]*scorecard-value/g).length, extendedInfo.value.length);
assert.equal(output.match(/class=\"[^"]*scorecard-title/g).length, extendedInfo.value.length);
});
});