Skip to content

Commit

Permalink
DBW: Responsive Images Audit (#1497)
Browse files Browse the repository at this point in the history
  • Loading branch information
patrickhulce authored and brendankenny committed Jan 25, 2017
1 parent a17238a commit bf964de
Show file tree
Hide file tree
Showing 8 changed files with 420 additions and 6 deletions.
19 changes: 18 additions & 1 deletion lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,14 @@ module.exports = [
'uses-optimized-images': {
score: false
},
'uses-responsive-images': {
score: false,
extendedInfo: {
value: {
length: 2
}
}
},
'deprecations': {
score: false,
extendedInfo: {
Expand Down
139 changes: 139 additions & 0 deletions lighthouse-core/audits/dobetterweb/uses-responsive-images.js
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;
5 changes: 4 additions & 1 deletion lighthouse-core/config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"theme-color",
"manifest",
"accessibility",
"image-usage",
"content-width"
]
},
Expand Down Expand Up @@ -96,6 +97,7 @@
"dobetterweb/script-blocking-first-paint",
"dobetterweb/uses-http2",
"dobetterweb/uses-optimized-images",
"dobetterweb/uses-responsive-images",
"dobetterweb/uses-passive-event-listeners"
],

Expand Down Expand Up @@ -294,7 +296,8 @@
"name": "Using bytes efficiently",
"audits": {
"unused-css-rules": {},
"uses-optimized-images": {}
"uses-optimized-images": {},
"uses-responsive-images": {}
}
}, {
"name": "Using modern CSS features",
Expand Down
10 changes: 7 additions & 3 deletions lighthouse-core/gather/gatherers/content-width.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ const Gatherer = require('./gatherer');
function getContentWidth() {
// window.innerWidth to get the scrollable size of the window (irrespective of zoom)
// window.outerWidth to get the size of the visible area
// window.devicePixelRatio to get ratio of logical pixels to physical pixels
return Promise.resolve({
scrollWidth: window.innerWidth,
viewportWidth: window.outerWidth
viewportWidth: window.outerWidth,
devicePixelRatio: window.devicePixelRatio,
});
}

Expand All @@ -39,15 +41,17 @@ class ContentWidth extends Gatherer {

.then(returnedValue => {
if (!Number.isFinite(returnedValue.scrollWidth) ||
!Number.isFinite(returnedValue.viewportWidth)) {
!Number.isFinite(returnedValue.viewportWidth) ||
!Number.isFinite(returnedValue.devicePixelRatio)) {
throw new Error(`ContentWidth results were not numeric: ${JSON.stringify(returnedValue)}`);
}

return returnedValue;
}, _ => {
return {
scrollWidth: -1,
viewportWidth: -1
viewportWidth: -1,
devicePixelRatio: -1,
};
});
}
Expand Down
113 changes: 113 additions & 0 deletions lighthouse-core/gather/gatherers/image-usage.js
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;
Loading

0 comments on commit bf964de

Please sign in to comment.