UNPKG

lighthouse

Version:

Automated auditing, performance metrics, and best practices for the web.

420 lines (353 loc) • 16.7 kB
/** * @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'; export class PerformanceCategoryRenderer extends CategoryRenderer { /** * @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 {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); } const auditsSection = this.renderFilterableSection(category, groups, ['insights', 'diagnostics'], metricAudits); if (auditsSection) { auditsSection.classList.add('lh-perf-audits'); element.append(auditsSection); } 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'); /** * @param {LH.ReportResult.AuditRef} auditRef */ const getGroup = (auditRef) => { return auditRef.group ?? ''; }; const allGroupAudits = category.auditRefs.filter(audit => groupNames.includes(getGroup(audit))); // Diagnostics const allFilterableAudits = allGroupAudits .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 notPassedAudits = 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; } } notPassedAudits.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 notPassedAudits) { 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 notPassedAudits) { 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 (notPassedAudits.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; } }); } } }