/
offscreen-images.js
239 lines (210 loc) · 10.3 KB
/
offscreen-images.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Checks to see if images are displayed only outside of the viewport.
* Images requested after TTI are not flagged as violations.
*/
import {ByteEfficiencyAudit} from './byte-efficiency-audit.js';
import {NetworkRequest} from '../../lib/network-request.js';
import {Sentry} from '../../lib/sentry.js';
import UrlUtils from '../../lib/url-utils.js';
import * as i18n from '../../lib/i18n/i18n.js';
import {Interactive} from '../../computed/metrics/interactive.js';
import {ProcessedTrace} from '../../computed/processed-trace.js';
const UIStrings = {
/** Imperative title of a Lighthouse audit that tells the user to defer loading offscreen images. Offscreen images are images located outside of the visible browser viewport. As they are unseen by the user and slow down page load, they should be loaded later, closer to when the user is going to see them. This is displayed in a list of audit titles that Lighthouse generates. */
title: 'Defer offscreen images',
/** Description of a Lighthouse audit that tells the user *why* they should defer loading offscreen images. Offscreen images are images located outside of the visible browser viewport. As they are unseen by the user and slow down page load, they should be loaded later, closer to when the user is going to see them. This is displayed after a user expands the section to see more. No character length limits. The last sentence starting with 'Learn' becomes link text to additional documentation. */
description:
'Consider lazy-loading offscreen and hidden images after all critical resources have ' +
'finished loading to lower time to interactive. ' +
'[Learn how to defer offscreen images](https://developer.chrome.com/docs/lighthouse/performance/offscreen-images/).',
};
const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings);
// See https://github.com/GoogleChrome/lighthouse/issues/10471 for discussion about the thresholds here.
const ALLOWABLE_OFFSCREEN_IN_PX = 100;
const ALLOWABLE_OFFSCREEN_BOTTOM_IN_VIEWPORTS = 3;
const IGNORE_THRESHOLD_IN_BYTES = 2048;
const IGNORE_THRESHOLD_IN_PERCENT = 75;
const IGNORE_THRESHOLD_IN_MS = 50;
/** @typedef {{node: LH.Audit.Details.NodeValue, url: string, requestStartTime: number, totalBytes: number, wastedBytes: number, wastedPercent: number}} WasteResult */
class OffscreenImages extends ByteEfficiencyAudit {
/**
* @return {LH.Audit.Meta}
*/
static get meta() {
return {
id: 'offscreen-images',
title: str_(UIStrings.title),
description: str_(UIStrings.description),
scoreDisplayMode: ByteEfficiencyAudit.SCORING_MODES.METRIC_SAVINGS,
supportedModes: ['navigation'],
guidanceLevel: 2,
requiredArtifacts: ['ImageElements', 'ViewportDimensions', 'GatherContext', 'devtoolsLogs',
'traces', 'URL'],
};
}
/**
* @param {{top: number, bottom: number, left: number, right: number}} imageRect
* @param {{innerWidth: number, innerHeight: number}} viewportDimensions
* @return {number}
*/
static computeVisiblePixels(imageRect, viewportDimensions) {
const innerWidth = viewportDimensions.innerWidth;
const innerHeight = viewportDimensions.innerHeight;
const allowableOffscreenBottomInPx = ALLOWABLE_OFFSCREEN_BOTTOM_IN_VIEWPORTS *
viewportDimensions.innerHeight;
const top = Math.max(imageRect.top, -1 * ALLOWABLE_OFFSCREEN_IN_PX);
const right = Math.min(imageRect.right, innerWidth + ALLOWABLE_OFFSCREEN_IN_PX);
const bottom = Math.min(imageRect.bottom, innerHeight + allowableOffscreenBottomInPx);
const left = Math.max(imageRect.left, -1 * ALLOWABLE_OFFSCREEN_IN_PX);
return Math.max(right - left, 0) * Math.max(bottom - top, 0);
}
/**
* @param {LH.Artifacts.ImageElement} image
* @param {{innerWidth: number, innerHeight: number}} viewportDimensions
* @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
* @return {null|Error|WasteResult}
*/
static computeWaste(image, viewportDimensions, networkRecords) {
const networkRecord = networkRecords.find(record => record.url === image.src);
// If we don't know how big it was, we can't really report savings, treat it as passed.
if (!networkRecord) return null;
// If the image had its loading behavior explicitly controlled already, treat it as passed.
if (image.loading === 'lazy' || image.loading === 'eager') return null;
const url = UrlUtils.elideDataURI(image.src);
const totalPixels = image.displayedWidth * image.displayedHeight;
const visiblePixels = this.computeVisiblePixels(image.clientRect, viewportDimensions);
// Treat images with 0 area as if they're offscreen. See https://github.com/GoogleChrome/lighthouse/issues/1914
const wastedRatio = totalPixels === 0 ? 1 : 1 - visiblePixels / totalPixels;
const totalBytes = NetworkRequest.getResourceSizeOnNetwork(networkRecord);
const wastedBytes = Math.round(totalBytes * wastedRatio);
if (!Number.isFinite(wastedRatio)) {
return new Error(`Invalid image sizing information ${url}`);
}
return {
node: ByteEfficiencyAudit.makeNodeItem(image.node),
url,
requestStartTime: networkRecord.networkRequestTime,
totalBytes,
wastedBytes,
wastedPercent: 100 * wastedRatio,
};
}
/**
* Filters out image requests that were requested after the last long task based on lantern timings.
*
* @param {WasteResult[]} images
* @param {LH.Artifacts.LanternMetric} lanternMetricData
*/
static filterLanternResults(images, lanternMetricData) {
const nodeTimings = lanternMetricData.pessimisticEstimate.nodeTimings;
// Find the last long task start time
let lastLongTaskStartTime = 0;
// Find the start time of all requests
/** @type {Map<string, number>} */
const startTimesByURL = new Map();
for (const [node, timing] of nodeTimings) {
if (node.type === 'cpu' && timing.duration >= 50) {
lastLongTaskStartTime = Math.max(lastLongTaskStartTime, timing.startTime);
} else if (node.type === 'network') {
startTimesByURL.set(node.record.url, timing.startTime);
}
}
return images.filter(image => {
// Filter out images that had little waste
if (image.wastedBytes < IGNORE_THRESHOLD_IN_BYTES) return false;
if (image.wastedPercent < IGNORE_THRESHOLD_IN_PERCENT) return false;
// Filter out images that started after the last long task
const imageRequestStartTime = startTimesByURL.get(image.url) || 0;
return imageRequestStartTime < lastLongTaskStartTime - IGNORE_THRESHOLD_IN_MS;
});
}
/**
* Filters out image requests that were requested after TTI.
*
* @param {WasteResult[]} images
* @param {number} interactiveTimestamp
*/
static filterObservedResults(images, interactiveTimestamp) {
return images.filter(image => {
if (image.wastedBytes < IGNORE_THRESHOLD_IN_BYTES) return false;
if (image.wastedPercent < IGNORE_THRESHOLD_IN_PERCENT) return false;
return image.requestStartTime < interactiveTimestamp / 1000 - IGNORE_THRESHOLD_IN_MS;
});
}
/**
* @param {LH.Artifacts} artifacts
* @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
* @param {LH.Audit.Context} context
* @return {Promise<import('./byte-efficiency-audit.js').ByteEfficiencyProduct>}
*/
static async audit_(artifacts, networkRecords, context) {
const images = artifacts.ImageElements;
const viewportDimensions = artifacts.ViewportDimensions;
const gatherContext = artifacts.GatherContext;
const trace = artifacts.traces[ByteEfficiencyAudit.DEFAULT_PASS];
const devtoolsLog = artifacts.devtoolsLogs[ByteEfficiencyAudit.DEFAULT_PASS];
const URL = artifacts.URL;
/** @type {string[]} */
const warnings = [];
/** @type {Map<string, WasteResult>} */
const resultsMap = new Map();
for (const image of images) {
const processed = OffscreenImages.computeWaste(image, viewportDimensions, networkRecords);
if (processed === null) {
continue;
}
if (processed instanceof Error) {
warnings.push(processed.message);
Sentry.captureException(processed, {tags: {audit: this.meta.id}, level: 'warning'});
continue;
}
// If an image was used more than once, warn only about its least wasteful usage
const existing = resultsMap.get(processed.url);
if (!existing || existing.wastedBytes > processed.wastedBytes) {
resultsMap.set(processed.url, processed);
}
}
const settings = context.settings;
let items;
const unfilteredResults = Array.from(resultsMap.values());
// get the interactive time or fallback to getting the end of trace time
try {
const metricComputationData = {trace, devtoolsLog, gatherContext, settings, URL};
const interactive = await Interactive.request(metricComputationData, context);
// use interactive to generate items
const lanternInteractive = /** @type {LH.Artifacts.LanternMetric} */ (interactive);
// Filter out images that were loaded after all CPU activity
items = context.settings.throttlingMethod === 'simulate' ?
OffscreenImages.filterLanternResults(unfilteredResults, lanternInteractive) :
// @ts-expect-error - .timestamp will exist if throttlingMethod isn't lantern
OffscreenImages.filterObservedResults(unfilteredResults, interactive.timestamp);
} catch (err) {
// if the error is during a Lantern run, end of trace may also be inaccurate, so rethrow
if (context.settings.throttlingMethod === 'simulate') {
throw err;
}
// use end of trace as a substitute for finding interactive time
items = OffscreenImages.filterObservedResults(unfilteredResults,
await ProcessedTrace.request(trace, context).then(tot => tot.timestamps.traceEnd));
}
/** @type {LH.Audit.Details.Opportunity['headings']} */
const headings = [
{key: 'node', valueType: 'node', label: ''},
{key: 'url', valueType: 'url', label: str_(i18n.UIStrings.columnURL)},
{key: 'totalBytes', valueType: 'bytes', label: str_(i18n.UIStrings.columnResourceSize)},
{key: 'wastedBytes', valueType: 'bytes', label: str_(i18n.UIStrings.columnWastedBytes)},
];
return {
warnings,
items,
headings,
};
}
}
export default OffscreenImages;
export {UIStrings};