Skip to content

Commit

Permalink
feat: add OffscreenImages Audit (#1807)
Browse files Browse the repository at this point in the history
* refactor: rename ContentWidth to ViewportDimensions
  • Loading branch information
patrickhulce authored and brendankenny committed Mar 23, 2017
1 parent bad5bda commit 902585b
Show file tree
Hide file tree
Showing 14 changed files with 393 additions and 51 deletions.
29 changes: 26 additions & 3 deletions lighthouse-cli/test/fixtures/byte-efficiency/tester.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@
style.appendChild(document.createTextNode(textContent));
document.head.appendChild(style);
}

// Lazily load the image
setTimeout(() => {
const template = document.getElementById('lazily-loaded-image');
document.body.appendChild(template.content.cloneNode(true));
}, 3000);
</script>
</head>

Expand All @@ -53,41 +59,56 @@ <h2>Byte efficiency tester page</h2>
<div class="images">
<!-- FAIL(optimized): image is not JPEG optimized -->
<!-- PASS(responsive): image is used at full size -->
<img src="lighthouse-unoptimized.jpg">
<!-- FAIL(offscreen): image is offscreen -->
<img style="position: absolute; top: -10000px;" src="lighthouse-unoptimized.jpg">

<!-- FAIL(optimized): image is not optimized -->
<!-- PASS(responsive): image is used at full size -->
<img src="http://localhost:10503/byte-efficiency/lighthouse-unoptimized.jpg">

<!-- PASSWARN(optimized): image is JPEG optimized but not WebP -->
<!-- FAIL(responsive): image is 25% used at DPR 2 -->
<!-- PASS(offscreen): image is onscreen -->
<img style="width: 256px; height: 170px;" src="lighthouse-1024x680.jpg">

<!-- PASSWARN(optimized): image is JPEG optimized but not WebP -->
<!-- PASS(responsive): image is fully used at DPR 2 -->
<!-- PASS(offscreen): image is onscreen -->
<img style="width: 240px; height: 160px;" src="lighthouse-480x320.jpg">

<!-- PASS(optimized): image has insignificant WebP savings -->
<!-- PASS(responsive): image is used at full size -->
<!-- PASS(offscreen): image is onscreen -->
<img src="lighthouse-320x212-poor.jpg">

<!-- PASS(optimized): image is fully optimized -->
<!-- PASSWARN(responsive): image is 25% used at DPR 2 (but small savings) -->
<img style="width: 120px; height: 80px;" src="lighthouse-480x320.webp">
<!-- FAIL(offscreen): image is offscreen -->
<img style="margin-top: 1000px; width: 120px; height: 80px;" src="lighthouse-480x320.webp">

<!-- PASS(optimized): image is vector -->
<!-- PASS(responsive): image is vector -->
<!-- FAIL(offscreen): image is offscreen -->
<img style="width: 100px; height: 100px;" src="large.svg">

<!-- PASS(optimized): image has insignificant WebP savings -->
<!-- PASS(responsive): image is later used at full size -->
<!-- PASS(offscreen): image is later used onscreen -->
<img style="width: 24px; height: 16px;"src="lighthouse-320x212-poor.jpg?duplicate">

<!-- PASS(optimized): image has insignificant WebP savings -->
<!-- PASS(responsive): image is used at full size -->
<img src="lighthouse-320x212-poor.jpg?duplicate">
<!-- PASS(offscreen): image is onscreen -->
<img style="position: absolute; top: 0; left: 0;" src="lighthouse-320x212-poor.jpg?duplicate">
</div>

<template id="lazily-loaded-image">
<!-- PASS(optimized): image is WebP -->
<!-- PASS(responsive): image is used at full size -->
<!-- PASS(offscreen): image is lazily loaded after TTI -->
<img style="position: absolute; top: -5000px;" src="lighthouse-480x320.webp?lazilyLoaded=true">
</template>

<script>
// PASS: unused but too small savings
generateInlineScriptWithSize(512, '.too-small { background: none; }\n');
Expand All @@ -99,5 +120,7 @@ <h2>Byte efficiency tester page</h2>
generateInlineScriptWithSize(24000, '.definitely-unused { background: none; }\n');
</script>

