lighthouse
Version:
Automated auditing, performance metrics, and best practices for the web.
252 lines (215 loc) • 9.66 kB
JavaScript
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {Audit} from '../audit.js';
import * as i18n from '../../lib/i18n/i18n.js';
import {NetworkRecords} from '../../computed/network-records.js';
import {LoadSimulator} from '../../computed/load-simulator.js';
import {LanternLargestContentfulPaint as LanternLCP} from '../../computed/metrics/lantern-largest-contentful-paint.js';
import {LanternFirstContentfulPaint as LanternFCP} from '../../computed/metrics/lantern-first-contentful-paint.js';
import {LCPImageRecord} from '../../computed/lcp-image-record.js';
const str_ = i18n.createIcuMessageFn(import.meta.url, {});
// Parameters for log-normal distribution scoring. These values were determined by fitting the
// log-normal cumulative distribution function curve to the former method of linear interpolation
// scoring between the control points {average = 300 ms, poor = 750 ms, zero = 5000 ms} using the
// curve-fit tool at https://mycurvefit.com/ rounded to the nearest integer. See
// https://www.desmos.com/calculator/gcexiyesdi for an interactive visualization of the curve fit.
const WASTED_MS_P10 = 150;
const WASTED_MS_MEDIAN = 935;
/**
* @typedef {object} ByteEfficiencyProduct
* @property {Array<LH.Audit.ByteEfficiencyItem>} items
* @property {Map<string, number>=} wastedBytesByUrl
* @property {LH.Audit.Details.Opportunity['headings']} headings
* @property {LH.IcuMessage} [displayValue]
* @property {LH.IcuMessage} [explanation]
* @property {Array<string | LH.IcuMessage>} [warnings]
* @property {Array<string>} [sortedBy]
*/
/**
* @overview Used as the base for all byte efficiency audits. Computes total bytes
* and estimated time saved. Subclass and override `audit_` to return results.
*/
class ByteEfficiencyAudit extends Audit {
/**
* Creates a score based on the wastedMs value using log-normal distribution scoring. A negative
* wastedMs will be scored as 1, assuming time is not being wasted with respect to the opportunity
* being measured.
*
* @param {number} wastedMs
* @return {number}
*/
static scoreForWastedMs(wastedMs) {
return Audit.computeLogNormalScore(
{p10: WASTED_MS_P10, median: WASTED_MS_MEDIAN},
wastedMs
);
}
/**
* @param {LH.Artifacts} artifacts
* @param {LH.Audit.Context} context
* @return {Promise<LH.Audit.Product>}
*/
static async audit(artifacts, context) {
const gatherContext = artifacts.GatherContext;
const devtoolsLog = artifacts.DevtoolsLog;
const settings = context?.settings || {};
const simulatorOptions = {
devtoolsLog,
settings,
};
const networkRecords = await NetworkRecords.request(devtoolsLog, context);
const hasContentfulRecords = networkRecords.some(record => record.transferSize);
// Requesting load simulator requires non-empty network records.
// Timespans are not guaranteed to have any network activity.
// There are no bytes to be saved if no bytes were downloaded, so mark N/A if empty.
if (!hasContentfulRecords && gatherContext.gatherMode === 'timespan') {
return {
score: 1,
notApplicable: true,
};
}
const metricComputationInput = Audit.makeMetricComputationDataInput(artifacts, context);
const [result, simulator] = await Promise.all([
this.audit_(artifacts, networkRecords, context),
LoadSimulator.request(simulatorOptions, context),
]);
return this.createAuditProduct(result, simulator, metricComputationInput, context);
}
/**
* Computes the estimated effect of all the byte savings on the provided graph.
*
* @param {Array<LH.Audit.ByteEfficiencyItem>} results The array of byte savings results per resource
* @param {LH.Gatherer.Simulation.GraphNode} graph
* @param {LH.Gatherer.Simulation.Simulator} simulator
* @param {{label?: string, providedWastedBytesByUrl?: Map<string, number>}=} options
* @return {{savings: number, simulationBeforeChanges: LH.Gatherer.Simulation.Result, simulationAfterChanges: LH.Gatherer.Simulation.Result}}
*/
static computeWasteWithGraph(results, graph, simulator, options) {
options = Object.assign({label: ''}, options);
const beforeLabel = `${this.meta.id}-${options.label}-before`;
const afterLabel = `${this.meta.id}-${options.label}-after`;
const simulationBeforeChanges = simulator.simulate(graph, {label: beforeLabel});
const wastedBytesByUrl = options.providedWastedBytesByUrl || new Map();
if (!options.providedWastedBytesByUrl) {
for (const {url, wastedBytes} of results) {
wastedBytesByUrl.set(url, (wastedBytesByUrl.get(url) || 0) + wastedBytes);
}
}
// Update all the transfer sizes to reflect implementing our recommendations
/** @type {Map<string, number>} */
const originalTransferSizes = new Map();
graph.traverse(node => {
if (node.type !== 'network') return;
const wastedBytes = wastedBytesByUrl.get(node.request.url);
if (!wastedBytes) return;
const original = node.request.transferSize;
originalTransferSizes.set(node.request.requestId, original);
node.request.transferSize = Math.max(original - wastedBytes, 0);
});
const simulationAfterChanges = simulator.simulate(graph, {label: afterLabel});
// Restore the original transfer size after we've done our simulation
graph.traverse(node => {
if (node.type !== 'network') return;
const originalTransferSize = originalTransferSizes.get(node.request.requestId);
if (originalTransferSize === undefined) return;
node.request.transferSize = originalTransferSize;
});
const savings = simulationBeforeChanges.timeInMs - simulationAfterChanges.timeInMs;
return {
// Round waste to nearest 10ms
savings: Math.round(Math.max(savings, 0) / 10) * 10,
simulationBeforeChanges,
simulationAfterChanges,
};
}
/**
* @param {ByteEfficiencyProduct} result
* @param {LH.Gatherer.Simulation.Simulator} simulator
* @param {LH.Artifacts.MetricComputationDataInput} metricComputationInput
* @param {LH.Audit.Context} context
* @return {Promise<LH.Audit.Product>}
*/
static async createAuditProduct(result, simulator, metricComputationInput, context) {
const results = result.items.sort((itemA, itemB) => itemB.wastedBytes - itemA.wastedBytes);
const wastedBytes = results.reduce((sum, item) => sum + item.wastedBytes, 0);
/** @type {LH.Audit.ProductMetricSavings} */
const metricSavings = {
FCP: 0,
LCP: 0,
};
// `wastedMs` may be negative, if making the opportunity change could be detrimental.
// This is useful information in the LHR and should be preserved.
let wastedMs;
if (metricComputationInput.gatherContext.gatherMode === 'navigation') {
const optimisticFCPGraph = (await LanternFCP.request(metricComputationInput, context))
.optimisticGraph;
const optimisticLCPGraph = (await LanternLCP.request(metricComputationInput, context))
.optimisticGraph;
const {savings: fcpSavings} = this.computeWasteWithGraph(
results,
optimisticFCPGraph,
simulator,
{providedWastedBytesByUrl: result.wastedBytesByUrl, label: 'fcp'}
);
// Note: LCP's optimistic graph sometimes unexpectedly yields higher savings than the pessimistic graph.
const {savings: lcpGraphSavings} = this.computeWasteWithGraph(
results,
optimisticLCPGraph,
simulator,
{providedWastedBytesByUrl: result.wastedBytesByUrl, label: 'lcp'}
);
// The LCP graph can underestimate the LCP savings if there is potential savings on the LCP record itself.
let lcpRecordSavings = 0;
const lcpRecord = await LCPImageRecord.request(metricComputationInput, context);
if (lcpRecord) {
const lcpResult = results.find(result => result.url === lcpRecord.url);
if (lcpResult) {
lcpRecordSavings = simulator.computeWastedMsFromWastedBytes(lcpResult.wastedBytes);
}
}
metricSavings.FCP = fcpSavings;
metricSavings.LCP = Math.max(lcpGraphSavings, lcpRecordSavings);
wastedMs = metricSavings.LCP;
} else {
wastedMs = simulator.computeWastedMsFromWastedBytes(wastedBytes);
}
let displayValue = result.displayValue || '';
if (typeof result.displayValue === 'undefined' && wastedBytes) {
displayValue = str_(i18n.UIStrings.displayValueByteSavings, {wastedBytes});
}
const sortedBy = result.sortedBy || ['wastedBytes'];
const details = Audit.makeOpportunityDetails(result.headings, results,
{overallSavingsMs: wastedMs, overallSavingsBytes: wastedBytes, sortedBy});
// TODO: Remove from debug data once `metricSavings` is added to the LHR.
// For now, add it to debug data for visibility.
details.debugData = {
type: 'debugdata',
metricSavings,
};
return {
explanation: result.explanation,
warnings: result.warnings,
displayValue,
numericValue: wastedMs,
numericUnit: 'millisecond',
score: results.length ? 0 : 1,
details,
metricSavings,
};
}
/* eslint-disable no-unused-vars */
/**
* @param {LH.Artifacts} artifacts
* @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
* @param {LH.Audit.Context} context
* @return {ByteEfficiencyProduct|Promise<ByteEfficiencyProduct>}
*/
static audit_(artifacts, networkRecords, context) {
throw new Error('audit_ unimplemented');
}
/* eslint-enable no-unused-vars */
}
export {ByteEfficiencyAudit};