Skip to content

Commit

Permalink
[core] Investigate bundle size (mui#954)
Browse files Browse the repository at this point in the history
  • Loading branch information
oliviertassinari authored Feb 1, 2021
1 parent bb1f7bd commit 391d39f
Show file tree
Hide file tree
Showing 5 changed files with 277 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,8 @@ __diff_output__
/coverage
/docs/.next
/docs/export
build
# TODO `dist` should be replaced with `build`, per convention
dist
node_modules
size-snapshot.json
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
"deduplicate": "node scripts/deduplicate.js",
"stylelint": "stylelint '**/*.js' '**/*.ts' '**/*.tsx'",
"prettier": "yarn babel-node -i '/node_modules/(?!@material-ui)/' ./scripts/prettier.js --branch master",
"prettier:all": "node ./scripts/prettier.js write",
"size:snapshot": "node --max-old-space-size=2048 ./scripts/sizeSnapshot/create",
"size:why": "yarn size:snapshot --analyze --accurateBundles",
"test": "lerna run test --parallel",
"test:coverage": "cross-env NODE_ENV=test BABEL_ENV=coverage nyc mocha 'packages/**/*.test.tsx' --exclude '**/node_modules/**' && nyc report -r lcovonly",
"test:coverage:html": "cross-env NODE_ENV=test BABEL_ENV=coverage nyc mocha 'packages/**/*.test.tsx' --exclude '**/node_modules/**' && nyc report --reporter=html",
Expand Down Expand Up @@ -66,6 +69,7 @@
"babel-plugin-transform-rename-import": "^2.3.0",
"chai": "^4.2.0",
"chai-dom": "^1.8.2",
"compression-webpack-plugin": "^6.0.2",
"cross-env": "^7.0.2",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
Expand Down
179 changes: 179 additions & 0 deletions scripts/sizeSnapshot/create.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
const fse = require('fs-extra');
const lodash = require('lodash');
const path = require('path');
const { promisify } = require('util');
const webpackCallbackBased = require('webpack');
const yargs = require('yargs');
const createWebpackConfig = require('./webpack.config');

const webpack = promisify(webpackCallbackBased);

const workspaceRoot = path.join(__dirname, '../../');
const snapshotDestPath = path.join(workspaceRoot, 'size-snapshot.json');

/**
* creates size snapshot for every bundle that built with webpack
*/
async function getWebpackSizes(webpackEnvironment) {
await fse.mkdirp(path.join(__dirname, 'build'));

const configurations = await createWebpackConfig(webpack, webpackEnvironment);
const webpackMultiStats = await webpack(configurations);

const sizes = [];
webpackMultiStats.stats.forEach((webpackStats) => {
if (webpackStats.hasErrors()) {
const { entrypoints, errors } = webpackStats.toJson({
all: false,
entrypoints: true,
errors: true,
});
throw new Error(
`The following errors occured during bundling of ${Object.keys(
entrypoints,
)} with webpack: \n${errors.join('\n')}`,
);
}

const stats = webpackStats.toJson({ all: false, assets: true });
const assets = new Map(stats.assets.map((asset) => [asset.name, asset]));

Object.entries(stats.assetsByChunkName).forEach(([chunkName, assetName]) => {
const parsedSize = assets.get(assetName).size;
const gzipSize = assets.get(`${assetName}.gz`).size;
sizes.push([chunkName, { parsed: parsedSize, gzip: gzipSize }]);
});
});

return sizes;
}

// waiting for String.prototype.matchAll in node 10
function* matchAll(string, regex) {
let match = null;
do {
match = regex.exec(string);
if (match !== null) {
yield match;
}
} while (match !== null);
}

/**
* Inverse to `pretty-bytes`
* @param {string} n
* @param {'B', 'kB' | 'MB' | 'GB' | 'TB' | 'PB'} unit
* @returns {number}
*/

function prettyBytesInverse(n, unit) {
const metrixPrefix = unit.length < 2 ? '' : unit[0];
const metricPrefixes = ['', 'k', 'M', 'G', 'T', 'P'];
const metrixPrefixIndex = metricPrefixes.indexOf(metrixPrefix);
if (metrixPrefixIndex === -1) {
throw new TypeError(
`unrecognized metric prefix '${metrixPrefix}' in unit '${unit}'. only '${metricPrefixes.join(
"', '",
)}' are allowed`,
);
}

const power = metrixPrefixIndex * 3;
return n * 10 ** power;
}

/**
* parses output from next build to size snapshot format
* @returns {[string, { gzip: number, files: number, packages: number }][]}
*/

async function getNextPagesSize() {
const consoleOutput = await fse.readFile(path.join(__dirname, 'build/docs.next'), {
encoding: 'utf8',
});
const pageRegex = /(?<treeViewPresentation>||)\s+((?<fileType>λ||)\s+)?(?<pageUrl>[^\s]+)\s+(?<sizeFormatted>[0-9.]+)\s+(?<sizeUnit>\w+)/gm;

const sharedChunks = [];

const entries = Array.from(matchAll(consoleOutput, pageRegex), (match) => {
const { pageUrl, sizeFormatted, sizeUnit } = match.groups;

let snapshotId = `docs:${pageUrl}`;
// used to be tracked with custom logic hence the different ids
if (pageUrl === '/') {
snapshotId = 'docs.landing';
// chunks contain a content hash that makes the names
// unsuitable for tracking. Using stable name instead:
} else if (/^chunks\/pages\/_app\.(.+)\.js$/.test(pageUrl)) {
snapshotId = 'docs.main';
} else if (/^chunks\/main\.(.+)\.js$/.test(pageUrl)) {
snapshotId = 'docs:shared:runtime/main';
} else if (/^chunks\/webpack\.(.+)\.js$/.test(pageUrl)) {
snapshotId = 'docs:shared:runtime/webpack';
} else if (/^chunks\/commons\.(.+)\.js$/.test(pageUrl)) {
snapshotId = 'docs:shared:chunk/commons';
} else if (/^chunks\/framework\.(.+)\.js$/.test(pageUrl)) {
snapshotId = 'docs:shared:chunk/framework';
} else if (/^chunks\/(.*)\.js$/.test(pageUrl)) {
// shared chunks are unnamed and only have a hash
// we just track their tally and summed size
sharedChunks.push(prettyBytesInverse(sizeFormatted, sizeUnit));
// and not each chunk individually
return null;
}

return [
snapshotId,
{
parsed: prettyBytesInverse(sizeFormatted, sizeUnit),
gzip: -1,
},
];
}).filter((entry) => entry !== null);

entries.push([
'docs:chunk:shared',
{
parsed: sharedChunks.reduce((sum, size) => sum + size, 0),
gzip: -1,
tally: sharedChunks.length,
},
]);

return entries;
}

async function run(argv) {
const { analyze, accurateBundles } = argv;

const bundleSizes = lodash.fromPairs([
...(await getWebpackSizes({ analyze, accurateBundles })),
...(await getNextPagesSize()),
]);

await fse.writeJSON(snapshotDestPath, bundleSizes, { spaces: 2 });
}

yargs
.command({
command: '$0',
description: 'Saves a size snapshot in size-snapshot.json',
builder: (command) => {
return command
.option('analyze', {
default: false,
describe: 'Creates a webpack-bundle-analyzer report for each bundle.',
type: 'boolean',
})
.option('accurateBundles', {
default: false,
describe: 'Displays used bundles accurately at the cost of CPU cycles.',
type: 'boolean',
});
},
handler: run,
})
.help()
.strict(true)
.version(false)
.parse();
80 changes: 80 additions & 0 deletions scripts/sizeSnapshot/webpack.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
const path = require('path');
const CompressionPlugin = require('compression-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

const workspaceRoot = path.join(__dirname, '..', '..');

async function getWebpackEntries() {
return [
{
name: '@material-ui/x-grid',
path: 'packages/grid/x-grid/dist/index-esm.js',
},
{
name: '@material-ui/data-grid',
path: 'packages/grid/data-grid/dist/index-esm.js',
},
{
name: '@material-ui/x-license',
path: 'packages/x-license/dist/esm/index.js',
},
];
}

module.exports = async function webpackConfig(webpack, environment) {
const analyzerMode = environment.analyze ? 'static' : 'disabled';
const concatenateModules = !environment.accurateBundles;

const entries = await getWebpackEntries();
const configurations = entries.map((entry) => {
return {
// ideally this would be computed from the bundles peer dependencies
externals: /^(react|react-dom|react\/jsx-runtime)$/,
mode: 'production',
optimization: {
concatenateModules,
minimizer: [
new TerserPlugin({
test: /\.js(\?.*)?$/i,
}),
],
},
output: {
filename: '[name].js',
path: path.join(__dirname, 'build'),
},
plugins: [
new CompressionPlugin(),
new BundleAnalyzerPlugin({
analyzerMode,
// We create a report for each bundle so around 120 reports.
// Opening them all is spam.
// If opened with `webpack --config . --analyze` it'll still open one new tab though.
openAnalyzer: false,
// '[name].html' not supported: https://github.com/webpack-contrib/webpack-bundle-analyzer/issues/12
reportFilename: `${entry.name}.html`,
}),
],
resolve: {
alias: {
'@material-ui/data-grid': path.join(
workspaceRoot,
'packages/grid/x-grid/dist/index-esm.js',
),
'@material-ui/x-grid': path.join(
workspaceRoot,
'packages/grid/data-grid/dist/index-esm.js',
),
'@material-ui/x-license': path.join(
workspaceRoot,
'packages/x-license/dist/esm/index.js',
),
},
},
entry: { [entry.name]: path.join(workspaceRoot, entry.path) },
};
});

return configurations;
};
11 changes: 11 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7987,6 +7987,17 @@ compressible@~2.0.16:
dependencies:
mime-db ">= 1.43.0 < 2"

compression-webpack-plugin@^6.0.2:
version "6.1.1"
resolved "https://registry.yarnpkg.com/compression-webpack-plugin/-/compression-webpack-plugin-6.1.1.tgz#ae8e4b2ffdb7396bb776e66918d751a20d8ccf0e"
integrity sha512-BEHft9M6lwOqVIQFMS/YJGmeCYXVOakC5KzQk05TFpMBlODByh1qNsZCWjUBxCQhUP9x0WfGidxTbGkjbWO/TQ==
dependencies:
cacache "^15.0.5"
find-cache-dir "^3.3.1"
schema-utils "^3.0.0"
serialize-javascript "^5.0.1"
webpack-sources "^1.4.3"

compression@^1.7.4:
version "1.7.4"
resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f"
Expand Down

0 comments on commit 391d39f

Please sign in to comment.