Skip to content

Commit

Permalink
new_audit: third party facades (#11290)
Browse files Browse the repository at this point in the history
  • Loading branch information
adamraine authored Dec 2, 2020
1 parent 07b1f1b commit ece5e8c
Show file tree
Hide file tree
Showing 64 changed files with 1,363 additions and 317 deletions.
8 changes: 8 additions & 0 deletions lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ Object {
Object {
"path": "third-party-summary",
},
Object {
"path": "third-party-facades",
},
Object {
"path": "largest-contentful-paint-element",
},
Expand Down Expand Up @@ -1014,6 +1017,11 @@ Object {
"id": "third-party-summary",
"weight": 0,
},
Object {
"group": "diagnostics",
"id": "third-party-facades",
"weight": 0,
},
Object {
"group": "diagnostics",
"id": "largest-contentful-paint-element",
Expand Down
14 changes: 14 additions & 0 deletions lighthouse-cli/test/fixtures/perf/third-party.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!--
* Copyright 2020 The Lighthouse Authors. 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.
-->
<!DOCTYPE html>
<html>
<body>

<div>We need some content to have a valid FCP/LCP</div>
<iframe width="420" height="315" src="https://www.youtube.com/embed/tgbNymZ7vqY"></iframe>

</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -359,4 +359,31 @@ module.exports = [
},
},
},
{
lhr: {
requestedUrl: 'http://localhost:10200/perf/third-party.html',
finalUrl: 'http://localhost:10200/perf/third-party.html',
audits: {
'third-party-facades': {
score: 0,
displayValue: '1 facade alternative available',
details: {
items: [
{
product: 'YouTube Embedded Player (Video)',
blockingTime: 0,
transferSize: '651128 +/- 100000',
subItems: {
type: 'subitems',
items: {
length: '>5', // We don't care exactly how many it has, just ensure we surface the subresources.
},
},
},
],
},
},
},
},
},
];
220 changes: 220 additions & 0 deletions lighthouse-core/audits/third-party-facades.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/**
* @license Copyright 2020 The Lighthouse Authors. 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.
*/
'use strict';

/**
* @fileoverview Audit which identifies third-party code on the page which can be lazy loaded.
* The audit will recommend a facade alternative which is used to imitate the third-party resource until it is needed.
*
* Entity: Set of domains which are used by a company or product area to deliver third-party resources
* Product: Specific piece of software belonging to an entity. Entities can have multiple products.
* Facade: Placeholder for a product which looks likes the actual product and replaces itself with that product when the user needs it.
*/

/** @typedef {import("third-party-web").IEntity} ThirdPartyEntity */
/** @typedef {import("third-party-web").IProduct} ThirdPartyProduct*/
/** @typedef {import("third-party-web").IFacade} ThirdPartyFacade*/

/** @typedef {{product: ThirdPartyProduct, entity: ThirdPartyEntity}} FacadableProduct */

const Audit = require('./audit.js');
const i18n = require('../lib/i18n/i18n.js');
const thirdPartyWeb = require('../lib/third-party-web.js');
const NetworkRecords = require('../computed/network-records.js');
const MainResource = require('../computed/main-resource.js');
const MainThreadTasks = require('../computed/main-thread-tasks.js');
const ThirdPartySummary = require('./third-party-summary.js');