<!-- Ensure the page takes at least 5 seconds and we don't exit before the lazily loaded image -->
<script src="delay-complete.js?delay=5000"></script>
</body>
</html>
10 changes: 10 additions & 0 deletions lighthouse-cli/test/smokehouse/byte-efficiency/expectations.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ module.exports = [
}
}
},
'offscreen-images': {
score: false,
extendedInfo: {
value: {
results: {
length: 3
}
}
}
},
'uses-optimized-images': {
score: false,
extendedInfo: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ class UnusedBytes extends Audit {
static audit(artifacts) {
const networkRecords = artifacts.networkRecords[Audit.DEFAULT_PASS];
return artifacts.requestNetworkThroughput(networkRecords).then(networkThroughput => {
const result = this.audit_(artifacts, networkRecords);
return this.createAuditResult(result, networkThroughput);
return Promise.resolve(this.audit_(artifacts, networkRecords)).then(result => {
return this.createAuditResult(result, networkThroughput);
});
});
}

Expand Down
147 changes: 147 additions & 0 deletions lighthouse-core/audits/byte-efficiency/offscreen-images.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* @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 images are displayed only outside of the viewport.
* Images requested after TTI are not flagged as violations.
*/
'use strict';

const Audit = require('./byte-efficiency-audit');
const TTIAudit = require('../time-to-interactive');
const URL = require('../../lib/url-shim');

const ALLOWABLE_OFFSCREEN_X = 100;
const ALLOWABLE_OFFSCREEN_Y = 200;

const IGNORE_THRESHOLD_IN_BYTES = 2048;
const IGNORE_THRESHOLD_IN_PERCENT = 75;

class OffscreenImages extends Audit {
/**
* @return {!AuditMeta}
*/
static get meta() {
return {
category: 'Images',
name: 'offscreen-images',
description: 'Offscreen images',
helpText: 'Images that are not above the fold should be lazily loaded after the page is ' +
'interactive. Consider using the [IntersectionObserver](https://developers.google.com/web/updates/2016/04/intersectionobserver) API.',
requiredArtifacts: ['ImageUsage', 'ViewportDimensions', 'traces', 'networkRecords']
};
}

/**
* @param {!ClientRect} imageRect
* @param {{innerWidth: number, innerHeight: number}} viewportDimensions
* @return {number}
*/
static computeVisiblePixels(imageRect, viewportDimensions) {
const innerWidth = viewportDimensions.innerWidth;
const innerHeight = viewportDimensions.innerHeight;

const top = Math.max(imageRect.top, -1 * ALLOWABLE_OFFSCREEN_Y);
const right = Math.min(imageRect.right, innerWidth + ALLOWABLE_OFFSCREEN_X);
const bottom = Math.min(imageRect.bottom, innerHeight + ALLOWABLE_OFFSCREEN_Y);
const left = Math.max(imageRect.left, -1 * ALLOWABLE_OFFSCREEN_X);

return Math.max(right - left, 0) * Math.max(bottom - top, 0);
}

/**
* @param {!Object} image
* @param {{innerWidth: number, innerHeight: number}} viewportDimensions
* @return {?Object}
*/
static computeWaste(image, viewportDimensions) {
const url = URL.getDisplayName(image.src, {preserveQuery: true});
const totalPixels = image.clientWidth * image.clientHeight;
const visiblePixels = this.computeVisiblePixels(image.clientRect, viewportDimensions);
const wastedRatio = 1 - visiblePixels / totalPixels;
const totalBytes = image.networkRecord.resourceSize;
const wastedBytes = Math.round(totalBytes * wastedRatio);

if (!Number.isFinite(wastedRatio)) {
return new Error(`Invalid image sizing information ${url}`);
}

return {
url,
preview: {
url: image.networkRecord.url,
mimeType: image.networkRecord.mimeType
},
requestStartTime: image.networkRecord.startTime,
totalBytes,
wastedBytes,
wastedPercent: 100 * wastedRatio,
};
}

/**
* @param {!Artifacts} artifacts
* @return {{results: !Array<Object>, tableHeadings: Object,
* passes: boolean=, debugString: string=}}
*/
static audit_(artifacts) {
const images = artifacts.ImageUsage;
const viewportDimensions = artifacts.ViewportDimensions;

let debugString;
const resultsMap = images.reduce((results, image) => {
if (!image.networkRecord) {
return results;
}

const processed = OffscreenImages.computeWaste(image, viewportDimensions);
if (processed instanceof Error) {
debugString = processed.message;
return results;
}

// If an image was used more than once, warn only about its least wasteful usage
const existing = results.get(processed.preview.url);
if (!existing || existing.wastedBytes > processed.wastedBytes) {
results.set(processed.preview.url, processed);
}

return results;
}, new Map());

return TTIAudit.audit(artifacts).then(ttiResult => {
const ttiTimestamp = ttiResult.extendedInfo.value.timestamps.timeToInteractive / 1000000;
const results = Array.from(resultsMap.values()).filter(item => {
const isWasteful = item.wastedBytes > IGNORE_THRESHOLD_IN_BYTES &&
item.wastedPercent > IGNORE_THRESHOLD_IN_PERCENT;
const loadedEarly = item.requestStartTime < ttiTimestamp;
return isWasteful && loadedEarly;
});
return {
debugString,
results,
tableHeadings: {
preview: '',
url: 'URL',
totalKb: 'Original',
potentialSavings: 'Potential Savings',
}
};
});
}
}

module.exports = OffscreenImages;
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class UsesResponsiveImages extends Audit {
'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', 'networkRecords']
requiredArtifacts: ['ImageUsage', 'ViewportDimensions', 'networkRecords']
};
}

