lighthouse
Version:
Automated auditing, performance metrics, and best practices for the web.
359 lines (330 loc) • 13.2 kB
JavaScript
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Checks to see if the size of the visible images used on
* the page are large enough with respect to the pixel ratio. The
* audit will list all visible images that are too small.
*/
import {Audit} from './audit.js';
import {ImageRecords} from '../computed/image-records.js';
import {NetworkRecords} from '../computed/network-records.js';
import UrlUtils from '../lib/url-utils.js';
import * as i18n from '../lib/i18n/i18n.js';
/** @typedef {LH.Artifacts.ImageElement & Required<Pick<LH.Artifacts.ImageElement, 'naturalDimensions'>>} ImageWithNaturalDimensions */
const UIStrings = {
/** Title of a Lighthouse audit that provides detail on the size of visible images on the page. This descriptive title is shown to users when all images have correct sizes. */
title: 'Serves images with appropriate resolution',
/** Title of a Lighthouse audit that provides detail on the size of visible images on the page. This descriptive title is shown to users when not all images have correct sizes. */
failureTitle: 'Serves images with low resolution',
/** Description of a Lighthouse audit that tells the user why they should maintain an appropriate size for all images. 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: 'Image natural dimensions should be proportional to the display size and the ' +
'pixel ratio to maximize image clarity. ' +
'[Learn how to provide responsive images](https://web.dev/articles/serve-responsive-images).',
/** Label for a column in a data table; entries in the column will be a string representing the displayed size of the image. */
columnDisplayed: 'Displayed size',
/** Label for a column in a data table; entries in the column will be a string representing the actual size of the image. */
columnActual: 'Actual size',
/** Label for a column in a data table; entries in the column will be a string representing the expected size of the image. */
columnExpected: 'Expected size',
};
const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings);
// Factors used to allow for smaller effective density.
// A factor of 1 means the actual device pixel density will be used.
// A factor of 0.5, means half the density is required. For example if the device pixel ratio is 3,
// then the images should have at least a density of 1.5.
const SMALL_IMAGE_FACTOR = 1.0;
const LARGE_IMAGE_FACTOR = 0.75;
// An image has must have both its dimensions lower or equal to the threshold in order to be
// considered SMALL.
const SMALL_IMAGE_THRESHOLD = 64;
/** @typedef {{url: string, node: LH.Audit.Details.NodeValue, displayedSize: string, actualSize: string, actualPixels: number, expectedSize: string, expectedPixels: number}} Result */
/**
* @param {{top: number, bottom: number, left: number, right: number}} imageRect
* @param {{innerWidth: number, innerHeight: number}} viewportDimensions
* @return {boolean}
*/
function isVisible(imageRect, viewportDimensions) {
return (
(imageRect.bottom - imageRect.top) * (imageRect.right - imageRect.left) > 0 &&
imageRect.top <= viewportDimensions.innerHeight &&
imageRect.bottom >= 0 &&
imageRect.left <= viewportDimensions.innerWidth &&
imageRect.right >= 0
);
}
/**
* @param {{top: number, bottom: number, left: number, right: number}} imageRect
* @param {{innerWidth: number, innerHeight: number}} viewportDimensions
* @return {boolean}
*/
function isSmallerThanViewport(imageRect, viewportDimensions) {
return (
(imageRect.bottom - imageRect.top) <= viewportDimensions.innerHeight &&
(imageRect.right - imageRect.left) <= viewportDimensions.innerWidth
);
}
/**
* @param {LH.Artifacts.ImageElement} image
* @param {LH.Artifacts.ImageElementRecord | undefined} imageRecord
* @return {boolean}
*/
function isCandidate(image, imageRecord) {
/** image-rendering solution for pixel art scaling.
* https://developer.mozilla.org/en-US/docs/Games/Techniques/Crisp_pixel_art_look
*/
const artisticImageRenderingValues = ['pixelated', 'crisp-edges'];
// https://html.spec.whatwg.org/multipage/images.html#pixel-density-descriptor
const densityDescriptorRegex = / \d+(\.\d+)?x/;
if (image.displayedWidth <= 1 || image.displayedHeight <= 1) {
return false;
}
if (
!image.naturalDimensions ||
!image.naturalDimensions.width ||
!image.naturalDimensions.height
) {
return false;
}
// Check the actual mimeType before guessing, since file extension is not guaranteed
if (imageRecord?.mimeType === 'image/svg+xml') {
return false;
}
if (UrlUtils.guessMimeType(image.src) === 'image/svg+xml') {
return false;
}
if (image.isCss) {
return false;
}
if (image.computedStyles.objectFit !== 'fill') {
return false;
}
// Check if pixel art scaling is used.
if (artisticImageRenderingValues.includes(image.computedStyles.imageRendering)) {
return false;
}
// Check if density descriptor is used.
if (densityDescriptorRegex.test(image.srcset)) {
return false;
}
return true;
}
/**
* Type check to ensure that the ImageElement has natural dimensions.
*
* @param {LH.Artifacts.ImageElement} image
* @return {image is ImageWithNaturalDimensions}
*/
function imageHasNaturalDimensions(image) {
return !!image.naturalDimensions;
}
/**
* @param {ImageWithNaturalDimensions} image
* @param {number} DPR
* @return {boolean}
*/
function imageHasRightSize(image, DPR) {
const [expectedWidth, expectedHeight] =
allowedImageSize(image.displayedWidth, image.displayedHeight, DPR);
return image.naturalDimensions.width >= expectedWidth &&
image.naturalDimensions.height >= expectedHeight;
}
/**
* @param {ImageWithNaturalDimensions} image
* @param {number} DPR
* @return {Result}
*/
function getResult(image, DPR) {
const [expectedWidth, expectedHeight] =
expectedImageSize(image.displayedWidth, image.displayedHeight, DPR);
return {
url: UrlUtils.elideDataURI(image.src),
node: Audit.makeNodeItem(image.node),
displayedSize: `${image.displayedWidth} x ${image.displayedHeight}`,
actualSize: `${image.naturalDimensions.width} x ${image.naturalDimensions.height}`,
actualPixels: image.naturalDimensions.width * image.naturalDimensions.height,
expectedSize: `${expectedWidth} x ${expectedHeight}`,
expectedPixels: expectedWidth * expectedHeight,
};
}
/**
* Compute the size an image should have given the display dimensions and pixel density in order to
* pass the audit.
*
* For smaller images, typically icons, the size must be proportional to the density.
* For larger images some tolerance is allowed as in those cases the perceived degradation is not
* that bad.
*
* @param {number} displayedWidth
* @param {number} displayedHeight
* @param {number} DPR
* @return {[number, number]}
*/
function allowedImageSize(displayedWidth, displayedHeight, DPR) {
let factor = SMALL_IMAGE_FACTOR;
if (displayedWidth > SMALL_IMAGE_THRESHOLD || displayedHeight > SMALL_IMAGE_THRESHOLD) {
factor = LARGE_IMAGE_FACTOR;
}
const requiredDpr = quantizeDpr(DPR);
const width = Math.ceil(factor * requiredDpr * displayedWidth);
const height = Math.ceil(factor * requiredDpr * displayedHeight);
return [width, height];
}
/**
* Compute the size an image should have given the display dimensions and pixel density.
*
* @param {number} displayedWidth
* @param {number} displayedHeight
* @param {number} DPR
* @return {[number, number]}
*/
function expectedImageSize(displayedWidth, displayedHeight, DPR) {
const width = Math.ceil(quantizeDpr(DPR) * displayedWidth);
const height = Math.ceil(quantizeDpr(DPR) * displayedHeight);
return [width, height];
}
/**
* Remove repeated entries for the same source.
*
* It will keep the entry with the largest expected size.
*
* @param {Result[]} results
* @return {Result[]}
*/
function deduplicateResultsByUrl(results) {
results.sort((a, b) => a.url === b.url ? 0 : (a.url < b. url ? -1 : 1));
/** @type {Result[]} */
const deduplicated = [];
for (const r of results) {
const previousResult = deduplicated[deduplicated.length - 1];
if (previousResult && previousResult.url === r.url) {
// If the URL was the same, this is a duplicate. Keep the largest image.
if (previousResult.expectedPixels < r.expectedPixels) {
deduplicated[deduplicated.length - 1] = r;
}
} else {
deduplicated.push(r);
}
}
return deduplicated;
}
/**
* Sort entries in descending order by the magnitude of the size deficit, i.e. most pressing issues listed first.
*
* @param {Result[]} results
* @return {Result[]}
*/
function sortResultsBySizeDelta(results) {
return results.sort(
(a, b) => (b.expectedPixels - b.actualPixels) - (a.expectedPixels - a.actualPixels));
}
class ImageSizeResponsive extends Audit {
/**
* @return {LH.Audit.Meta}
*/
static get meta() {
return {
id: 'image-size-responsive',
title: str_(UIStrings.title),
failureTitle: str_(UIStrings.failureTitle),
description: str_(UIStrings.description),
requiredArtifacts: ['ImageElements', 'ViewportDimensions'],
__internalOptionalArtifacts: ['DevtoolsLog'],
};
}
/**
* @param {LH.Artifacts} artifacts
* @param {LH.Audit.Context} context
* @return {Promise<LH.Audit.Product>}
*/
static async audit(artifacts, context) {
const DPR = artifacts.ViewportDimensions.devicePixelRatio;
// Prepare ImageElementRecord map for retrieving the real mimeType
// Derived from ./is-on-https.js and ./byte-efficiency/uses-responsive-images.js
/** @type {Map<string, LH.Artifacts.ImageElementRecord>} */
const imageRecordsByURL = new Map();
if (artifacts.DevtoolsLog) {
// https://github.com/GoogleChrome/lighthouse/blob/main/docs/plugins.md#using-network-requests
// if DevtoolsLog is provided, use it to fetch image networkRecords
// else the empty imageRecordsByURL map will satisfy isCandidate with an undefined image and fallback to the original logic
const networkRecords = await NetworkRecords.request(artifacts.DevtoolsLog, context);
const images = await ImageRecords.request({
ImageElements: artifacts.ImageElements,
networkRecords,
}, context);
images.forEach(img => imageRecordsByURL.set(img.src, img));
}
const results = Array
.from(artifacts.ImageElements)
.filter(image => isCandidate(image, imageRecordsByURL.get(image.src)))
.filter(imageHasNaturalDimensions)
.filter(image => !imageHasRightSize(image, DPR))
.filter(image => isVisible(image.clientRect, artifacts.ViewportDimensions))
.filter(image => isSmallerThanViewport(image.clientRect, artifacts.ViewportDimensions))
.map(image => getResult(image, DPR));
/** @type {LH.Audit.Details.Table['headings']} */
const headings = [
{key: 'node', valueType: 'node', label: ''},
{key: 'url', valueType: 'url', label: str_(i18n.UIStrings.columnURL)},
{key: 'displayedSize', valueType: 'text', label: str_(UIStrings.columnDisplayed)},
{key: 'actualSize', valueType: 'text', label: str_(UIStrings.columnActual)},
{key: 'expectedSize', valueType: 'text', label: str_(UIStrings.columnExpected)},
];
const finalResults = sortResultsBySizeDelta(deduplicateResultsByUrl(results));
return {
score: Number(results.length === 0),
details: Audit.makeTableDetails(headings, finalResults),
};
}
}
/**
* Return a quantized version of the DPR.
*
* This is to relax the required size of the image.
* There's strong evidence that 3 DPR images are not perceived to be significantly better to mobile users than
* 2 DPR images. The additional high byte cost (3x images are ~225% the file size of 2x images) makes this practice
* difficult to recommend.
*
* Human minimum visual acuity angle = 0.016 degrees (see Sun Microsystems paper)
* Typical phone operating distance from eye = 12 in
*
* A
* _
* \ | B
* \|
* θ
* A = minimum observable pixel size = ?
* B = viewing distance = 12 in
* θ = human minimum visual acuity angle = 0.016 degrees
*
* tan θ = A / B ---- Solve for A
* A = tan (0.016 degrees) * B = 0.00335 in
*
* Moto G4 display width = 2.7 in
* Moto G4 horizontal 2x resolution = 720 pixels
* Moto G4 horizontal 3x resolution = 1080 pixels
*
* Moto G4 1x pixel size = 2.7 / 360 = 0.0075 in
* Moto G4 2x pixel size = 2.7 / 720 = 0.00375 in
* Moto G4 3x pixel size = 2.7 / 1080 = 0.0025 in
*
* Wasted additional pixels in 3x image = (.00335 - .0025) / (.00375 - .0025) = 68% waste
*
*
* @see https://www.swift.ac.uk/about/files/vision.pdf
* @param {number} dpr
* @return {number}
*/
function quantizeDpr(dpr) {
if (dpr >= 2) {
return 2;
}
if (dpr >= 1.5) {
return 1.5;
}
return 1.0;
}
export default ImageSizeResponsive;
export {UIStrings};