const UIStrings = {
/** Title of a diagnostic audit that provides details about the third-party code on a web page that can be lazy loaded with a facade alternative. This descriptive title is shown to users when no resources have facade alternatives available. A facade is a lightweight component which looks like the desired resource. Lazy loading means resources are deferred until they are needed. Third-party code refers to resources that are not within the control of the site owner. */
title: 'Lazy load third-party resources with facades',
/** Title of a diagnostic audit that provides details about the third-party code on a web page that can be lazy loaded with a facade alternative. This descriptive title is shown to users when one or more third-party resources have available facade alternatives. A facade is a lightweight component which looks like the desired resource. Lazy loading means resources are deferred until they are needed. Third-party code refers to resources that are not within the control of the site owner. */
failureTitle: 'Some third-party resources can be lazy loaded with a facade',
/** Description of a Lighthouse audit that identifies the third-party code on the page that can be lazy loaded with a facade alternative. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. A facade is a lightweight component which looks like the desired resource. Lazy loading means resources are deferred until they are needed. Third-party code refers to resources that are not within the control of the site owner. */
description: 'Some third-party embeds can be lazy loaded. ' +
'Consider replacing them with a facade until they are required. [Learn more](https://web.dev/third-party-facades/).',
/** Summary text for the result of a Lighthouse audit that identifies the third-party code on a web page that can be lazy loaded with a facade alternative. This text summarizes the number of lazy loading facades that can be used on the page. A facade is a lightweight component which looks like the desired resource. */
displayValue: `{itemCount, plural,
=1 {# facade alternative available}
other {# facade alternatives available}
}`,
/** Label for a table column that displays the name of the product that a URL is used for. The products in the column will be pieces of software used on the page, like the "YouTube Embedded Player" or the "Drift Live Chat" box. */
columnProduct: 'Product',
/**
* @description Template for a table entry that gives the name of a product which we categorize as video related.
* @example {YouTube Embedded Player} productName
*/
categoryVideo: '{productName} (Video)',
/**
* @description Template for a table entry that gives the name of a product which we categorize as customer success related. Customer success means the product supports customers by offering chat and contact solutions.
* @example {Intercom Widget} productName
*/
categoryCustomerSuccess: '{productName} (Customer Success)',
/**
* @description Template for a table entry that gives the name of a product which we categorize as marketing related.
* @example {Drift Live Chat} productName
*/
categoryMarketing: '{productName} (Marketing)',
/**
* @description Template for a table entry that gives the name of a product which we categorize as social related.
* @example {Facebook Messenger Customer Chat} productName
*/
categorySocial: '{productName} (Social)',
};

const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);

/** @type {Record<string, string>} */
const CATEGORY_UI_MAP = {
'video': UIStrings.categoryVideo,
'customer-success': UIStrings.categoryCustomerSuccess,
'marketing': UIStrings.categoryMarketing,
'social': UIStrings.categorySocial,
};

