lighthouse
Version:
Automated auditing, performance metrics, and best practices for the web.
552 lines (468 loc) • 21.4 kB
JavaScript
/**
* @license
* Copyright 2018 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/** @typedef {import('./dom.js').DOM} DOM */
import {CategoryRenderer} from './category-renderer.js';
import {ReportUtils} from './report-utils.js';
import {Globals} from './report-globals.js';
import {Util} from '../../shared/util.js';
import {createGauge, updateGauge} from './explodey-gauge.js';
const LOCAL_STORAGE_INSIGHTS_KEY = '__lh__insights_audits_toggle_state_2';
/**
* @typedef {('DEFAULT'|'AUDITS'|'INSIGHTS')} InsightsExperimentState
*/
export class PerformanceCategoryRenderer extends CategoryRenderer {
/** @type InsightsExperimentState*/
_memoryInsightToggleState = 'DEFAULT';
/**
* @param {LH.ReportResult.AuditRef} audit
* @return {!Element}
*/
_renderMetric(audit) {
const tmpl = this.dom.createComponent('metric');
const element = this.dom.find('.lh-metric', tmpl);
element.id = audit.result.id;
const rating = ReportUtils.calculateRating(audit.result.score, audit.result.scoreDisplayMode);
element.classList.add(`lh-metric--${rating}`);
const titleEl = this.dom.find('.lh-metric__title', tmpl);
titleEl.textContent = audit.result.title;
const valueEl = this.dom.find('.lh-metric__value', tmpl);
valueEl.textContent = audit.result.displayValue || '';
const descriptionEl = this.dom.find('.lh-metric__description', tmpl);
descriptionEl.append(this.dom.convertMarkdownLinkSnippets(audit.result.description));
if (audit.result.scoreDisplayMode === 'error') {
descriptionEl.textContent = '';
valueEl.textContent = 'Error!';
const tooltip = this.dom.createChildOf(descriptionEl, 'span');
tooltip.textContent = audit.result.errorMessage || 'Report error: no metric information';
} else if (audit.result.scoreDisplayMode === 'notApplicable') {
valueEl.textContent = '--';
}
return element;
}
/**
* Get a link to the interactive scoring calculator with the metric values.
* @param {LH.ReportResult.AuditRef[]} auditRefs
* @return {string}
*/
_getScoringCalculatorHref(auditRefs) {
// TODO: filter by !!acronym when dropping renderer support of v7 LHRs.
const metrics = auditRefs.filter(audit => audit.group === 'metrics');
const tti = auditRefs.find(audit => audit.id === 'interactive');
const fci = auditRefs.find(audit => audit.id === 'first-cpu-idle');
const fmp = auditRefs.find(audit => audit.id === 'first-meaningful-paint');
if (tti) metrics.push(tti);
if (fci) metrics.push(fci);
if (fmp && typeof fmp.result.score === 'number') metrics.push(fmp);
/**
* Clamp figure to 2 decimal places
* @param {number} val
* @return {number}
*/
const clampTo2Decimals = val => Math.round(val * 100) / 100;
const metricPairs = metrics.map(audit => {
let value;
if (typeof audit.result.numericValue === 'number') {
value = audit.id === 'cumulative-layout-shift' ?
clampTo2Decimals(audit.result.numericValue) :
Math.round(audit.result.numericValue);
value = value.toString();
} else {
value = 'null';
}
return [audit.acronym || audit.id, value];
});
const paramPairs = [...metricPairs];
if (Globals.reportJson) {
paramPairs.push(['device', Globals.reportJson.configSettings.formFactor]);
paramPairs.push(['version', Globals.reportJson.lighthouseVersion]);
}
const params = new URLSearchParams(paramPairs);
const url = new URL('https://googlechrome.github.io/lighthouse/scorecalc/');
url.hash = params.toString();
return url.href;
}
/**
* Returns overallImpact and linearImpact for an audit.
* The overallImpact is determined by the audit saving's effect on the overall performance score.
* We use linearImpact to compare audits where their overallImpact is rounded down to 0.
*
* @param {LH.ReportResult.AuditRef} audit
* @param {LH.ReportResult.AuditRef[]} metricAudits
* @return {{overallImpact: number, overallLinearImpact: number}}
*/
overallImpact(audit, metricAudits) {
if (!audit.result.metricSavings) {
return {overallImpact: 0, overallLinearImpact: 0};
}
let overallImpact = 0;
let overallLinearImpact = 0;
for (const [k, savings] of Object.entries(audit.result.metricSavings)) {
// Get metric savings for individual audit.
if (savings === undefined) continue;
// Get the metric data.
const mAudit = metricAudits.find(audit => audit.acronym === k);
if (!mAudit) continue;
if (mAudit.result.score === null) continue;
const mValue = mAudit.result.numericValue;
if (!mValue) continue;
const linearImpact = savings / mValue * mAudit.weight;
overallLinearImpact += linearImpact;
const scoringOptions = mAudit.result.scoringOptions;
if (!scoringOptions) continue;
const newMetricScore = Util.computeLogNormalScore(scoringOptions, mValue - savings);
const weightedMetricImpact = (newMetricScore - mAudit.result.score) * mAudit.weight;
overallImpact += weightedMetricImpact;
}
return {overallImpact, overallLinearImpact};
}
/**
* @param {InsightsExperimentState} newState
**/
_persistInsightToggleToStorage(newState) {
try {
window.localStorage.setItem(LOCAL_STORAGE_INSIGHTS_KEY, newState);
} finally {
this._memoryInsightToggleState = newState;
}
}
/**
* @returns {InsightsExperimentState}
**/
_getInsightToggleState() {
let state = this._getRawInsightToggleState();
if (state === 'DEFAULT') state = 'INSIGHTS';
return state;
}
/**
* @returns {InsightsExperimentState}
**/
_getRawInsightToggleState() {
try {
const fromStorage = window.localStorage.getItem(LOCAL_STORAGE_INSIGHTS_KEY);
if (fromStorage === 'AUDITS' || fromStorage === 'INSIGHTS') {
return fromStorage;
}
} catch {
return this._memoryInsightToggleState;
}
return 'DEFAULT';
}
/**
* @param {HTMLButtonElement} button
**/
_setInsightToggleButtonText(button) {
const state = this._getInsightToggleState();
button.innerText =
state === 'AUDITS' ? Globals.strings.tryInsights : Globals.strings.goBackToAudits;
}
/**
* @param {HTMLElement} element
*/
_renderInsightsToggle(element) {
// Insights / Audits toggle.
const container = this.dom.createChildOf(element, 'div', 'lh-perf-insights-toggle');
const textSpan = this.dom.createChildOf(container, 'span', 'lh-perf-toggle-text');
const icon = this.dom.createElement('span', 'lh-perf-insights-icon insights-icon-url');
textSpan.appendChild(icon);
textSpan.appendChild(this.dom.convertMarkdownLinkSnippets(Globals.strings.insightsNotice));
const buttonClasses = 'lh-button lh-button-insight-toggle';
const button = this.dom.createChildOf(container, 'button', buttonClasses);
this._setInsightToggleButtonText(button);
button.addEventListener('click', event => {
event.preventDefault();
const swappableSection = this.dom.maybeFind('.lh-perf-audits--swappable');
if (swappableSection) {
this.dom.swapSectionIfPossible(swappableSection);
}
const currentState = this._getInsightToggleState();
const newState = currentState === 'AUDITS' ? 'INSIGHTS' : 'AUDITS';
this.dom.fireEventOn('lh-analytics', this.dom.document(), {
name: 'toggle_insights',
data: {newState},
});
this._persistInsightToggleToStorage(newState);
this._setInsightToggleButtonText(button);
});
container.appendChild(button);
}
/**
* @param {LH.ReportResult.Category} category
* @param {Object<string, LH.Result.ReportGroup>} groups
* @param {{gatherMode: LH.Result.GatherMode}=} options
* @return {Element}
* @override
*/
render(category, groups, options) {
const strings = Globals.strings;
const element = this.dom.createElement('div', 'lh-category');
element.id = category.id;
element.append(this.renderCategoryHeader(category, groups, options));
// Metrics.
const metricAudits = category.auditRefs.filter(audit => audit.group === 'metrics');
if (metricAudits.length) {
const [metricsGroupEl, metricsFooterEl] = this.renderAuditGroup(groups.metrics);
// Metric descriptions toggle.
const checkboxEl = this.dom.createElement('input', 'lh-metrics-toggle__input');
const checkboxId = `lh-metrics-toggle${Globals.getUniqueSuffix()}`;
checkboxEl.setAttribute('aria-label', 'Toggle the display of metric descriptions');
checkboxEl.type = 'checkbox';
checkboxEl.id = checkboxId;
metricsGroupEl.prepend(checkboxEl);
const metricHeaderEl = this.dom.find('.lh-audit-group__header', metricsGroupEl);
const labelEl = this.dom.createChildOf(metricHeaderEl, 'label', 'lh-metrics-toggle__label');
labelEl.htmlFor = checkboxId;
const showEl = this.dom.createChildOf(labelEl, 'span', 'lh-metrics-toggle__labeltext--show');
const hideEl = this.dom.createChildOf(labelEl, 'span', 'lh-metrics-toggle__labeltext--hide');
showEl.textContent = Globals.strings.expandView;
hideEl.textContent = Globals.strings.collapseView;
const metricsBoxesEl = this.dom.createElement('div', 'lh-metrics-container');
metricsGroupEl.insertBefore(metricsBoxesEl, metricsFooterEl);
metricAudits.forEach(item => {
metricsBoxesEl.append(this._renderMetric(item));
});
// Only add the disclaimer with the score calculator link if the category was rendered with a score gauge.
if (element.querySelector('.lh-gauge__wrapper')) {
const descriptionEl = this.dom.find('.lh-category-header__description', element);
const estValuesEl = this.dom.createChildOf(descriptionEl, 'div', 'lh-metrics__disclaimer');
const disclaimerEl = this.dom.convertMarkdownLinkSnippets(strings.varianceDisclaimer);
estValuesEl.append(disclaimerEl);
// Add link to score calculator.
const calculatorLink = this.dom.createChildOf(estValuesEl, 'a', 'lh-calclink');
calculatorLink.target = '_blank';
calculatorLink.textContent = strings.calculatorLink;
this.dom.safelySetHref(calculatorLink, this._getScoringCalculatorHref(category.auditRefs));
}
metricsGroupEl.classList.add('lh-audit-group--metrics');
element.append(metricsGroupEl);
}
// Filmstrip
const timelineEl = this.dom.createChildOf(element, 'div', 'lh-filmstrip-container');
const thumbnailAudit = category.auditRefs.find(audit => audit.id === 'screenshot-thumbnails');
const thumbnailResult = thumbnailAudit?.result;
if (thumbnailResult?.details) {
timelineEl.id = thumbnailResult.id;
const filmstripEl = this.detailsRenderer.render(thumbnailResult.details);
filmstripEl && timelineEl.append(filmstripEl);
}
this._renderInsightsToggle(element);
const legacyAuditsSection =
this.renderFilterableSection(category, groups, ['diagnostics'], metricAudits);
legacyAuditsSection?.classList.add('lh-perf-audits--swappable', 'lh-perf-audits--legacy');
const experimentalInsightsSection =
this.renderFilterableSection(category, groups, ['insights', 'diagnostics'], metricAudits);
experimentalInsightsSection?.classList.add(
'lh-perf-audits--swappable', 'lh-perf-audits--experimental');
if (legacyAuditsSection) {
element.append(legacyAuditsSection);
// Many tests expect just one of these sections to be in the DOM at a given time.
// To prevent the hidden section from tripping up these tests, we will just remove the hidden
// section from the DOM and store it in memory.
if (experimentalInsightsSection) {
this.dom.registerSwappableSections(legacyAuditsSection, experimentalInsightsSection);
}
}
// Deal with the user loading the report and having toggled to Insights
// which is now stored in local storage. Put in a rAF otherwise this code
// runs before the DOM is created.
if (this._getInsightToggleState() === 'INSIGHTS') {
requestAnimationFrame(() => {
const swappableSection = this.dom.maybeFind('.lh-perf-audits--swappable');
if (swappableSection) {
this.dom.swapSectionIfPossible(swappableSection);
}
});
}
// Log the initial state.
this.dom.fireEventOn('lh-analytics', this.dom.document(), {
name: 'initial_insights_state',
data: {state: this._getRawInsightToggleState()},
});
const isNavigationMode = !options || options?.gatherMode === 'navigation';
if (isNavigationMode && category.score !== null) {
const el = createGauge(this.dom);
updateGauge(this.dom, el, category);
this.dom.find('.lh-score__gauge', element).replaceWith(el);
}
return element;
}
/**
* @param {LH.ReportResult.Category} category
* @param {Object<string, LH.Result.ReportGroup>} groups
* @param {string[]} groupNames
* @param {LH.ReportResult.AuditRef[]} metricAudits
* @return {Element|null}
*/
renderFilterableSection(category, groups, groupNames, metricAudits) {
if (groupNames.some(groupName => !groups[groupName])) return null;
const element = this.dom.createElement('div');
/** @type {Set<string>} */
const replacedAuditIds = new Set();
/**
* This exists to temporarily allow showing insights - which are in the hidden
* group by default - when using the insights toggle.
* See https://github.com/GoogleChrome/lighthouse/pull/16418 for motivation.
*
* @param {LH.ReportResult.AuditRef} auditRef
*/
const getGroup = (auditRef) => {
return auditRef.id.endsWith('-insight') ? 'insights' : auditRef.group ?? '';
};
const allGroupAudits =
category.auditRefs.filter(audit => groupNames.includes(getGroup(audit)));
for (const auditRef of allGroupAudits) {
auditRef.result.replacesAudits?.forEach(replacedAuditId => {
replacedAuditIds.add(replacedAuditId);
});
}
// Diagnostics
const allFilterableAudits = allGroupAudits
.filter(audit => !replacedAuditIds.has(audit.id))
.map(auditRef => {
const {overallImpact, overallLinearImpact} = this.overallImpact(auditRef, metricAudits);
const guidanceLevel = auditRef.result.guidanceLevel || 1;
const auditEl = this.renderAudit(auditRef);
return {auditRef, auditEl, overallImpact, overallLinearImpact, guidanceLevel};
});
const filterableAudits = allFilterableAudits
.filter(audit => !ReportUtils.showAsPassed(audit.auditRef.result));
const passedAudits = allFilterableAudits
.filter(audit => ReportUtils.showAsPassed(audit.auditRef.result));
/** @type {Record<string, [Element, Element|null]|undefined>} */
const groupElsMap = {};
for (const groupName of groupNames) {
const groupEls = this.renderAuditGroup(groups[groupName]);
groupEls[0].classList.add(`lh-audit-group--${groupName}`);
groupElsMap[groupName] = groupEls;
}
/**
* @param {string} acronym
*/
function refreshFilteredAudits(acronym) {
for (const audit of allFilterableAudits) {
if (acronym === 'All') {
audit.auditEl.hidden = false;
} else {
const shouldHide = audit.auditRef.result.metricSavings?.[acronym] === undefined;
audit.auditEl.hidden = shouldHide;
}
}
filterableAudits.sort((a, b) => {
// Performance diagnostics should only have score display modes of "informative" and "metricSavings"
// If the score display mode is "metricSavings", the `score` will be a coarse approximation of the overall impact.
// Therefore, it makes sense to sort audits by score first to ensure visual clarity with the score icons.
const scoreA = a.auditRef.result.score || 0;
const scoreB = b.auditRef.result.score || 0;
if (scoreA !== scoreB) return scoreA - scoreB;
// If there is a metric filter applied, we should sort by the impact to that specific metric.
if (acronym !== 'All') {
const aSavings = a.auditRef.result.metricSavings?.[acronym] ?? -1;
const bSavings = b.auditRef.result.metricSavings?.[acronym] ?? -1;
if (aSavings !== bSavings) return bSavings - aSavings;
}
// Overall impact is the estimated improvement to the performance score
if (a.overallImpact !== b.overallImpact) {
return b.overallImpact * b.guidanceLevel - a.overallImpact * a.guidanceLevel;
}
// Fall back to the linear impact if the normal impact is rounded down to 0
if (
a.overallImpact === 0 && b.overallImpact === 0 &&
a.overallLinearImpact !== b.overallLinearImpact
) {
return b.overallLinearImpact * b.guidanceLevel - a.overallLinearImpact * a.guidanceLevel;
}
// Audits that have no estimated savings should be prioritized by the guidance level
return b.guidanceLevel - a.guidanceLevel;
});
for (const audit of filterableAudits) {
if (!audit.auditRef.group) continue;
const groupEls = groupElsMap[getGroup(audit.auditRef)];
if (!groupEls) continue;
const [groupEl, footerEl] = groupEls;
groupEl.insertBefore(audit.auditEl, footerEl);
}
}
/** @type {Set<string>} */
const filterableMetricAcronyms = new Set();
for (const audit of filterableAudits) {
const metricSavings = audit.auditRef.result.metricSavings || {};
for (const [key, value] of Object.entries(metricSavings)) {
if (typeof value === 'number') filterableMetricAcronyms.add(key);
}
}
const filterableMetrics =
metricAudits.filter(a => a.acronym && filterableMetricAcronyms.has(a.acronym));
// TODO: only add if there are opportunities & diagnostics rendered.
if (filterableMetrics.length) {
this.renderMetricAuditFilter(filterableMetrics, element, refreshFilteredAudits);
}
refreshFilteredAudits('All');
for (const groupName of groupNames) {
if (filterableAudits.some(audit => getGroup(audit.auditRef) === groupName)) {
const groupEls = groupElsMap[groupName];
if (!groupEls) continue;
element.append(groupEls[0]);
}
}
if (!passedAudits.length) return element;
const clumpOpts = {
auditRefsOrEls: passedAudits.map(audit => audit.auditEl),
groupDefinitions: groups,
};
const passedElem = this.renderClump('passed', clumpOpts);
element.append(passedElem);
return element;
}
/**
* Render the control to filter the audits by metric. The filtering is done at runtime by CSS only
* @param {LH.ReportResult.AuditRef[]} filterableMetrics
* @param {HTMLDivElement} categoryEl
* @param {(acronym: string) => void} onFilterChange
*/
renderMetricAuditFilter(filterableMetrics, categoryEl, onFilterChange) {
const metricFilterEl = this.dom.createElement('div', 'lh-metricfilter');
const textEl = this.dom.createChildOf(metricFilterEl, 'span', 'lh-metricfilter__text');
textEl.textContent = Globals.strings.showRelevantAudits;
const filterChoices = [
/** @type {const} */ ({acronym: 'All', id: 'All'}),
...filterableMetrics,
];
// Form labels need to reference unique IDs, but multiple reports rendered in the same DOM (eg PSI)
// would mean ID conflict. To address this, we 'scope' these radio inputs with a unique suffix.
const uniqSuffix = Globals.getUniqueSuffix();
for (const metric of filterChoices) {
const elemId = `metric-${metric.acronym}-${uniqSuffix}`;
const radioEl = this.dom.createChildOf(metricFilterEl, 'input', 'lh-metricfilter__radio');
radioEl.type = 'radio';
radioEl.name = `metricsfilter-${uniqSuffix}`;
radioEl.id = elemId;
const labelEl = this.dom.createChildOf(metricFilterEl, 'label', 'lh-metricfilter__label');
labelEl.htmlFor = elemId;
labelEl.title = 'result' in metric ? metric.result.title : '';
labelEl.textContent = metric.acronym || metric.id;
if (metric.acronym === 'All') {
radioEl.checked = true;
labelEl.classList.add('lh-metricfilter__label--active');
}
categoryEl.append(metricFilterEl);
// Toggle class/hidden state based on filter choice.
radioEl.addEventListener('input', _ => {
for (const elem of categoryEl.querySelectorAll('label.lh-metricfilter__label')) {
elem.classList.toggle('lh-metricfilter__label--active', elem.htmlFor === elemId);
}
categoryEl.classList.toggle('lh-category--filtered', metric.acronym !== 'All');
onFilterChange(metric.acronym || 'All');
// Hide groups/clumps if all child audits are also hidden.
const groupEls = categoryEl.querySelectorAll('div.lh-audit-group, details.lh-audit-group');
for (const groupEl of groupEls) {
groupEl.hidden = false;
const childEls = Array.from(groupEl.querySelectorAll('div.lh-audit'));
const areAllHidden = !!childEls.length && childEls.every(auditEl => auditEl.hidden);
groupEl.hidden = areAllHidden;
}
});
}
}
}