-
Notifications
You must be signed in to change notification settings - Fork 9.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
new_audit: preload-lcp-image #11486
new_audit: preload-lcp-image #11486
Changes from all commits
1d26627
9d7e25b
c16cb78
ea3b50d
e71ae83
944a0c2
8d44edd
5991c60
2bdc94d
aabf8c7
eb79c5b
1060477
eb0cfc6
ed5d5c2
b16b9b4
136dd71
9a65182
47f237f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,176 @@ | ||
/** | ||
* @license Copyright 2020 The Lighthouse Authors. 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 URL = require('../lib/url-shim.js'); | ||
const Audit = require('./audit.js'); | ||
const i18n = require('../lib/i18n/i18n.js'); | ||
const MainResource = require('../computed/main-resource.js'); | ||
const LanternLCP = require('../computed/metrics/lantern-largest-contentful-paint.js'); | ||
const LoadSimulator = require('../computed/load-simulator.js'); | ||
const UnusedBytes = require('./byte-efficiency/byte-efficiency-audit.js'); | ||
|
||
const UIStrings = { | ||
/** Title of a lighthouse audit that tells a user to preload an image in order to improve their LCP time. */ | ||
title: 'Preload Largest Contentful Paint image', | ||
/** Description of a lighthouse audit that tells a user to preload an image in order to improve their LCP time. */ | ||
description: 'Preload the image used by ' + | ||
'the LCP element in order to improve your LCP time. [Learn more](https://web.dev/optimize-lcp/#preload-important-resources).', | ||
}; | ||
|
||
const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings); | ||
|
||
class PreloadLCPImageAudit extends Audit { | ||
/** | ||
* @return {LH.Audit.Meta} | ||
*/ | ||
static get meta() { | ||
return { | ||
id: 'preload-lcp-image', | ||
title: str_(UIStrings.title), | ||
description: str_(UIStrings.description), | ||
requiredArtifacts: ['traces', 'devtoolsLogs', 'URL', 'TraceElements', 'ImageElements'], | ||
scoreDisplayMode: Audit.SCORING_MODES.NUMERIC, | ||
}; | ||
} | ||
|
||
/** | ||
* | ||
* @param {LH.Artifacts.NetworkRequest} request | ||
* @param {LH.Artifacts.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 a request loaded over the network, don't recommend it. | ||
if (URL.NON_NETWORK_PROTOCOLS.includes(request.protocol)) return false; | ||
// It's already discoverable from the main document, don't recommend it. | ||
if (initiatorPath.length <= mainResourceDepth) return false; | ||
// Finally, return whether or not it belongs to the main frame | ||
return request.frameId === mainResource.frameId; | ||
} | ||
|
||
/** | ||
* @param {LH.Gatherer.Simulation.GraphNode} graph | ||
* @param {string} imageUrl | ||
* @return {{lcpNode: LH.Gatherer.Simulation.GraphNetworkNode|undefined, path: Array<LH.Gatherer.Simulation.GraphNetworkNode>|undefined}} | ||
*/ | ||
static findLCPNode(graph, imageUrl) { | ||
let lcpNode; | ||
let path; | ||
graph.traverse((node, traversalPath) => { | ||
if (node.type !== 'network') return; | ||
if (node.record.url === imageUrl) { | ||
lcpNode = node; | ||
path = | ||
traversalPath.slice(1).filter(initiator => initiator.type === 'network'); | ||
} | ||
}); | ||
return { | ||
lcpNode, | ||
path, | ||
}; | ||
} | ||
|
||
/** | ||
* @param {LH.Artifacts.NetworkRequest} mainResource | ||
* @param {LH.Gatherer.Simulation.GraphNode} graph | ||
* @param {LH.Artifacts.TraceElement|undefined} lcpElement | ||
* @param {Array<LH.Artifacts.ImageElement>} imageElements | ||
* @return {LH.Gatherer.Simulation.GraphNetworkNode|undefined} | ||
*/ | ||
static getLCPNodeToPreload(mainResource, graph, lcpElement, imageElements) { | ||
if (!lcpElement) return undefined; | ||
|
||
const lcpImageElement = imageElements.find(elem => { | ||
return elem.devtoolsNodePath === lcpElement.devtoolsNodePath; | ||
}); | ||
|
||
if (!lcpImageElement) return undefined; | ||
const lcpUrl = lcpImageElement.src; | ||
const {lcpNode, path} = PreloadLCPImageAudit.findLCPNode(graph, lcpUrl); | ||
if (!lcpNode || !path) return undefined; | ||
// eslint-disable-next-line max-len | ||
const shouldPreload = PreloadLCPImageAudit.shouldPreloadRequest(lcpNode.record, mainResource, path); | ||
return shouldPreload ? lcpNode : undefined; | ||
} | ||
|
||
/** | ||
* Computes the estimated effect of preloading the LCP image. | ||
* @param {LH.Gatherer.Simulation.GraphNetworkNode} lcpNode | ||
* @param {LH.Gatherer.Simulation.GraphNode} graph | ||
* @param {LH.Gatherer.Simulation.Simulator} simulator | ||
* @return {{wastedMs: number, results: Array<{url: string, wastedMs: number}>}} | ||
*/ | ||
static computeWasteWithGraph(lcpNode, graph, simulator) { | ||
const simulation = simulator.simulate(graph, {flexibleOrdering: true}); | ||
// For now, we are simply using the duration of the LCP image request as the wastedMS value | ||
const lcpTiming = simulation.nodeTimings.get(lcpNode); | ||
const wastedMs = lcpTiming && lcpTiming.duration || 0; | ||
return { | ||
wastedMs, | ||
results: [{ | ||
url: lcpNode.record.url, | ||
wastedMs, | ||
}], | ||
}; | ||
} | ||
|
||
/** | ||
* @param {LH.Artifacts} artifacts | ||
* @param {LH.Audit.Context} context | ||
* @return {Promise<LH.Audit.Product>} | ||
*/ | ||
static async audit(artifacts, context) { | ||
const trace = artifacts.traces[PreloadLCPImageAudit.DEFAULT_PASS]; | ||
const devtoolsLog = artifacts.devtoolsLogs[PreloadLCPImageAudit.DEFAULT_PASS]; | ||
const URL = artifacts.URL; | ||
const simulatorOptions = {trace, devtoolsLog, settings: context.settings}; | ||
const lcpElement = artifacts.TraceElements | ||
.find(element => element.traceEventType === 'largest-contentful-paint'); | ||
|
||
const [mainResource, lanternLCP, simulator] = await Promise.all([ | ||
MainResource.request({devtoolsLog, URL}, context), | ||
LanternLCP.request(simulatorOptions, context), | ||
LoadSimulator.request(simulatorOptions, context), | ||
]); | ||
|
||
const graph = lanternLCP.pessimisticGraph; | ||
// eslint-disable-next-line max-len | ||
const lcpNode = PreloadLCPImageAudit.getLCPNodeToPreload(mainResource, graph, lcpElement, artifacts.ImageElements); | ||
if (!lcpNode) { | ||
return { | ||
score: 1, | ||
notApplicable: true, | ||
}; | ||
} | ||
|
||
const {results, wastedMs} = | ||
PreloadLCPImageAudit.computeWasteWithGraph(lcpNode, graph, simulator); | ||
|
||
/** @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, wastedMs); | ||
|
||
return { | ||
score: UnusedBytes.scoreForWastedMs(wastedMs), | ||
numericValue: wastedMs, | ||
numericUnit: 'millisecond', | ||
displayValue: wastedMs ? str_(i18n.UIStrings.displayValueMsSavings, {wastedMs}) : '', | ||
details, | ||
}; | ||
} | ||
} | ||
|
||
module.exports = PreloadLCPImageAudit; | ||
module.exports.UIStrings = UIStrings; |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -398,7 +398,7 @@ function getNodeLabel(node) { | |
|
||
/** | ||
* @param {HTMLElement} element | ||
* @param {LH.Artifacts.Rect} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nice find! I think was supposed to be a |
||
* @return {LH.Artifacts.Rect} | ||
*/ | ||
/* istanbul ignore next */ | ||
function getBoundingClientRect(element) { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
/** | ||
* @license Copyright 2020 The Lighthouse Authors. 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 jest */ | ||
|
||
const PreloadLCPImage = require('../../audits/preload-lcp-image.js'); | ||
const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js'); | ||
const createTestTrace = require('../create-test-trace.js'); | ||
|
||
const rootNodeUrl = 'http://example.com:3000'; | ||
const mainDocumentNodeUrl = 'http://www.example.com:3000'; | ||
const scriptNodeUrl = 'http://www.example.com/script.js'; | ||
const imageUrl = 'http://www.example.com/image.png'; | ||
|
||
describe('Performance: preload-lcp audit', () => { | ||
const mockArtifacts = (networkRecords, finalUrl, imageUrl) => { | ||
return { | ||
traces: { | ||
[PreloadLCPImage.DEFAULT_PASS]: createTestTrace({ | ||
traceEnd: 6e3, | ||
largestContentfulPaint: 45e2, | ||
}), | ||
}, | ||
devtoolsLogs: {[PreloadLCPImage.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)}, | ||
URL: {finalUrl}, | ||
TraceElements: [ | ||
{ | ||
traceEventType: 'largest-contentful-paint', | ||
devtoolsNodePath: '1,HTML,1,BODY,3,DIV,2,IMG', | ||
}, | ||
], | ||
ImageElements: [ | ||
{ | ||
src: imageUrl, | ||
devtoolsNodePath: '1,HTML,1,BODY,3,DIV,2,IMG', | ||
}, | ||
], | ||
}; | ||
}; | ||
|
||
const mockNetworkRecords = () => { | ||
return [ | ||
{ | ||
requestId: '2', | ||
priority: 'High', | ||
isLinkPreload: false, | ||
startTime: 0, | ||
endTime: 0.5, | ||
timing: {receiveHeadersEnd: 500}, | ||
url: rootNodeUrl, | ||
}, | ||
{ | ||
requestId: '2:redirect', | ||
resourceType: 'Document', | ||
priority: 'High', | ||
isLinkPreload: false, | ||
startTime: 0.5, | ||
endTime: 1, | ||
timing: {receiveHeadersEnd: 500}, | ||
url: mainDocumentNodeUrl, | ||
}, | ||
{ | ||
requestId: '3', | ||
resourceType: 'Script', | ||
priority: 'High', | ||
isLinkPreload: false, | ||
startTime: 1, | ||
endTime: 5, | ||
timing: {receiveHeadersEnd: 4000}, | ||
url: scriptNodeUrl, | ||
initiator: {type: 'parser', url: mainDocumentNodeUrl}, | ||
}, | ||
{ | ||
requestId: '4', | ||
resourceType: 'Image', | ||
priority: 'High', | ||
isLinkPreload: false, | ||
startTime: 2, | ||
endTime: 4.5, | ||
timing: {receiveHeadersEnd: 2500}, | ||
url: imageUrl, | ||
initiator: {type: 'script', url: scriptNodeUrl}, | ||
}, | ||
]; | ||
}; | ||
|
||
it('should suggest preloading a lcp image', async () => { | ||
const networkRecords = mockNetworkRecords(); | ||
const artifacts = mockArtifacts(networkRecords, mainDocumentNodeUrl, imageUrl); | ||
const context = {settings: {}, computedCache: new Map()}; | ||
const results = await PreloadLCPImage.audit(artifacts, context); | ||
expect(results.numericValue).toEqual(180); | ||
expect(results.details.overallSavingsMs).toEqual(180); | ||
expect(results.details.items[0].url).toEqual(imageUrl); | ||
expect(results.details.items[0].wastedMs).toEqual(180); | ||
}); | ||
|
||
it('shouldn\'t be applicable if lcp image is not found', async () => { | ||
const networkRecords = mockNetworkRecords(); | ||
const artifacts = mockArtifacts(networkRecords, mainDocumentNodeUrl, imageUrl); | ||
artifacts.ImageElements = []; | ||
const context = {settings: {}, computedCache: new Map()}; | ||
const results = await PreloadLCPImage.audit(artifacts, context); | ||
expect(results.score).toEqual(1); | ||
expect(results.notApplicable).toBeTruthy(); | ||
expect(results.details).toBeUndefined(); | ||
}); | ||
|
||
it('shouldn\'t be applicable if the lcp is already preloaded', async () => { | ||
const networkRecords = mockNetworkRecords(); | ||
networkRecords[3].isLinkPreload = true; | ||
const artifacts = mockArtifacts(networkRecords, mainDocumentNodeUrl, imageUrl); | ||
const context = {settings: {}, computedCache: new Map()}; | ||
const results = await PreloadLCPImage.audit(artifacts, context); | ||
expect(results.score).toEqual(1); | ||
expect(results.notApplicable).toBeTruthy(); | ||
expect(results.details).toBeUndefined(); | ||
}); | ||
|
||
it('shouldn\'t be applicable if the lcp request is not from over the network', async () => { | ||
const networkRecords = mockNetworkRecords(); | ||
networkRecords[3].protocol = 'data'; | ||
const artifacts = mockArtifacts(networkRecords, mainDocumentNodeUrl, imageUrl); | ||
const context = {settings: {}, computedCache: new Map()}; | ||
const results = await PreloadLCPImage.audit(artifacts, context); | ||
expect(results.score).toEqual(1); | ||
expect(results.notApplicable).toBeTruthy(); | ||
expect(results.details).toBeUndefined(); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sitting on it for a while now, I don't love this audit title and ID, but I don't really have concrete ideas for improving it right away.
My thinking is that it feels a bit weird to use the metric name as an adjective for this image when the metric is based on other adjectives of the image that are more easily understood 😆
(conceptually I think we could do this audit for any very large hero-type image, not just the LCP one if text was LCP for example. In that case I might suggest
Preload critical images
even if we only target LCP to start)It's all experimental for now though, so shouldn't be a blocker, maybe add a todo to finalize this decision or update the issue (is there one?) to include it as a final check?