class ThirdPartyFacades extends Audit {
/**
* @return {LH.Audit.Meta}
*/
static get meta() {
return {
id: 'third-party-facades',
title: str_(UIStrings.title),
failureTitle: str_(UIStrings.failureTitle),
description: str_(UIStrings.description),
requiredArtifacts: ['traces', 'devtoolsLogs', 'URL'],
};
}

/**
* Sort items by transfer size and combine small items into a single row.
* Items will be mutated in place to a maximum of 6 rows.
* @param {ThirdPartySummary.URLSummary[]} items
*/
static condenseItems(items) {
items.sort((a, b) => b.transferSize - a.transferSize);

// Items <1KB are condensed. If all items are <1KB, condense all but the largest.
let splitIndex = items.findIndex((item) => item.transferSize < 1000) || 1;
// Show details for top 5 items.
if (splitIndex === -1 || splitIndex > 5) splitIndex = 5;
// If there is only 1 item to condense, leave it as is.
if (splitIndex >= items.length - 1) return;

const remainder = items.splice(splitIndex);
const finalItem = remainder.reduce((result, item) => {
result.transferSize += item.transferSize;
result.blockingTime += item.blockingTime;
return result;
});

// If condensed row is still <1KB, don't show it.
if (finalItem.transferSize < 1000) return;

finalItem.url = str_(i18n.UIStrings.otherResourcesLabel);
items.push(finalItem);
}

/**
* @param {Map<string, ThirdPartySummary.Summary>} byURL
* @param {ThirdPartyEntity | undefined} mainEntity
* @return {FacadableProduct[]}
*/
static getProductsWithFacade(byURL, mainEntity) {
/** @type {Map<string, FacadableProduct>} */
const facadableProductMap = new Map();
for (const url of byURL.keys()) {
const entity = thirdPartyWeb.getEntity(url);
if (!entity || thirdPartyWeb.isFirstParty(url, mainEntity)) continue;

const product = thirdPartyWeb.getProduct(url);
if (!product || !product.facades || !product.facades.length) continue;

if (facadableProductMap.has(product.name)) continue;
facadableProductMap.set(product.name, {product, entity});
}

return Array.from(facadableProductMap.values());
}

/**
* @param {LH.Artifacts} artifacts
* @param {LH.Audit.Context} context
* @return {Promise<LH.Audit.Product>}
*/
static async audit(artifacts, context) {
const settings = context.settings;
const trace = artifacts.traces[Audit.DEFAULT_PASS];
const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
const networkRecords = await NetworkRecords.request(devtoolsLog, context);
const mainResource = await MainResource.request({devtoolsLog, URL: artifacts.URL}, context);
const mainEntity = thirdPartyWeb.getEntity(mainResource.url);
const tasks = await MainThreadTasks.request(trace, context);
const multiplier = settings.throttlingMethod === 'simulate' ?
settings.throttling.cpuSlowdownMultiplier : 1;
const summaries = ThirdPartySummary.getSummaries(networkRecords, tasks, multiplier);
const facadableProducts =
ThirdPartyFacades.getProductsWithFacade(summaries.byURL, mainEntity);

/** @type {LH.Audit.Details.TableItem[]} */
const results = [];
for (const {product, entity} of facadableProducts) {
const categoryTemplate = CATEGORY_UI_MAP[product.categories[0]];

let productWithCategory;
if (categoryTemplate) {
// Display product name with category next to it in the same column.
productWithCategory = str_(categoryTemplate, {productName: product.name});
} else {
// Just display product name if no category is found.
productWithCategory = product.name;
}

const urls = summaries.urls.get(entity);
const entitySummary = summaries.byEntity.get(entity);
if (!urls || !entitySummary) continue;

const items = Array.from(urls).map((url) => {
const urlStats = summaries.byURL.get(url);
return /** @type {ThirdPartySummary.URLSummary} */ ({url, ...urlStats});
});
this.condenseItems(items);
results.push({
product: productWithCategory,
transferSize: entitySummary.transferSize,
blockingTime: entitySummary.blockingTime,
subItems: {type: 'subitems', items},
});
}

if (!results.length) {
return {
score: 1,
notApplicable: true,
};
}

/** @type {LH.Audit.Details.Table['headings']} */
const headings = [
/* eslint-disable max-len */
{key: 'product', itemType: 'text', subItemsHeading: {key: 'url', itemType: 'url'}, text: str_(UIStrings.columnProduct)},
{key: 'transferSize', itemType: 'bytes', subItemsHeading: {key: 'transferSize'}, granularity: 1, text: str_(i18n.UIStrings.columnTransferSize)},
{key: 'blockingTime', itemType: 'ms', subItemsHeading: {key: 'blockingTime'}, granularity: 1, text: str_(i18n.UIStrings.columnBlockingTime)},
/* eslint-enable max-len */
];

return {
score: 0,
displayValue: str_(UIStrings.displayValue, {
itemCount: results.length,
}),
details: Audit.makeTableDetails(headings, results),
};
}
}

module.exports = ThirdPartyFacades;
module.exports.UIStrings = UIStrings;
35 changes: 17 additions & 18 deletions lighthouse-core/audits/third-party-summary.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,9 @@ const UIStrings = {
'your page has primarily finished loading. [Learn more](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/loading-third-party-javascript/).',
/** Label for a table column that displays the name of a third-party provider that potentially links to their website. */
columnThirdParty: 'Third-Party',
/** Label for a table column that displays how much time each row spent blocking other work on the main thread, entries will be the number of milliseconds spent. */
columnBlockingTime: 'Main-Thread Blocking Time',
/** Summary text for the result of a Lighthouse audit that identifies the code on a web page that the user doesn't control (referred to as "third-party code"). This text summarizes the number of distinct entities that were found on the page. */
displayValue: 'Third-party code blocked the main thread for ' +
`{timeInMs, number, milliseconds}\xa0ms`,
/** Label used to identify a value in a table where many individual values are aggregated to a single value, for brevity. "Other resources" could also be read as "the rest of the resources". Resource refers to network resources requested by the browser. */
otherValue: 'Other resources',
};

const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
Expand All @@ -41,21 +37,24 @@ const PASS_THRESHOLD_IN_MS = 250;
/** @typedef {import("third-party-web").IEntity} ThirdPartyEntity */

