Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add OffscreenImages Audit #1807

Merged
merged 15 commits into from
Mar 23, 2017
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions lighthouse-cli/test/fixtures/byte-efficiency/tester.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,35 +53,43 @@ <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(unused): image is offscreen -->
<img style="position: absolute; top: -10000px;" src="lighthouse-unoptimized.jpg">

<!-- PASSWARN(optimized): image is JPEG optimized but not WebP -->
<!-- FAIL(responsive): image is 25% used at DPR 2 -->
<!-- PASS(unused): 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(unused): 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(unused): 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(unused): 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(unused): 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(unused): 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(unused): image is onscreen -->
<img style="position: absolute; top: 0; left: 0;" src="lighthouse-320x212-poor.jpg?duplicate">
</div>

<script>
Expand Down
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
136 changes: 136 additions & 0 deletions lighthouse-core/audits/byte-efficiency/offscreen-images.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* @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.
*/
'use strict';

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

const ALLOWABLE_OFFSCREEN_X = 100;
const ALLOWABLE_OFFSCREEN_Y = 200;

const IGNORE_THRESHOLD_IN_BYTES = 2048;

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 lazy loaded on interaction. ' +
'Consider using the [IntersectionObserver](https://developers.google.com/web/updates/2016/04/intersectionobserver) API.',
requiredArtifacts: ['ImageUsage', 'ViewportDimensions', 'networkRecords']
};
}

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

const top = Math.max(imageRect.top, -1 * ALLOWABLE_OFFSCREEN_Y);
const right = Math.min(imageRect.right, scrollWidth + ALLOWABLE_OFFSCREEN_X);
const bottom = Math.min(imageRect.bottom, scrollHeight + 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 {{scrollWidth: number, scrollHeight: 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
},
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;
}

// Don't warn about an image that was also used appropriately
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about something like, "if an image was used more than once, warn about the least wasteful usage of it"

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

const existing = results.get(processed.preview.url);
if (!existing || existing.wastedBytes > processed.wastedBytes) {
results.set(processed.preview.url, processed);
}

return results;
}, new Map());

const results = Array.from(resultsMap.values())
.filter(item => item.wastedBytes > IGNORE_THRESHOLD_IN_BYTES);
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 @@ -43,7 +43,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 @@ -84,10 +84,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
8 changes: 4 additions & 4 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 scrollWidth = artifacts.ViewportDimensions.scrollWidth;
const viewportWidth = artifacts.ViewportDimensions.viewportWidth;
const widthsMatch = scrollWidth === viewportWidth;

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

Expand Down
2 changes: 1 addition & 1 deletion lighthouse-core/closure/typedefs/Artifacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Artifacts.prototype.CriticalRequestChains;
Artifacts.prototype.Speedline;

/** @type {{scrollWidth: number, viewportWidth: number}} */
Artifacts.prototype.ContentWidth;
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 @@ -92,6 +92,7 @@
"accessibility/tabindex",
"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 @@ -393,6 +394,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
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,21 @@ const Gatherer = require('./gatherer');
/* global window */

/* istanbul ignore next */
function getContentWidth() {
function getViewportDimensions() {
// 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({
scrollPosition: {x: window.scrollX, y: window.scrollY},
scrollWidth: window.innerWidth,
scrollHeight: window.innerHeight,
Copy link
Contributor

@ebidel ebidel Mar 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see these now. Yea, I would switch these to use the same name as the property you're stashing. e.g. innerHeight: window.innerHeight.

I typically use document.documentElement.clientWidth to get the viewport width, but inner* works great. That's what you want.

viewportWidth: window.outerWidth,
viewportHeight: window.innerHeight,
devicePixelRatio: window.devicePixelRatio,
});
}

class ContentWidth extends Gatherer {
class ViewportDimensions extends Gatherer {

/**
* @param {!Object} options
Expand All @@ -41,18 +44,21 @@ class ContentWidth extends Gatherer {
afterPass(options) {
const driver = options.driver;

return driver.evaluateAsync(`(${getContentWidth.toString()}())`)
return driver.evaluateAsync(`(${getViewportDimensions.toString()}())`)

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

return returnedValue;
});
}
}

module.exports = ContentWidth;
module.exports = ViewportDimensions;
Loading