Expand Down Expand Up @@ -85,10 +85,9 @@ class UsesResponsiveImages extends Audit {
*/
static audit_(artifacts) {
const images = artifacts.ImageUsage;
const contentWidth = artifacts.ContentWidth;
const DPR = artifacts.ViewportDimensions.devicePixelRatio;

let debugString;
const DPR = contentWidth.devicePixelRatio;
const resultsMap = images.reduce((results, image) => {
// TODO: give SVG a free pass until a detail per pixel metric is available
if (!image.networkRecord || image.networkRecord.mimeType === 'image/svg+xml') {
Expand Down
14 changes: 7 additions & 7 deletions lighthouse-core/audits/content-width.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class ContentWidth extends Audit {
helpText: 'If the width of your app\'s content doesn\'t match the width ' +
'of the viewport, your app might not be optimized for mobile screens. ' +
'[Learn more](https://developers.google.com/web/tools/lighthouse/audits/content-sized-correctly-for-viewport).',
requiredArtifacts: ['ContentWidth']
requiredArtifacts: ['ViewportDimensions']
};
}

Expand All @@ -40,13 +40,13 @@ class ContentWidth extends Audit {
* @return {!AuditResult}
*/
static audit(artifacts) {
const scrollWidth = artifacts.ContentWidth.scrollWidth;
const viewportWidth = artifacts.ContentWidth.viewportWidth;
const widthsMatch = scrollWidth === viewportWidth;
const viewportWidth = artifacts.ViewportDimensions.innerWidth;
const windowWidth = artifacts.ViewportDimensions.outerWidth;
const widthsMatch = viewportWidth === windowWidth;

return {
rawValue: widthsMatch,
debugString: this.createDebugString(widthsMatch, artifacts.ContentWidth)
debugString: this.createDebugString(widthsMatch, artifacts.ViewportDimensions)
};
}

Expand All @@ -55,8 +55,8 @@ class ContentWidth extends Audit {
return '';
}

return 'The content scroll size is ' + artifact.scrollWidth + 'px, ' +
'whereas the viewport size is ' + artifact.viewportWidth + 'px.';
return 'The viewport size is ' + artifact.innerWidth + 'px, ' +
'whereas the window size is ' + artifact.outerWidth + 'px.';
}
}

Expand Down
4 changes: 2 additions & 2 deletions lighthouse-core/closure/typedefs/Artifacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ Artifacts.prototype.CriticalRequestChains;
/** @type {{first: number, complete: number, duration: number, frames: !Array<!Object>, debugString: (string|undefined)}} */
Artifacts.prototype.Speedline;

/** @type {{scrollWidth: number, viewportWidth: number}} */
Artifacts.prototype.ContentWidth;
/** @type {{innerWidth: number, outerWidth: number}} */
Artifacts.prototype.ViewportDimensions;

/** @type {!Array<string>} */
Artifacts.prototype.CacheContents;
Expand Down
9 changes: 7 additions & 2 deletions lighthouse-core/config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
"url",
"https",
"viewport",
"viewport-dimensions",
"theme-color",
"manifest",
"accessibility",
"image-usage",
"content-width"
"accessibility"
]
},
{
Expand Down Expand Up @@ -120,6 +120,7 @@
"accessibility/video-description",
"byte-efficiency/total-byte-weight",
"byte-efficiency/unused-css-rules",
"byte-efficiency/offscreen-images",
"byte-efficiency/uses-optimized-images",
"byte-efficiency/uses-responsive-images",
"dobetterweb/appcache-manifest",
Expand Down Expand Up @@ -579,6 +580,10 @@
"expectedValue": true,
"weight": 1
},
"offscreen-images": {
"expectedValue": true,
"weight": 1
},
"uses-optimized-images": {
"expectedValue": true,
"weight": 1
Expand Down
8 changes: 8 additions & 0 deletions lighthouse-core/gather/gatherers/image-usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,20 @@ const Gatherer = require('./gatherer');
/* istanbul ignore next */
function collectImageElementInfo() {
return [...document.querySelectorAll('img')].map(element => {
const clientRect = element.getBoundingClientRect();
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,
clientRect: {
// manually copy the properties because ClientRect does not JSONify
top: clientRect.top,
bottom: clientRect.bottom,
left: clientRect.left,
right: clientRect.right,
},
naturalWidth: element.naturalWidth,
naturalHeight: element.naturalHeight,
isPicture: element.parentElement.tagName === 'PICTURE',
Expand Down
Loading

0 comments on commit 902585b

Please sign in to comment.