UNPKG

lighthouse

Version:

Automated auditing, performance metrics, and best practices for the web.

322 lines (283 loc) 11.3 kB
/** * @license * Copyright 2020 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview * Creates nodes for treemap app. * Example output: treemap/app/debug.json */ /** * @typedef {Omit<LH.Treemap.Node, 'name'|'children'>} SourceData */ import {Audit} from './audit.js'; import {JSBundles} from '../computed/js-bundles.js'; import {NetworkRecords} from '../computed/network-records.js'; import {UnusedJavascriptSummary} from '../computed/unused-javascript-summary.js'; import {ModuleDuplication} from '../computed/module-duplication.js'; import {getRequestForScript, isInline} from '../lib/script-helpers.js'; class ScriptTreemapDataAudit extends Audit { /** * @return {LH.Audit.Meta} */ static get meta() { return { id: 'script-treemap-data', scoreDisplayMode: Audit.SCORING_MODES.INFORMATIVE, title: 'Script Treemap Data', description: 'Used for treemap app', requiredArtifacts: ['Trace', 'DevtoolsLog', 'SourceMaps', 'Scripts', 'JsUsage', 'URL', 'SourceMaps'], }; } /** * Returns a tree data structure where leaf nodes are sources (ie. real files from source tree) * from a source map, and non-leaf nodes are directories. Leaf nodes have data * for bytes, coverage, etc., when available, and non-leaf nodes have the * same data as the sum of all descendant leaf nodes. * @param {string} src * @param {string} sourceRoot * @param {Record<string, SourceData>} sourcesData * @return {LH.Treemap.Node} */ static makeScriptNode(src, sourceRoot, sourcesData) { /** * @param {string} name * @return {LH.Treemap.Node} */ function newNode(name) { return { name, resourceBytes: 0, encodedBytes: undefined, }; } const sourceRootNode = newNode(sourceRoot); /** * Given a slash-delimited path, traverse the Node structure and increment * the data provided for each node in the chain. Creates nodes as needed. * Ex: path/to/file.js will find or create "path" on `node`, increment the data fields, * and continue with "to", and so on. * @param {string} source * @param {SourceData} data */ function addAllNodesInSourcePath(source, data) { let node = sourceRootNode; // Apply the data to the sourceRootNode. sourceRootNode.resourceBytes += data.resourceBytes; if (data.unusedBytes) { sourceRootNode.unusedBytes = (sourceRootNode.unusedBytes || 0) + data.unusedBytes; } // Strip off the shared root. const sourcePathSegments = source.replace(sourceRoot, '').split(/\/+/); sourcePathSegments.forEach((sourcePathSegment, i) => { if (sourcePathSegment.length === 0) return; const isLeaf = i === sourcePathSegments.length - 1; let child = node.children && node.children.find(child => child.name === sourcePathSegment); if (!child) { child = newNode(sourcePathSegment); node.children = node.children || []; node.children.push(child); } node = child; // Now that we've found or created the next node in the path, apply the data. node.resourceBytes += data.resourceBytes; if (data.unusedBytes) node.unusedBytes = (node.unusedBytes || 0) + data.unusedBytes; // Only leaf nodes might have duplication data. if (isLeaf && data.duplicatedNormalizedModuleName !== undefined) { node.duplicatedNormalizedModuleName = data.duplicatedNormalizedModuleName; } }); } // For every source file, apply the data to all components // of the source path, creating nodes as necessary. for (const [source, data] of Object.entries(sourcesData)) { addAllNodesInSourcePath(source, data); } /** * Collapse nodes that have only one child. * @param {LH.Treemap.Node} node */ function collapseAll(node) { while (node.children && node.children.length === 1) { const child = node.children[0]; node.name += '/' + child.name; if (child.duplicatedNormalizedModuleName) { node.duplicatedNormalizedModuleName = child.duplicatedNormalizedModuleName; } node.children = child.children; } if (node.children) { for (const child of node.children) { collapseAll(child); } } } collapseAll(sourceRootNode); // If sourceRootNode.name is falsy (no defined sourceRoot + no collapsed common prefix), // collapse the sourceRootNode children into the scriptNode. // Otherwise, we add another node. if (!sourceRootNode.name) { return { ...sourceRootNode, name: src, children: sourceRootNode.children, }; } // Script node should be just the script src. const scriptNode = {...sourceRootNode}; scriptNode.name = src; scriptNode.children = [sourceRootNode]; return scriptNode; } /** * Returns nodes where the first level of nodes are URLs. * Every external script has a node. * All inline scripts are combined into a single node. * If a script has a source map, that node will be created by makeScriptNode. * * Example return result: - index.html (inline scripts) - main.js - - webpack:// - - - react.js - - - app.js - i-have-no-map.js * * @param {LH.Artifacts} artifacts * @param {LH.Audit.Context} context * @return {Promise<LH.Treemap.Node[]>} */ static async makeNodes(artifacts, context) { const devtoolsLog = artifacts.DevtoolsLog; const networkRecords = await NetworkRecords.request(devtoolsLog, context); /** @type {LH.Treemap.Node[]} */ const nodes = []; /** @type {Map<string, LH.Treemap.Node>} */ const htmlNodesByFrameId = new Map(); const bundles = await JSBundles.request(artifacts, context); const duplicationByPath = await ModuleDuplication.request(artifacts, context); for (const script of artifacts.Scripts) { if (script.scriptLanguage !== 'JavaScript') continue; const name = script.url; const bundle = bundles.find(bundle => script.scriptId === bundle.script.scriptId) ?? null; const scriptCoverage = /** @type {LH.Artifacts['JsUsage'][string] | undefined} */ (artifacts.JsUsage[script.scriptId]); const unusedJavascriptSummary = scriptCoverage ? await UnusedJavascriptSummary.request( {scriptId: script.scriptId, scriptCoverage, bundle}, context) : undefined; /** @type {LH.Treemap.Node} */ let node; if (bundle && !('errorMessage' in bundle.sizes)) { // Create nodes for each module in a bundle. /** @type {Record<string, SourceData>} */ const sourcesData = {}; for (const source of Object.keys(bundle.sizes.files)) { /** @type {SourceData} */ const sourceData = { resourceBytes: bundle.sizes.files[source], }; if (unusedJavascriptSummary?.sourcesWastedBytes) { sourceData.unusedBytes = unusedJavascriptSummary.sourcesWastedBytes[source]; } // ModuleDuplication uses keys without the source root prepended, but // bundle.sizes uses keys with it prepended, so we remove the source root before // using it with duplicationByPath. let sourceWithoutSourceRoot = source; if (bundle.rawMap.sourceRoot && source.startsWith(bundle.rawMap.sourceRoot)) { sourceWithoutSourceRoot = source.replace(bundle.rawMap.sourceRoot, ''); } const key = ModuleDuplication.normalizeSource(sourceWithoutSourceRoot); if (duplicationByPath.has(key)) sourceData.duplicatedNormalizedModuleName = key; sourcesData[source] = sourceData; } if (bundle.sizes.unmappedBytes) { /** @type {SourceData} */ const sourceData = { resourceBytes: bundle.sizes.unmappedBytes, }; if (unusedJavascriptSummary?.sourcesWastedBytes) { sourceData.unusedBytes = unusedJavascriptSummary.sourcesWastedBytes['(unmapped)']; } sourcesData['(unmapped)'] = sourceData; } node = this.makeScriptNode(script.url, bundle.rawMap.sourceRoot || '', sourcesData); } else { // No valid source map for this script, so we can only produce a single node. node = { name, resourceBytes: unusedJavascriptSummary?.totalBytes ?? script.length ?? 0, encodedBytes: undefined, unusedBytes: unusedJavascriptSummary?.wastedBytes, }; } // If this is an inline script, place the node inside a top-level (aka depth-one) node. // Also separate each iframe / the main page's inline scripts into their own top-level nodes. if (isInline(script)) { let htmlNode = htmlNodesByFrameId.get(script.executionContextAuxData.frameId); if (!htmlNode) { htmlNode = { name, resourceBytes: 0, encodedBytes: undefined, unusedBytes: undefined, children: [], }; htmlNodesByFrameId.set(script.executionContextAuxData.frameId, htmlNode); nodes.push(htmlNode); } htmlNode.resourceBytes += node.resourceBytes; if (node.unusedBytes) htmlNode.unusedBytes = (htmlNode.unusedBytes || 0) + node.unusedBytes; node.name = script.content ? '(inline) ' + script.content.trimStart().substring(0, 15) + '…' : '(inline)'; htmlNode.children?.push(node); } else { // Non-inline scripts each have their own top-level node. nodes.push(node); const networkRecord = getRequestForScript(networkRecords, script); if (networkRecord) { const bodyTransferSize = networkRecord.transferSize - networkRecord.responseHeadersTransferSize; node.encodedBytes = bodyTransferSize; } else { node.encodedBytes = node.resourceBytes; } } } // For the HTML nodes, set encodedBytes to be the size of all the inline // scripts multiplied by the average compression ratio of the HTML document. for (const [frameId, node] of htmlNodesByFrameId) { const record = networkRecords.find(r => r.resourceType === 'Document' && r.frameId === frameId); if (record) { const inlineScriptsPct = node.resourceBytes / record.resourceSize; const bodyTransferSize = record.transferSize - record.responseHeadersTransferSize; node.encodedBytes = Math.floor(bodyTransferSize * inlineScriptsPct); } else { node.encodedBytes = node.resourceBytes; } } return nodes; } /** * @param {LH.Artifacts} artifacts * @param {LH.Audit.Context} context * @return {Promise<LH.Audit.Product>} */ static async audit(artifacts, context) { const nodes = await ScriptTreemapDataAudit.makeNodes(artifacts, context); /** @type {LH.Audit.Details.TreemapData} */ const details = { type: 'treemap-data', nodes, }; return { score: 1, details, }; } } export default ScriptTreemapDataAudit;