/**
* @typedef {{
* mainThreadTime: number,
* transferSize: number,
* blockingTime: number,
* }} Summary
* @typedef Summary
* @property {number} mainThreadTime
* @property {number} transferSize
* @property {number} blockingTime
*/

/**
* @typedef {{
* transferSize: number,
* blockingTime: number,
* url: string | LH.IcuMessage,
* }} URLSummary
* @typedef URLSummary
* @property {number} transferSize
* @property {number} blockingTime
* @property {string | LH.IcuMessage} url
*/

/** @typedef SummaryMaps
* @property {Map<ThirdPartyEntity, Summary>} byEntity Map of impact summaries for each entity.
* @property {Map<string, Summary>} byURL Map of impact summaries for each URL.
* @property {Map<ThirdPartyEntity, string[]>} urls Map of URLs under each entity.
*/

/**
* Don't bother showing resources smaller than 4KiB since they're likely to be pixels, which isn't
Expand Down Expand Up @@ -85,7 +84,7 @@ class ThirdPartySummary extends Audit {
* @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
* @param {Array<LH.Artifacts.TaskNode>} mainThreadTasks
* @param {number} cpuMultiplier
* @return {{byEntity: Map<ThirdPartyEntity, Summary>, byURL: Map<string, Summary>, urls: Map<ThirdPartyEntity, string[]>}}
* @return {SummaryMaps}
*/
static getSummaries(networkRecords, mainThreadTasks, cpuMultiplier) {
/** @type {Map<string, Summary>} */
Expand Down Expand Up @@ -142,7 +141,7 @@ class ThirdPartySummary extends Audit {

/**
* @param {ThirdPartyEntity} entity
* @param {{byEntity: Map<ThirdPartyEntity, Summary>, byURL: Map<string, Summary>, urls: Map<ThirdPartyEntity, string[]>}} summaries
* @param {SummaryMaps} summaries
* @param {Summary} stats
* @return {Array<URLSummary>}
*/
Expand Down Expand Up @@ -179,7 +178,7 @@ class ThirdPartySummary extends Audit {
// we'll replace the tail entries with single remainder entry.
items = items.slice(0, numSubItems);
const remainder = {
url: str_(UIStrings.otherValue),
url: str_(i18n.UIStrings.otherResourcesLabel),
transferSize: stats.transferSize - subitemSummary.transferSize,
blockingTime: stats.blockingTime - subitemSummary.blockingTime,
};
Expand Down Expand Up @@ -237,7 +236,7 @@ class ThirdPartySummary extends Audit {
/* eslint-disable max-len */
{key: 'entity', itemType: 'link', text: str_(UIStrings.columnThirdParty), subItemsHeading: {key: 'url', itemType: 'url'}},
{key: 'transferSize', granularity: 1, itemType: 'bytes', text: str_(i18n.UIStrings.columnTransferSize), subItemsHeading: {key: 'transferSize'}},
{key: 'blockingTime', granularity: 1, itemType: 'ms', text: str_(UIStrings.columnBlockingTime), subItemsHeading: {key: 'blockingTime'}},
{key: 'blockingTime', granularity: 1, itemType: 'ms', text: str_(i18n.UIStrings.columnBlockingTime), subItemsHeading: {key: 'blockingTime'}},
/* eslint-enable max-len */
];

Expand Down
2 changes: 2 additions & 0 deletions lighthouse-core/config/default-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ const defaultConfig = {
'timing-budget',
'resource-summary',
'third-party-summary',
'third-party-facades',
'largest-contentful-paint-element',
'layout-shift-elements',
'long-tasks',
Expand Down Expand Up @@ -470,6 +471,7 @@ const defaultConfig = {
{id: 'timing-budget', weight: 0, group: 'budgets'},
{id: 'resource-summary', weight: 0, group: 'diagnostics'},
{id: 'third-party-summary', weight: 0, group: 'diagnostics'},
{id: 'third-party-facades', weight: 0, group: 'diagnostics'},
{id: 'largest-contentful-paint-element', weight: 0, group: 'diagnostics'},
{id: 'layout-shift-elements', weight: 0, group: 'diagnostics'},
{id: 'uses-passive-event-listeners', weight: 0, group: 'diagnostics'},
Expand Down
Loading

0 comments on commit ece5e8c

Please sign in to comment.