-
Notifications
You must be signed in to change notification settings - Fork 9.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
DBW: Responsive Images Audit (#1497)
- Loading branch information
1 parent
a17238a
commit bf964de
Showing
8 changed files
with
420 additions
and
6 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
139 changes: 139 additions & 0 deletions
139
lighthouse-core/audits/dobetterweb/uses-responsive-images.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,139 @@ | ||
/** | ||
* @license | ||
* Copyright 2017 Google Inc. 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. | ||
*/ | ||
/** | ||
* @fileoverview Checks to see if the images used on the page are larger than | ||
* their display sizes. The audit will list all images that are larger than | ||
* their display size regardless of DPR (a 1000px wide image displayed as a | ||
* 500px high-res image on a Retina display will show up as 75% unused); | ||
* however, the audit will only fail pages that use images that have waste | ||
* when computed with DPR taken into account. | ||
*/ | ||
'use strict'; | ||
|
||
const Audit = require('../audit'); | ||
const URL = require('../../lib/url-shim'); | ||
const Formatter = require('../../formatters/formatter'); | ||
|
||
const KB_IN_BYTES = 1024; | ||
const WASTEFUL_THRESHOLD_AS_RATIO = 0.1; | ||
|
||
class UsesResponsiveImages extends Audit { | ||
/** | ||
* @return {!AuditMeta} | ||
*/ | ||
static get meta() { | ||
return { | ||
category: 'Images', | ||
name: 'uses-responsive-images', | ||
description: 'Site uses appropriate image sizes', | ||
helpText: | ||
'Image sizes served should be based on the device display size to save network bytes. ' + | ||
'Learn more about [responsive images](https://developers.google.com/web/fundamentals/design-and-ui/media/images) ' + | ||
'and [client hints](https://developers.google.com/web/updates/2015/09/automating-resource-selection-with-client-hints).', | ||
requiredArtifacts: ['ImageUsage', 'ContentWidth'] | ||
}; | ||
} | ||
|
||
/** | ||
* @param {!Object} image | ||
* @param {number} DPR devicePixelRatio | ||
* @return {?Object} | ||
*/ | ||
static computeWaste(image, DPR) { | ||
const url = URL.getDisplayName(image.src); | ||
const actualPixels = image.naturalWidth * image.naturalHeight; | ||
const usedPixels = image.clientWidth * image.clientHeight; | ||
const usedPixelsFullDPR = usedPixels * Math.pow(DPR, 2); | ||
const wastedRatio = 1 - (usedPixels / actualPixels); | ||
const wastedRatioFullDPR = 1 - (usedPixelsFullDPR / actualPixels); | ||
|
||
if (!Number.isFinite(wastedRatio)) { | ||
return new Error(`Invalid image sizing information ${url}`); | ||
} else if (wastedRatio <= 0) { | ||
// Image did not have sufficient resolution to fill display at DPR=1 | ||
return null; | ||
} | ||
|
||
// TODO(#1517): use an average transfer time for data URI images | ||
const size = image.networkRecord.resourceSize; | ||
const transferTimeInMs = 1000 * (image.networkRecord.endTime - | ||
image.networkRecord.responseReceivedTime); | ||
const wastedBytes = Math.round(size * wastedRatio); | ||
const wastedTime = Math.round(transferTimeInMs * wastedRatio); | ||
const percentSavings = Math.round(100 * wastedRatio); | ||
const label = `${Math.round(size / KB_IN_BYTES)}KB total, ${percentSavings}% potential savings`; | ||
|
||
return { | ||
wastedBytes, | ||
wastedTime, | ||
isWasteful: wastedRatioFullDPR > WASTEFUL_THRESHOLD_AS_RATIO, | ||
result: {url, label}, | ||
}; | ||
} | ||
|
||
/** | ||
* @param {!Artifacts} artifacts | ||
* @return {!AuditResult} | ||
*/ | ||
static audit(artifacts) { | ||
const images = artifacts.ImageUsage; | ||
const contentWidth = artifacts.ContentWidth; | ||
|
||
let debugString; | ||
let totalWastedBytes = 0; | ||
let totalWastedTime = 0; | ||
let hasWastefulImage = false; | ||
const DPR = contentWidth.devicePixelRatio; | ||
const results = images.reduce((results, image) => { | ||
if (!image.networkRecord) { | ||
return results; | ||
} | ||
|
||
const processed = UsesResponsiveImages.computeWaste(image, DPR); | ||
if (!processed) { | ||
return results; | ||
} else if (processed instanceof Error) { | ||
debugString = processed.message; | ||
return results; | ||
} | ||
|
||
hasWastefulImage = hasWastefulImage || processed.isWasteful; | ||
totalWastedTime += processed.wastedTime; | ||
totalWastedBytes += processed.wastedBytes; | ||
results.push(processed.result); | ||
return results; | ||
}, []); | ||
|
||
let displayValue; | ||
if (results.length) { | ||
const totalWastedKB = Math.round(totalWastedBytes / KB_IN_BYTES); | ||
displayValue = `${totalWastedKB}KB (~${totalWastedTime}ms) potential savings`; | ||
} | ||
|
||
return UsesResponsiveImages.generateAuditResult({ | ||
debugString, | ||
displayValue, | ||
rawValue: !hasWastefulImage, | ||
extendedInfo: { | ||
formatter: Formatter.SUPPORTED_FORMATS.URLLIST, | ||
value: results | ||
} | ||
}); | ||
} | ||
} | ||
|
||
module.exports = UsesResponsiveImages; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
/** | ||
* @license | ||
* Copyright 2017 Google Inc. 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. | ||
*/ | ||
|
||
/** | ||
* @fileoverview Gathers all images used on the page with their src, size, | ||
* and attribute information. Executes script in the context of the page. | ||
*/ | ||
'use strict'; | ||
|
||
const Gatherer = require('./gatherer'); | ||
|
||
/* global document, Image */ | ||
|
||
/* istanbul ignore next */ | ||
function collectImageElementInfo() { | ||
return [...document.querySelectorAll('img')].map(element => { | ||
return { | ||
// currentSrc used over src to get the url as determined by the browser | ||
// after taking into account srcset/media/sizes/etc. | ||
src: element.currentSrc, | ||
clientWidth: element.clientWidth, | ||
clientHeight: element.clientHeight, | ||
naturalWidth: element.naturalWidth, | ||
naturalHeight: element.naturalHeight, | ||
isPicture: element.parentElement.tagName === 'PICTURE', | ||
}; | ||
}); | ||
} | ||
|
||
/* istanbul ignore next */ | ||
function determineNaturalSize(url) { | ||
return new Promise((resolve, reject) => { | ||
const img = new Image(); | ||
img.addEventListener('error', reject); | ||
img.addEventListener('load', () => { | ||
resolve({ | ||
naturalWidth: img.naturalWidth, | ||
naturalHeight: img.naturalHeight | ||
}); | ||
}); | ||
|
||
img.src = url; | ||
}); | ||
} | ||
|
||
class ImageUsage extends Gatherer { | ||
|
||
/** | ||
* @param {{src: string}} element | ||
* @return {!Promise<!Object>} | ||
*/ | ||
fetchElementWithSizeInformation(element) { | ||
const url = JSON.stringify(element.src); | ||
return this.driver.evaluateAsync(`(${determineNaturalSize.toString()})(${url})`) | ||
.then(size => { | ||
return Object.assign(element, size); | ||
}); | ||
} | ||
|
||
afterPass(options, traceData) { | ||
const driver = this.driver = options.driver; | ||
const indexedNetworkRecords = traceData.networkRecords.reduce((map, record) => { | ||
if (/^image/.test(record._mimeType)) { | ||
map[record._url] = { | ||
url: record.url, | ||
resourceSize: record.resourceSize, | ||
startTime: record.startTime, | ||
endTime: record.endTime, | ||
responseReceivedTime: record.responseReceivedTime | ||
}; | ||
} | ||
|
||
return map; | ||
}, {}); | ||
|
||
return driver.evaluateAsync(`(${collectImageElementInfo.toString()})()`) | ||
.then(elements => { | ||
return elements.reduce((promise, element) => { | ||
return promise.then(collector => { | ||
// link up the image with its network record | ||
element.networkRecord = indexedNetworkRecords[element.src]; | ||
|
||
// Images within `picture` behave strangely and natural size information | ||
// isn't accurate. Try to get the actual size if we can. | ||
const elementPromise = element.isPicture && element.networkRecord ? | ||
this.fetchElementWithSizeInformation(element) : | ||
Promise.resolve(element); | ||
|
||
return elementPromise.then(element => { | ||
collector.push(element); | ||
return collector; | ||
}); | ||
}); | ||
}, Promise.resolve([])); | ||
}); | ||
} | ||
} | ||
|
||
module.exports = ImageUsage; |
Oops, something went wrong.