lighthouse
Version:
Automated auditing, performance metrics, and best practices for the web.
264 lines (231 loc) • 11.4 kB
JavaScript
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as Lantern from '../lib/lantern/lantern.js';
import UrlUtils from '../lib/url-utils.js';
import {NetworkRequest} from '../lib/network-request.js';
import {Audit} from './audit.js';
import {CriticalRequestChains} from '../computed/critical-request-chains.js';
import * as i18n from '../lib/i18n/i18n.js';
import {MainResource} from '../computed/main-resource.js';
import {PageDependencyGraph} from '../computed/page-dependency-graph.js';
import {LoadSimulator} from '../computed/load-simulator.js';
const UIStrings = {
/** Imperative title of a Lighthouse audit that tells the user to use <link rel=preload> to initiate important network requests earlier during page load. This is displayed in a list of audit titles that Lighthouse generates. */
title: 'Preload key requests',
/** Description of a Lighthouse audit that tells the user *why* they should preload important network requests. The associated network requests are started halfway through pageload (or later) but should be started at the beginning. This is displayed after a user expands the section to see more. No character length limits. '<link rel=preload>' is the html code the user would include in their page and shouldn't be translated. The last sentence starting with 'Learn' becomes link text to additional documentation. */
description: 'Consider using `<link rel=preload>` to prioritize fetching resources that are ' +
'currently requested later in page load. ' +
'[Learn how to preload key requests](https://developer.chrome.com/docs/lighthouse/performance/uses-rel-preload/).',
/**
* @description A warning message that is shown when the user tried to follow the advice of the audit, but it's not working as expected. Forgetting to set the `crossorigin` HTML attribute, or setting it to an incorrect value, on the link is a common mistake when adding preload links.
* @example {https://example.com} preloadURL
* */
crossoriginWarning: 'A preload `<link>` was found for "{preloadURL}" but was not used ' +
'by the browser. Check that you are using the `crossorigin` attribute properly.',
};
const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings);
const THRESHOLD_IN_MS = 100;
class UsesRelPreloadAudit extends Audit {
/**
* @return {LH.Audit.Meta}
*/
static get meta() {
return {
id: 'uses-rel-preload',
title: str_(UIStrings.title),
description: str_(UIStrings.description),
supportedModes: ['navigation'],
guidanceLevel: 3,
requiredArtifacts: ['DevtoolsLog', 'Trace', 'URL', 'SourceMaps'],
scoreDisplayMode: Audit.SCORING_MODES.METRIC_SAVINGS,
};
}
/**
* @param {LH.Artifacts.NetworkRequest} mainResource
* @param {LH.Gatherer.Simulation.GraphNode} graph
* @return {Set<string>}
*/
static getURLsToPreload(mainResource, graph) {
/** @type {Set<string>} */
const urls = new Set();
graph.traverse((node, traversalPath) => {
if (node.type !== 'network') return;
// Don't include the node itself or any CPU nodes in the initiatorPath
const path = traversalPath.slice(1).filter(initiator => initiator.type === 'network');
if (!UsesRelPreloadAudit.shouldPreloadRequest(node.request, mainResource, path)) return;
urls.add(node.request.url);
});
return urls;
}
/**
* Finds which URLs were attempted to be preloaded, but failed to be reused and were requested again.
*
* @param {LH.Gatherer.Simulation.GraphNode} graph
* @return {Set<string>}
*/
static getURLsFailedToPreload(graph) {
// TODO: add `fromPrefetchCache` to Lantern.Types.NetworkRequest, then use node.request here instead of rawRequest.
/** @type {Array<LH.Artifacts.NetworkRequest>} */
const requests = [];
graph.traverse(node => node.type === 'network' && requests.push(node.rawRequest));
const preloadRequests = requests.filter(req => req.isLinkPreload);
const preloadURLsByFrame = new Map();
for (const request of preloadRequests) {
const preloadURLs = preloadURLsByFrame.get(request.frameId) || new Set();
preloadURLs.add(request.url);
preloadURLsByFrame.set(request.frameId, preloadURLs);
}
// A failed preload attempt will manifest as a URL that was requested twice within the same frame.
// Once with `isLinkPreload` AND again without `isLinkPreload` but not hitting the cache.
const duplicateRequestsAfterPreload = requests.filter(request => {
const preloadURLsForFrame = preloadURLsByFrame.get(request.frameId);
if (!preloadURLsForFrame) return false;
if (!preloadURLsForFrame.has(request.url)) return false;
const fromCache = request.fromDiskCache ||
request.fromMemoryCache ||
request.fromPrefetchCache;
return !fromCache && !request.isLinkPreload;
});
return new Set(duplicateRequestsAfterPreload.map(req => req.url));
}
/**
* We want to preload all first party critical requests at depth 2.
* Third party requests can be tricky to know the URL ahead of time.
* Critical requests at depth 1 would already be identified by the browser for preloading.
* Critical requests deeper than depth 2 are more likely to be a case-by-case basis such that it
* would be a little risky to recommend blindly.
*
* @param {Lantern.Types.NetworkRequest} request
* @param {Lantern.Types.NetworkRequest} mainResource
* @param {Array<LH.Gatherer.Simulation.GraphNode>} initiatorPath
* @return {boolean}
*/
static shouldPreloadRequest(request, mainResource, initiatorPath) {
const mainResourceDepth = mainResource.redirects ? mainResource.redirects.length : 0;
// If it's already preloaded, no need to recommend it.
if (request.isLinkPreload) return false;
// It's not critical, don't recommend it.
if (!CriticalRequestChains.isCritical(request, mainResource)) return false;
// It's not a request loaded over the network, don't recommend it.
if (NetworkRequest.isNonNetworkRequest(request)) return false;
// It's not at the right depth, don't recommend it.
if (initiatorPath.length !== mainResourceDepth + 2) return false;
// It's not a request for the main frame, it wouldn't get reused even if you did preload it.
if (request.frameId !== mainResource.frameId) return false;
// We survived everything else, just check that it's a first party request.
return UrlUtils.rootDomainsMatch(request.url, mainResource.url);
}
/**
* Computes the estimated effect of preloading all the resources.
* @param {Set<string>} urls The array of byte savings results per resource
* @param {LH.Gatherer.Simulation.GraphNode} graph
* @param {LH.Gatherer.Simulation.Simulator} simulator
* @return {{wastedMs: number, results: Array<{url: string, wastedMs: number}>}}
*/
static computeWasteWithGraph(urls, graph, simulator) {
if (!urls.size) {
return {wastedMs: 0, results: []};
}
// Preload changes the ordering of requests, simulate the original graph
// to have a reasonable baseline for comparison.
const simulationBeforeChanges = simulator.simulate(graph);
const modifiedGraph = graph.cloneWithRelationships();
/** @type {Array<LH.Gatherer.Simulation.GraphNetworkNode>} */
const nodesToPreload = [];
/** @type {LH.Gatherer.Simulation.GraphNode|null} */
let mainDocumentNode = null;
modifiedGraph.traverse(node => {
if (node.type !== 'network') return;
if (node.isMainDocument()) {
mainDocumentNode = node;
} else if (node.request && urls.has(node.request.url)) {
nodesToPreload.push(node);
}
});
if (!mainDocumentNode) {
// Should always find the main document node
throw new Error('Could not find main document node');
}
// Preload has the effect of moving the resource's only dependency to the main HTML document
// Remove all dependencies of the nodes
for (const node of nodesToPreload) {
node.removeAllDependencies();
node.addDependency(mainDocumentNode);
}
// Once we've modified the dependencies, simulate the new graph.
const simulationAfterChanges = simulator.simulate(modifiedGraph);
const originalNodesByRequest = Array.from(simulationBeforeChanges.nodeTimings.keys())
// @ts-expect-error we don't care if all nodes without a request collect on `undefined`
.reduce((map, node) => map.set(node.request, node), new Map());
const results = [];
for (const node of nodesToPreload) {
const originalNode = originalNodesByRequest.get(node.request);
const timingAfter = simulationAfterChanges.nodeTimings.get(node);
const timingBefore = simulationBeforeChanges.nodeTimings.get(originalNode);
if (!timingBefore || !timingAfter) throw new Error('Missing preload node');
const wastedMs = Math.round(timingBefore.endTime - timingAfter.endTime);
if (wastedMs < THRESHOLD_IN_MS) continue;
results.push({url: node.request.url, wastedMs});
}
if (!results.length) {
return {wastedMs: 0, results};
}
return {
// Preload won't necessarily impact the deepest chain/overall time
// We'll use the maximum endTime improvement for now
wastedMs: Math.max(...results.map(item => item.wastedMs)),
results,
};
}
/**
* @param {LH.Artifacts} artifacts
* @param {LH.Audit.Context} context
* @return {Promise<LH.Audit.Product>}
*/
static async audit(artifacts, context) {
const settings = context.settings;
const trace = artifacts.Trace;
const devtoolsLog = artifacts.DevtoolsLog;
const {URL, SourceMaps} = artifacts;
const simulatorOptions = {devtoolsLog, settings: context.settings};
const [mainResource, graph, simulator] = await Promise.all([
MainResource.request({devtoolsLog, URL}, context),
PageDependencyGraph.request(
{settings, trace, devtoolsLog, URL, SourceMaps, fromTrace: false}, context),
LoadSimulator.request(simulatorOptions, context),
]);
const urls = UsesRelPreloadAudit.getURLsToPreload(mainResource, graph);
const {results, wastedMs} = UsesRelPreloadAudit.computeWasteWithGraph(urls, graph, simulator);
// sort results by wastedTime DESC
results.sort((a, b) => b.wastedMs - a.wastedMs);
/** @type {Array<LH.IcuMessage>|undefined} */
let warnings;
const failedURLs = UsesRelPreloadAudit.getURLsFailedToPreload(graph);
if (failedURLs.size) {
warnings = Array.from(failedURLs)
.map(preloadURL => str_(UIStrings.crossoriginWarning, {preloadURL}));
}
/** @type {LH.Audit.Details.Opportunity['headings']} */
const headings = [
{key: 'url', valueType: 'url', label: str_(i18n.UIStrings.columnURL)},
{key: 'wastedMs', valueType: 'timespanMs', label: str_(i18n.UIStrings.columnWastedMs)},
];
const details = Audit.makeOpportunityDetails(headings, results,
{overallSavingsMs: wastedMs, sortedBy: ['wastedMs']});
return {
score: results.length ? 0 : 1,
numericValue: wastedMs,
numericUnit: 'millisecond',
displayValue: wastedMs ?
str_(i18n.UIStrings.displayValueMsSavings, {wastedMs}) :
'',
details,
warnings,
};
}
}
export default UsesRelPreloadAudit;
export {UIStrings};