chushihua
This commit is contained in:
+128
@@ -0,0 +1,128 @@
|
||||
const bfj = require('bfj-node4');
|
||||
const path = require('path');
|
||||
const mkdir = require('mkdirp');
|
||||
const { bold } = require('chalk');
|
||||
|
||||
const Logger = require('./Logger');
|
||||
const viewer = require('./viewer');
|
||||
|
||||
class BundleAnalyzerPlugin {
|
||||
|
||||
constructor(opts) {
|
||||
this.opts = {
|
||||
analyzerMode: 'server',
|
||||
analyzerHost: '127.0.0.1',
|
||||
analyzerPort: 8888,
|
||||
reportFilename: 'report.html',
|
||||
defaultSizes: 'parsed',
|
||||
openAnalyzer: true,
|
||||
generateStatsFile: false,
|
||||
statsFilename: 'stats.json',
|
||||
statsOptions: null,
|
||||
excludeAssets: null,
|
||||
logLevel: 'info',
|
||||
// deprecated
|
||||
startAnalyzer: true,
|
||||
...opts
|
||||
};
|
||||
|
||||
this.server = null;
|
||||
this.logger = new Logger(this.opts.logLevel);
|
||||
}
|
||||
|
||||
apply(compiler) {
|
||||
this.compiler = compiler;
|
||||
|
||||
const done = stats => {
|
||||
stats = stats.toJson(this.opts.statsOptions);
|
||||
|
||||
const actions = [];
|
||||
|
||||
if (this.opts.generateStatsFile) {
|
||||
actions.push(() => this.generateStatsFile(stats));
|
||||
}
|
||||
|
||||
// Handling deprecated `startAnalyzer` flag
|
||||
if (this.opts.analyzerMode === 'server' && !this.opts.startAnalyzer) {
|
||||
this.opts.analyzerMode = 'disabled';
|
||||
}
|
||||
|
||||
if (this.opts.analyzerMode === 'server') {
|
||||
actions.push(() => this.startAnalyzerServer(stats));
|
||||
} else if (this.opts.analyzerMode === 'static') {
|
||||
actions.push(() => this.generateStaticReport(stats));
|
||||
}
|
||||
|
||||
if (actions.length) {
|
||||
// Making analyzer logs to be after all webpack logs in the console
|
||||
setImmediate(() => {
|
||||
actions.forEach(action => action());
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (compiler.hooks) {
|
||||
compiler.hooks.done.tap('webpack-bundle-analyzer', done);
|
||||
} else {
|
||||
compiler.plugin('done', done);
|
||||
}
|
||||
}
|
||||
|
||||
async generateStatsFile(stats) {
|
||||
const statsFilepath = path.resolve(this.compiler.outputPath, this.opts.statsFilename);
|
||||
mkdir.sync(path.dirname(statsFilepath));
|
||||
|
||||
try {
|
||||
await bfj.write(statsFilepath, stats, {
|
||||
space: 2,
|
||||
promises: 'ignore',
|
||||
buffers: 'ignore',
|
||||
maps: 'ignore',
|
||||
iterables: 'ignore',
|
||||
circular: 'ignore'
|
||||
});
|
||||
|
||||
this.logger.info(
|
||||
`${bold('Webpack Bundle Analyzer')} saved stats file to ${bold(statsFilepath)}`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`${bold('Webpack Bundle Analyzer')} error saving stats file to ${bold(statsFilepath)}: ${error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async startAnalyzerServer(stats) {
|
||||
if (this.server) {
|
||||
(await this.server).updateChartData(stats);
|
||||
} else {
|
||||
this.server = viewer.startServer(stats, {
|
||||
openBrowser: this.opts.openAnalyzer,
|
||||
host: this.opts.analyzerHost,
|
||||
port: this.opts.analyzerPort,
|
||||
bundleDir: this.getBundleDirFromCompiler(),
|
||||
logger: this.logger,
|
||||
defaultSizes: this.opts.defaultSizes,
|
||||
excludeAssets: this.opts.excludeAssets
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
generateStaticReport(stats) {
|
||||
viewer.generateReport(stats, {
|
||||
openBrowser: this.opts.openAnalyzer,
|
||||
reportFilename: path.resolve(this.compiler.outputPath, this.opts.reportFilename),
|
||||
bundleDir: this.getBundleDirFromCompiler(),
|
||||
logger: this.logger,
|
||||
defaultSizes: this.opts.defaultSizes,
|
||||
excludeAssets: this.opts.excludeAssets
|
||||
});
|
||||
}
|
||||
|
||||
getBundleDirFromCompiler() {
|
||||
return (this.compiler.outputFileSystem.constructor.name === 'MemoryFileSystem') ? null : this.compiler.outputPath;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = BundleAnalyzerPlugin;
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
const LEVELS = [
|
||||
'debug',
|
||||
'info',
|
||||
'warn',
|
||||
'error',
|
||||
'silent'
|
||||
];
|
||||
|
||||
const LEVEL_TO_CONSOLE_METHOD = new Map([
|
||||
['debug', 'log'],
|
||||
['info', 'log'],
|
||||
['warn', 'log']
|
||||
]);
|
||||
|
||||
class Logger {
|
||||
|
||||
static levels = LEVELS;
|
||||
static defaultLevel = 'info';
|
||||
|
||||
constructor(level = Logger.defaultLevel) {
|
||||
this.activeLevels = new Set();
|
||||
this.setLogLevel(level);
|
||||
}
|
||||
|
||||
setLogLevel(level) {
|
||||
const levelIndex = LEVELS.indexOf(level);
|
||||
|
||||
if (levelIndex === -1) throw new Error(`Invalid log level "${level}". Use one of these: ${LEVELS.join(', ')}`);
|
||||
|
||||
this.activeLevels.clear();
|
||||
|
||||
for (const [i, level] of LEVELS.entries()) {
|
||||
if (i >= levelIndex) this.activeLevels.add(level);
|
||||
}
|
||||
}
|
||||
|
||||
_log(level, ...args) {
|
||||
console[LEVEL_TO_CONSOLE_METHOD.get(level) || level](...args);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
LEVELS.forEach(level => {
|
||||
if (level === 'silent') return;
|
||||
|
||||
Logger.prototype[level] = function (...args) {
|
||||
if (this.activeLevels.has(level)) this._log(level, ...args);
|
||||
};
|
||||
});
|
||||
|
||||
module.exports = Logger;
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const _ = require('lodash');
|
||||
const gzipSize = require('gzip-size');
|
||||
|
||||
const Logger = require('./Logger');
|
||||
const Folder = require('./tree/Folder').default;
|
||||
const { parseBundle } = require('./parseUtils');
|
||||
const { createAssetsFilter } = require('./utils');
|
||||
|
||||
const FILENAME_QUERY_REGEXP = /\?.*$/;
|
||||
|
||||
module.exports = {
|
||||
getViewerData,
|
||||
readStatsFromFile
|
||||
};
|
||||
|
||||
function getViewerData(bundleStats, bundleDir, opts) {
|
||||
const {
|
||||
logger = new Logger(),
|
||||
excludeAssets = null
|
||||
} = opts || {};
|
||||
|
||||
const isAssetIncluded = createAssetsFilter(excludeAssets);
|
||||
|
||||
// Sometimes all the information is located in `children` array (e.g. problem in #10)
|
||||
if (_.isEmpty(bundleStats.assets) && !_.isEmpty(bundleStats.children)) {
|
||||
bundleStats = bundleStats.children[0];
|
||||
}
|
||||
|
||||
// Picking only `*.js` assets from bundle that has non-empty `chunks` array
|
||||
bundleStats.assets = _.filter(bundleStats.assets, asset => {
|
||||
// Removing query part from filename (yes, somebody uses it for some reason and Webpack supports it)
|
||||
// See #22
|
||||
asset.name = asset.name.replace(FILENAME_QUERY_REGEXP, '');
|
||||
|
||||
return _.endsWith(asset.name, '.js') && !_.isEmpty(asset.chunks) && isAssetIncluded(asset.name);
|
||||
});
|
||||
|
||||
// Trying to parse bundle assets and get real module sizes if `bundleDir` is provided
|
||||
let bundlesSources = null;
|
||||
let parsedModules = null;
|
||||
|
||||
if (bundleDir) {
|
||||
bundlesSources = {};
|
||||
parsedModules = {};
|
||||
|
||||
for (const statAsset of bundleStats.assets) {
|
||||
const assetFile = path.join(bundleDir, statAsset.name);
|
||||
let bundleInfo;
|
||||
|
||||
try {
|
||||
bundleInfo = parseBundle(assetFile);
|
||||
} catch (err) {
|
||||
const msg = (err.code === 'ENOENT') ? 'no such file' : err.message;
|
||||
logger.warn(`Error parsing bundle asset "${assetFile}": ${msg}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
bundlesSources[statAsset.name] = bundleInfo.src;
|
||||
_.assign(parsedModules, bundleInfo.modules);
|
||||
}
|
||||
|
||||
if (_.isEmpty(bundlesSources)) {
|
||||
bundlesSources = null;
|
||||
parsedModules = null;
|
||||
logger.warn('\nNo bundles were parsed. Analyzer will show only original module sizes from stats file.\n');
|
||||
}
|
||||
}
|
||||
|
||||
const modules = getBundleModules(bundleStats);
|
||||
const assets = _.transform(bundleStats.assets, (result, statAsset) => {
|
||||
const asset = result[statAsset.name] = _.pick(statAsset, 'size');
|
||||
|
||||
if (bundlesSources && _.has(bundlesSources, statAsset.name)) {
|
||||
asset.parsedSize = bundlesSources[statAsset.name].length;
|
||||
asset.gzipSize = gzipSize.sync(bundlesSources[statAsset.name]);
|
||||
}
|
||||
|
||||
// Picking modules from current bundle script
|
||||
asset.modules = _(modules)
|
||||
.filter(statModule => assetHasModule(statAsset, statModule))
|
||||
.each(statModule => {
|
||||
if (parsedModules) {
|
||||
statModule.parsedSrc = parsedModules[statModule.id];
|
||||
}
|
||||
});
|
||||
|
||||
asset.tree = createModulesTree(asset.modules);
|
||||
}, {});
|
||||
|
||||
return _.transform(assets, (result, asset, filename) => {
|
||||
result.push({
|
||||
label: filename,
|
||||
// Not using `asset.size` here provided by Webpack because it can be very confusing when `UglifyJsPlugin` is used.
|
||||
// In this case all module sizes from stats file will represent unminified module sizes, but `asset.size` will
|
||||
// be the size of minified bundle.
|
||||
// Using `asset.size` only if current asset doesn't contain any modules (resulting size equals 0)
|
||||
statSize: asset.tree.size || asset.size,
|
||||
parsedSize: asset.parsedSize,
|
||||
gzipSize: asset.gzipSize,
|
||||
groups: _.invokeMap(asset.tree.children, 'toChartData')
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
|
||||
function readStatsFromFile(filename) {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(filename, 'utf8')
|
||||
);
|
||||
}
|
||||
|
||||
function getBundleModules(bundleStats) {
|
||||
return _(bundleStats.chunks)
|
||||
.map('modules')
|
||||
.concat(bundleStats.modules)
|
||||
.compact()
|
||||
.flatten()
|
||||
.uniqBy('id')
|
||||
.value();
|
||||
}
|
||||
|
||||
function assetHasModule(statAsset, statModule) {
|
||||
// Checking if this module is the part of asset chunks
|
||||
return _.some(statModule.chunks, moduleChunk =>
|
||||
_.includes(statAsset.chunks, moduleChunk)
|
||||
);
|
||||
}
|
||||
|
||||
function createModulesTree(modules) {
|
||||
const root = new Folder('.');
|
||||
|
||||
_.each(modules, module => root.addModule(module));
|
||||
|
||||
return root;
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
#! /usr/bin/env node
|
||||
|
||||
const { resolve, dirname } = require('path');
|
||||
|
||||
const _ = require('lodash');
|
||||
const commander = require('commander');
|
||||
const { magenta } = require('chalk');
|
||||
|
||||
const analyzer = require('../analyzer');
|
||||
const viewer = require('../viewer');
|
||||
const Logger = require('../Logger');
|
||||
|
||||
const SIZES = new Set(['stat', 'parsed', 'gzip']);
|
||||
|
||||
const program = commander
|
||||
.version(require('../../package.json').version)
|
||||
.usage(
|
||||
`<bundleStatsFile> [bundleDir] [options]
|
||||
|
||||
Arguments:
|
||||
|
||||
bundleStatsFile Path to Webpack Stats JSON file.
|
||||
bundleDir Directory containing all generated bundles.
|
||||
You should provided it if you want analyzer to show you the real parsed module sizes.
|
||||
By default a directory of stats file is used.`
|
||||
)
|
||||
.option(
|
||||
'-m, --mode <mode>',
|
||||
'Analyzer mode. Should be `server` or `static`.' +
|
||||
br('In `server` mode analyzer will start HTTP server to show bundle report.') +
|
||||
br('In `static` mode single HTML file with bundle report will be generated.'),
|
||||
'server'
|
||||
)
|
||||
.option(
|
||||
'-h, --host <host>',
|
||||
'Host that will be used in `server` mode to start HTTP server.',
|
||||
'127.0.0.1'
|
||||
)
|
||||
.option(
|
||||
'-p, --port <n>',
|
||||
'Port that will be used in `server` mode to start HTTP server.',
|
||||
Number,
|
||||
8888
|
||||
)
|
||||
.option(
|
||||
'-r, --report <file>',
|
||||
'Path to bundle report file that will be generated in `static` mode.',
|
||||
'report.html'
|
||||
)
|
||||
.option(
|
||||
'-s, --default-sizes <type>',
|
||||
'Module sizes to show in treemap by default.' +
|
||||
br(`Possible values: ${[...SIZES].join(', ')}`),
|
||||
'parsed'
|
||||
)
|
||||
.option(
|
||||
'-O, --no-open',
|
||||
"Don't open report in default browser automatically."
|
||||
)
|
||||
.option(
|
||||
'-e, --exclude <regexp>',
|
||||
'Assets that should be excluded from the report.' +
|
||||
br('Can be specified multiple times.'),
|
||||
array()
|
||||
)
|
||||
.option(
|
||||
'-l, --log-level <level>',
|
||||
'Log level.' +
|
||||
br(`Possible values: ${[...Logger.levels].join(', ')}`),
|
||||
Logger.defaultLevel
|
||||
)
|
||||
.parse(process.argv);
|
||||
|
||||
let {
|
||||
mode,
|
||||
host,
|
||||
port,
|
||||
report: reportFilename,
|
||||
defaultSizes,
|
||||
logLevel,
|
||||
open: openBrowser,
|
||||
exclude: excludeAssets,
|
||||
args: [bundleStatsFile, bundleDir]
|
||||
} = program;
|
||||
const logger = new Logger(logLevel);
|
||||
|
||||
if (!bundleStatsFile) showHelp('Provide path to Webpack Stats file as first argument');
|
||||
if (mode !== 'server' && mode !== 'static') showHelp('Invalid mode. Should be either `server` or `static`.');
|
||||
if (mode === 'server' && !host) showHelp('Invalid host name');
|
||||
if (mode === 'server' && isNaN(port)) showHelp('Invalid port number');
|
||||
if (!SIZES.has(defaultSizes)) showHelp(`Invalid default sizes option. Possible values are: ${[...SIZES].join(', ')}`);
|
||||
|
||||
bundleStatsFile = resolve(bundleStatsFile);
|
||||
|
||||
if (!bundleDir) bundleDir = dirname(bundleStatsFile);
|
||||
|
||||
let bundleStats;
|
||||
try {
|
||||
bundleStats = analyzer.readStatsFromFile(bundleStatsFile);
|
||||
} catch (err) {
|
||||
logger.error(`Could't read webpack bundle stats from "${bundleStatsFile}":\n${err}`);
|
||||
logger.debug(err.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (mode === 'server') {
|
||||
viewer.startServer(bundleStats, {
|
||||
openBrowser,
|
||||
port,
|
||||
host,
|
||||
defaultSizes,
|
||||
bundleDir,
|
||||
excludeAssets,
|
||||
logger: new Logger(logLevel)
|
||||
});
|
||||
} else {
|
||||
viewer.generateReport(bundleStats, {
|
||||
openBrowser,
|
||||
reportFilename: resolve(reportFilename),
|
||||
defaultSizes,
|
||||
bundleDir,
|
||||
excludeAssets,
|
||||
logger: new Logger(logLevel)
|
||||
});
|
||||
}
|
||||
|
||||
function showHelp(error) {
|
||||
if (error) console.log(`\n ${magenta(error)}`);
|
||||
program.outputHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function br(str) {
|
||||
return `\n${_.repeat(' ', 28)}${str}`;
|
||||
}
|
||||
|
||||
function array() {
|
||||
const arr = [];
|
||||
return (val) => {
|
||||
arr.push(val);
|
||||
return arr;
|
||||
};
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
const { start } = require('./viewer');
|
||||
|
||||
module.exports = {
|
||||
start,
|
||||
BundleAnalyzerPlugin: require('./BundleAnalyzerPlugin')
|
||||
};
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
const fs = require('fs');
|
||||
const _ = require('lodash');
|
||||
const acorn = require('acorn');
|
||||
const walk = require('acorn/dist/walk');
|
||||
|
||||
module.exports = {
|
||||
parseBundle
|
||||
};
|
||||
|
||||
function parseBundle(bundlePath) {
|
||||
const content = fs.readFileSync(bundlePath, 'utf8');
|
||||
const ast = acorn.parse(content, {
|
||||
sourceType: 'script',
|
||||
// I believe in a bright future of ECMAScript!
|
||||
// Actually, it's set to `2050` to support the latest ECMAScript version that currently exists.
|
||||
// Seems like `acorn` supports such weird option value.
|
||||
ecmaVersion: 2050
|
||||
});
|
||||
|
||||
const walkState = {
|
||||
locations: null
|
||||
};
|
||||
|
||||
walk.recursive(
|
||||
ast,
|
||||
walkState,
|
||||
{
|
||||
CallExpression(node, state, c) {
|
||||
if (state.locations) return;
|
||||
|
||||
const args = node.arguments;
|
||||
|
||||
// Main chunk with webpack loader.
|
||||
// Modules are stored in first argument:
|
||||
// (function (...) {...})(<modules>)
|
||||
if (
|
||||
node.callee.type === 'FunctionExpression' &&
|
||||
!node.callee.id &&
|
||||
args.length === 1 &&
|
||||
isSimpleModulesList(args[0])
|
||||
) {
|
||||
state.locations = getModulesLocations(args[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Async Webpack < v4 chunk without webpack loader.
|
||||
// webpackJsonp([<chunks>], <modules>, ...)
|
||||
// As function name may be changed with `output.jsonpFunction` option we can't rely on it's default name.
|
||||
if (
|
||||
node.callee.type === 'Identifier' &&
|
||||
mayBeAsyncChunkArguments(args) &&
|
||||
isModulesList(args[1])
|
||||
) {
|
||||
state.locations = getModulesLocations(args[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Async Webpack v4 chunk without webpack loader.
|
||||
// (window.webpackJsonp=window.webpackJsonp||[]).push([[<chunks>], <modules>, ...]);
|
||||
// As function name may be changed with `output.jsonpFunction` option we can't rely on it's default name.
|
||||
if (isAsyncChunkPushExpression(node)) {
|
||||
state.locations = getModulesLocations(args[0].elements[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Walking into arguments because some of plugins (e.g. `DedupePlugin`) or some Webpack
|
||||
// features (e.g. `umd` library output) can wrap modules list into additional IIFE.
|
||||
_.each(args, arg => c(arg, state));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let modules;
|
||||
|
||||
if (walkState.locations) {
|
||||
modules = _.mapValues(walkState.locations,
|
||||
loc => content.slice(loc.start, loc.end)
|
||||
);
|
||||
} else {
|
||||
modules = {};
|
||||
}
|
||||
|
||||
return {
|
||||
src: content,
|
||||
modules
|
||||
};
|
||||
}
|
||||
|
||||
function isModulesList(node) {
|
||||
return (
|
||||
isSimpleModulesList(node) ||
|
||||
// Modules are contained in expression `Array([minimum ID]).concat([<module>, <module>, ...])`
|
||||
isOptimizedModulesArray(node)
|
||||
);
|
||||
}
|
||||
|
||||
function isSimpleModulesList(node) {
|
||||
return (
|
||||
// Modules are contained in hash. Keys are module ids.
|
||||
isModulesHash(node) ||
|
||||
// Modules are contained in array. Indexes are module ids.
|
||||
isModulesArray(node)
|
||||
);
|
||||
}
|
||||
|
||||
function isModulesHash(node) {
|
||||
return (
|
||||
node.type === 'ObjectExpression' &&
|
||||
_(node.properties)
|
||||
.map('value')
|
||||
.every(isModuleWrapper)
|
||||
);
|
||||
}
|
||||
|
||||
function isModulesArray(node) {
|
||||
return (
|
||||
node.type === 'ArrayExpression' &&
|
||||
_.every(node.elements, elem =>
|
||||
// Some of array items may be skipped because there is no module with such id
|
||||
!elem ||
|
||||
isModuleWrapper(elem)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isOptimizedModulesArray(node) {
|
||||
// Checking whether modules are contained in `Array(<minimum ID>).concat(...modules)` array:
|
||||
// https://github.com/webpack/webpack/blob/v1.14.0/lib/Template.js#L91
|
||||
// The `<minimum ID>` + array indexes are module ids
|
||||
return (
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
// Make sure the object called is `Array(<some number>)`
|
||||
node.callee.object.type === 'CallExpression' &&
|
||||
node.callee.object.callee.type === 'Identifier' &&
|
||||
node.callee.object.callee.name === 'Array' &&
|
||||
node.callee.object.arguments.length === 1 &&
|
||||
isNumericId(node.callee.object.arguments[0]) &&
|
||||
// Make sure the property X called for `Array(<some number>).X` is `concat`
|
||||
node.callee.property.type === 'Identifier' &&
|
||||
node.callee.property.name === 'concat' &&
|
||||
// Make sure exactly one array is passed in to `concat`
|
||||
node.arguments.length === 1 &&
|
||||
isModulesArray(node.arguments[0])
|
||||
);
|
||||
}
|
||||
|
||||
function isModuleWrapper(node) {
|
||||
return (
|
||||
// It's an anonymous function expression that wraps module
|
||||
((node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') && !node.id) ||
|
||||
// If `DedupePlugin` is used it can be an ID of duplicated module...
|
||||
isModuleId(node) ||
|
||||
// or an array of shape [<module_id>, ...args]
|
||||
(node.type === 'ArrayExpression' && node.elements.length > 1 && isModuleId(node.elements[0]))
|
||||
);
|
||||
}
|
||||
|
||||
function isModuleId(node) {
|
||||
return (node.type === 'Literal' && (isNumericId(node) || typeof node.value === 'string'));
|
||||
}
|
||||
|
||||
function isNumericId(node) {
|
||||
return (node.type === 'Literal' && Number.isInteger(node.value) && node.value >= 0);
|
||||
}
|
||||
|
||||
function isChunkIds(node) {
|
||||
// Array of numeric or string ids. Chunk IDs are strings when NamedChunksPlugin is used
|
||||
return (
|
||||
node.type === 'ArrayExpression' &&
|
||||
_.every(node.elements, isModuleId)
|
||||
);
|
||||
}
|
||||
|
||||
function isAsyncChunkPushExpression(node) {
|
||||
const {
|
||||
callee,
|
||||
arguments: args
|
||||
} = node;
|
||||
|
||||
return (
|
||||
callee.type === 'MemberExpression' &&
|
||||
callee.property.name === 'push' &&
|
||||
callee.object.type === 'AssignmentExpression' &&
|
||||
callee.object.left.object &&
|
||||
(
|
||||
callee.object.left.object.name === 'window' ||
|
||||
// Webpack 4 uses `this` instead of `window`
|
||||
callee.object.left.object.type === 'ThisExpression'
|
||||
) &&
|
||||
args.length === 1 &&
|
||||
args[0].type === 'ArrayExpression' &&
|
||||
mayBeAsyncChunkArguments(args[0].elements) &&
|
||||
isModulesList(args[0].elements[1])
|
||||
);
|
||||
}
|
||||
|
||||
function mayBeAsyncChunkArguments(args) {
|
||||
return (
|
||||
args.length >= 2 &&
|
||||
isChunkIds(args[0])
|
||||
);
|
||||
}
|
||||
|
||||
function getModulesLocations(node) {
|
||||
if (node.type === 'ObjectExpression') {
|
||||
// Modules hash
|
||||
const modulesNodes = node.properties;
|
||||
|
||||
return _.transform(modulesNodes, (result, moduleNode) => {
|
||||
const moduleId = moduleNode.key.name || moduleNode.key.value;
|
||||
|
||||
result[moduleId] = getModuleLocation(moduleNode.value);
|
||||
}, {});
|
||||
}
|
||||
|
||||
const isOptimizedArray = (node.type === 'CallExpression');
|
||||
|
||||
if (node.type === 'ArrayExpression' || isOptimizedArray) {
|
||||
// Modules array or optimized array
|
||||
const minId = isOptimizedArray ?
|
||||
// Get the [minId] value from the Array() call first argument literal value
|
||||
node.callee.object.arguments[0].value :
|
||||
// `0` for simple array
|
||||
0;
|
||||
const modulesNodes = isOptimizedArray ?
|
||||
// The modules reside in the `concat()` function call arguments
|
||||
node.arguments[0].elements :
|
||||
node.elements;
|
||||
|
||||
return _.transform(modulesNodes, (result, moduleNode, i) => {
|
||||
if (!moduleNode) return;
|
||||
result[i + minId] = getModuleLocation(moduleNode);
|
||||
}, {});
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function getModuleLocation(node) {
|
||||
return {
|
||||
start: node.start,
|
||||
end: node.end
|
||||
};
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
import Node from './Node';
|
||||
|
||||
export default class BaseFolder extends Node {
|
||||
|
||||
constructor(name, parent) {
|
||||
super(name, parent);
|
||||
this.children = Object.create(null);
|
||||
}
|
||||
|
||||
get src() {
|
||||
if (!_.has(this, '_src')) {
|
||||
this._src = this.walk((node, src) => (src += node.src || ''), '', false);
|
||||
}
|
||||
|
||||
return this._src;
|
||||
}
|
||||
|
||||
get size() {
|
||||
if (!_.has(this, '_size')) {
|
||||
this._size = this.walk((node, size) => (size + node.size), 0, false);
|
||||
}
|
||||
|
||||
return this._size;
|
||||
}
|
||||
|
||||
getChild(name) {
|
||||
return this.children[name];
|
||||
}
|
||||
|
||||
addChildModule(module) {
|
||||
const { name } = module;
|
||||
const currentChild = this.children[name];
|
||||
|
||||
// For some reason we already have this node in children and it's a folder.
|
||||
if (currentChild && currentChild instanceof BaseFolder) return;
|
||||
|
||||
if (currentChild) {
|
||||
// We already have this node in children and it's a module.
|
||||
// Merging it's data.
|
||||
currentChild.mergeData(module.data);
|
||||
} else {
|
||||
// Pushing new module
|
||||
module.parent = this;
|
||||
this.children[name] = module;
|
||||
}
|
||||
|
||||
delete this._size;
|
||||
delete this._src;
|
||||
}
|
||||
|
||||
addChildFolder(folder) {
|
||||
folder.parent = this;
|
||||
this.children[folder.name] = folder;
|
||||
delete this._size;
|
||||
delete this._src;
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
walk(walker, state = {}, deep = true) {
|
||||
let stopped = false;
|
||||
|
||||
_.each(this.children, child => {
|
||||
if (deep && child.walk) {
|
||||
state = child.walk(walker, state, stop);
|
||||
} else {
|
||||
state = walker(child, state, stop);
|
||||
}
|
||||
|
||||
if (stopped) return false;
|
||||
});
|
||||
|
||||
return state;
|
||||
|
||||
function stop(finalState) {
|
||||
stopped = true;
|
||||
return finalState;
|
||||
}
|
||||
}
|
||||
|
||||
toChartData() {
|
||||
return {
|
||||
label: this.name,
|
||||
path: this.path,
|
||||
statSize: this.size,
|
||||
groups: _.invokeMap(this.children, 'toChartData')
|
||||
};
|
||||
}
|
||||
|
||||
};
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
import Module from './Module';
|
||||
import ContentModule from './ContentModule';
|
||||
import ContentFolder from './ContentFolder';
|
||||
import { getModulePathParts } from './utils';
|
||||
|
||||
export default class ConcatenatedModule extends Module {
|
||||
|
||||
constructor(name, data, parent) {
|
||||
super(name, data, parent);
|
||||
this.name += ' (concatenated)';
|
||||
this.children = Object.create(null);
|
||||
this.fillContentModules();
|
||||
}
|
||||
|
||||
fillContentModules() {
|
||||
_.each(this.data.modules, moduleData => this.addContentModule(moduleData));
|
||||
}
|
||||
|
||||
addContentModule(moduleData) {
|
||||
const pathParts = getModulePathParts(moduleData);
|
||||
|
||||
if (!pathParts) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [folders, fileName] = [pathParts.slice(0, -1), _.last(pathParts)];
|
||||
let currentFolder = this;
|
||||
|
||||
_.each(folders, folderName => {
|
||||
let childFolder = currentFolder.getChild(folderName);
|
||||
|
||||
if (!childFolder) {
|
||||
childFolder = currentFolder.addChildFolder(new ContentFolder(folderName, this));
|
||||
}
|
||||
|
||||
currentFolder = childFolder;
|
||||
});
|
||||
|
||||
const module = new ContentModule(fileName, moduleData, this);
|
||||
currentFolder.addChildModule(module);
|
||||
}
|
||||
|
||||
getChild(name) {
|
||||
return this.children[name];
|
||||
}
|
||||
|
||||
addChildModule(module) {
|
||||
module.parent = this;
|
||||
this.children[module.name] = module;
|
||||
}
|
||||
|
||||
addChildFolder(folder) {
|
||||
folder.parent = this;
|
||||
this.children[folder.name] = folder;
|
||||
return folder;
|
||||
}
|
||||
|
||||
toChartData() {
|
||||
return {
|
||||
...super.toChartData(),
|
||||
concatenated: true,
|
||||
groups: _.invokeMap(this.children, 'toChartData')
|
||||
};
|
||||
}
|
||||
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import BaseFolder from './BaseFolder';
|
||||
|
||||
export default class ContentFolder extends BaseFolder {
|
||||
|
||||
constructor(name, ownerModule, parent) {
|
||||
super(name, parent);
|
||||
this.ownerModule = ownerModule;
|
||||
}
|
||||
|
||||
get parsedSize() {
|
||||
return this.getSize('parsedSize');
|
||||
}
|
||||
|
||||
get gzipSize() {
|
||||
return this.getSize('gzipSize');
|
||||
}
|
||||
|
||||
getSize(sizeType) {
|
||||
const ownerModuleSize = this.ownerModule[sizeType];
|
||||
|
||||
if (ownerModuleSize !== undefined) {
|
||||
return Math.floor((this.size / this.ownerModule.size) * ownerModuleSize);
|
||||
}
|
||||
}
|
||||
|
||||
toChartData() {
|
||||
return {
|
||||
...super.toChartData(),
|
||||
parsedSize: this.parsedSize,
|
||||
gzipSize: this.gzipSize,
|
||||
inaccurateSizes: true
|
||||
};
|
||||
}
|
||||
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import Module from './Module';
|
||||
|
||||
export default class ContentModule extends Module {
|
||||
|
||||
constructor(name, data, ownerModule, parent) {
|
||||
super(name, data, parent);
|
||||
this.ownerModule = ownerModule;
|
||||
}
|
||||
|
||||
get parsedSize() {
|
||||
return this.getSize('parsedSize');
|
||||
}
|
||||
|
||||
get gzipSize() {
|
||||
return this.getSize('gzipSize');
|
||||
}
|
||||
|
||||
getSize(sizeType) {
|
||||
const ownerModuleSize = this.ownerModule[sizeType];
|
||||
|
||||
if (ownerModuleSize !== undefined) {
|
||||
return Math.floor((this.size / this.ownerModule.size) * ownerModuleSize);
|
||||
}
|
||||
}
|
||||
|
||||
toChartData() {
|
||||
return {
|
||||
...super.toChartData(),
|
||||
inaccurateSizes: true
|
||||
};
|
||||
}
|
||||
|
||||
};
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import _ from 'lodash';
|
||||
import gzipSize from 'gzip-size';
|
||||
|
||||
import Module from './Module';
|
||||
import BaseFolder from './BaseFolder';
|
||||
import ConcatenatedModule from './ConcatenatedModule';
|
||||
import { getModulePathParts } from './utils';
|
||||
|
||||
export default class Folder extends BaseFolder {
|
||||
|
||||
get parsedSize() {
|
||||
return this.src ? this.src.length : 0;
|
||||
}
|
||||
|
||||
get gzipSize() {
|
||||
if (!_.has(this, '_gzipSize')) {
|
||||
this._gzipSize = this.src ? gzipSize.sync(this.src) : 0;
|
||||
}
|
||||
|
||||
return this._gzipSize;
|
||||
}
|
||||
|
||||
addModule(moduleData) {
|
||||
const pathParts = getModulePathParts(moduleData);
|
||||
|
||||
if (!pathParts) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [folders, fileName] = [pathParts.slice(0, -1), _.last(pathParts)];
|
||||
let currentFolder = this;
|
||||
|
||||
_.each(folders, folderName => {
|
||||
let childNode = currentFolder.getChild(folderName);
|
||||
|
||||
if (
|
||||
// Folder is not created yet
|
||||
!childNode ||
|
||||
// In some situations (invalid usage of dynamic `require()`) webpack generates a module with empty require
|
||||
// context, but it's moduleId points to a directory in filesystem.
|
||||
// In this case we replace this `File` node with `Folder`.
|
||||
// See `test/stats/with-invalid-dynamic-require.json` as an example.
|
||||
!(childNode instanceof Folder)
|
||||
) {
|
||||
childNode = currentFolder.addChildFolder(new Folder(folderName));
|
||||
}
|
||||
|
||||
currentFolder = childNode;
|
||||
});
|
||||
|
||||
const ModuleConstructor = moduleData.modules ? ConcatenatedModule : Module;
|
||||
const module = new ModuleConstructor(fileName, moduleData, this);
|
||||
currentFolder.addChildModule(module);
|
||||
}
|
||||
|
||||
toChartData() {
|
||||
return {
|
||||
...super.toChartData(),
|
||||
parsedSize: this.parsedSize,
|
||||
gzipSize: this.gzipSize
|
||||
};
|
||||
}
|
||||
|
||||
};
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import _ from 'lodash';
|
||||
import gzipSize from 'gzip-size';
|
||||
|
||||
import Node from './Node';
|
||||
|
||||
export default class Module extends Node {
|
||||
|
||||
constructor(name, data, parent) {
|
||||
super(name, parent);
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
get src() {
|
||||
return this.data.parsedSrc;
|
||||
}
|
||||
|
||||
set src(value) {
|
||||
this.data.parsedSrc = value;
|
||||
delete this._gzipSize;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.data.size;
|
||||
}
|
||||
|
||||
set size(value) {
|
||||
this.data.size = value;
|
||||
}
|
||||
|
||||
get parsedSize() {
|
||||
return this.src ? this.src.length : undefined;
|
||||
}
|
||||
|
||||
get gzipSize() {
|
||||
if (!_.has(this, '_gzipSize')) {
|
||||
this._gzipSize = this.src ? gzipSize.sync(this.src) : undefined;
|
||||
}
|
||||
|
||||
return this._gzipSize;
|
||||
}
|
||||
|
||||
mergeData(data) {
|
||||
if (data.size) {
|
||||
this.size += data.size;
|
||||
}
|
||||
|
||||
if (data.parsedSrc) {
|
||||
this.src = (this.src || '') + data.parsedSrc;
|
||||
}
|
||||
}
|
||||
|
||||
toChartData() {
|
||||
return {
|
||||
id: this.data.id,
|
||||
label: this.name,
|
||||
path: this.path,
|
||||
statSize: this.size,
|
||||
parsedSize: this.parsedSize,
|
||||
gzipSize: this.gzipSize
|
||||
};
|
||||
}
|
||||
|
||||
};
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
export default class Node {
|
||||
|
||||
constructor(name, parent) {
|
||||
this.name = name;
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
get path() {
|
||||
const path = [];
|
||||
let node = this;
|
||||
|
||||
while (node) {
|
||||
path.push(node.name);
|
||||
node = node.parent;
|
||||
}
|
||||
|
||||
return path.reverse().join('/');
|
||||
}
|
||||
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
const MULTI_MODULE_REGEXP = /^multi /;
|
||||
|
||||
export function getModulePathParts(moduleData) {
|
||||
if (MULTI_MODULE_REGEXP.test(moduleData.identifier)) {
|
||||
return [moduleData.identifier];
|
||||
}
|
||||
|
||||
const parsedPath = _
|
||||
// Removing loaders from module path: they're joined by `!` and the last part is a raw module path
|
||||
.last(moduleData.name.split('!'))
|
||||
// Splitting module path into parts
|
||||
.split('/')
|
||||
// Removing first `.`
|
||||
.slice(1)
|
||||
// Replacing `~` with `node_modules`
|
||||
.map(part => (part === '~' ? 'node_modules' : part));
|
||||
|
||||
return parsedPath.length ? parsedPath : null;
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
const { inspect } = require('util');
|
||||
const _ = require('lodash');
|
||||
|
||||
exports.createAssetsFilter = createAssetsFilter;
|
||||
|
||||
function createAssetsFilter(excludePatterns) {
|
||||
const excludeFunctions = _(excludePatterns)
|
||||
.castArray()
|
||||
.compact()
|
||||
.map(pattern => {
|
||||
if (typeof pattern === 'string') {
|
||||
pattern = new RegExp(pattern);
|
||||
}
|
||||
|
||||
if (_.isRegExp(pattern)) {
|
||||
return (asset) => pattern.test(asset);
|
||||
}
|
||||
|
||||
if (!_.isFunction(pattern)) {
|
||||
throw new TypeError(
|
||||
`Pattern should be either string, RegExp or a function, but "${inspect(pattern, { depth: 0 })}" got.`
|
||||
);
|
||||
}
|
||||
|
||||
return pattern;
|
||||
})
|
||||
.value();
|
||||
|
||||
if (excludeFunctions.length) {
|
||||
return (asset) => _.every(excludeFunctions, fn => fn(asset) !== true);
|
||||
} else {
|
||||
return () => true;
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
|
||||
const WebSocket = require('ws');
|
||||
const _ = require('lodash');
|
||||
const express = require('express');
|
||||
const ejs = require('ejs');
|
||||
const opener = require('opener');
|
||||
const mkdir = require('mkdirp');
|
||||
const { bold } = require('chalk');
|
||||
|
||||
const Logger = require('./Logger');
|
||||
const analyzer = require('./analyzer');
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
|
||||
module.exports = {
|
||||
startServer,
|
||||
generateReport,
|
||||
// deprecated
|
||||
start: startServer
|
||||
};
|
||||
|
||||
async function startServer(bundleStats, opts) {
|
||||
const {
|
||||
port = 8888,
|
||||
host = '127.0.0.1',
|
||||
openBrowser = true,
|
||||
bundleDir = null,
|
||||
logger = new Logger(),
|
||||
defaultSizes = 'parsed',
|
||||
excludeAssets = null
|
||||
} = opts || {};
|
||||
|
||||
const analyzerOpts = { logger, excludeAssets };
|
||||
|
||||
let chartData = getChartData(analyzerOpts, bundleStats, bundleDir);
|
||||
|
||||
if (!chartData) return;
|
||||
|
||||
const app = express();
|
||||
|
||||
// Explicitly using our `ejs` dependency to render templates
|
||||
// Fixes #17
|
||||
app.engine('ejs', require('ejs').renderFile);
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', `${projectRoot}/views`);
|
||||
app.use(express.static(`${projectRoot}/public`));
|
||||
|
||||
app.use('/', (req, res) => {
|
||||
res.render('viewer', {
|
||||
mode: 'server',
|
||||
get chartData() { return JSON.stringify(chartData) },
|
||||
defaultSizes: JSON.stringify(defaultSizes)
|
||||
});
|
||||
});
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
await new Promise(resolve => {
|
||||
server.listen(port, host, () => {
|
||||
resolve();
|
||||
|
||||
const url = `http://${host}:${server.address().port}`;
|
||||
|
||||
logger.info(
|
||||
`${bold('Webpack Bundle Analyzer')} is started at ${bold(url)}\n` +
|
||||
`Use ${bold('Ctrl+C')} to close it`
|
||||
);
|
||||
|
||||
if (openBrowser) {
|
||||
opener(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const wss = new WebSocket.Server({ server });
|
||||
|
||||
wss.on('connection', ws => {
|
||||
ws.on('error', err => {
|
||||
// Ignore network errors like `ECONNRESET`, `EPIPE`, etc.
|
||||
if (err.errno) return;
|
||||
|
||||
logger.info(err.message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
ws: wss,
|
||||
http: server,
|
||||
updateChartData
|
||||
};
|
||||
|
||||
function updateChartData(bundleStats) {
|
||||
const newChartData = getChartData(analyzerOpts, bundleStats, bundleDir);
|
||||
|
||||
if (!newChartData) return;
|
||||
|
||||
chartData = newChartData;
|
||||
|
||||
wss.clients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify({
|
||||
event: 'chartDataUpdated',
|
||||
data: newChartData
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function generateReport(bundleStats, opts) {
|
||||
const {
|
||||
openBrowser = true,
|
||||
reportFilename = 'report.html',
|
||||
bundleDir = null,
|
||||
logger = new Logger(),
|
||||
defaultSizes = 'parsed',
|
||||
excludeAssets = null
|
||||
} = opts || {};
|
||||
|
||||
const chartData = getChartData({ logger, excludeAssets }, bundleStats, bundleDir);
|
||||
|
||||
if (!chartData) return;
|
||||
|
||||
ejs.renderFile(
|
||||
`${projectRoot}/views/viewer.ejs`,
|
||||
{
|
||||
mode: 'static',
|
||||
chartData: JSON.stringify(chartData),
|
||||
assetContent: getAssetContent,
|
||||
defaultSizes: JSON.stringify(defaultSizes)
|
||||
},
|
||||
(err, reportHtml) => {
|
||||
if (err) return logger.error(err);
|
||||
|
||||
const reportFilepath = path.resolve(bundleDir || process.cwd(), reportFilename);
|
||||
|
||||
mkdir.sync(path.dirname(reportFilepath));
|
||||
fs.writeFileSync(reportFilepath, reportHtml);
|
||||
|
||||
logger.info(
|
||||
`${bold('Webpack Bundle Analyzer')} saved report to ${bold(reportFilepath)}`
|
||||
);
|
||||
|
||||
if (openBrowser) {
|
||||
opener(`file://${reportFilepath}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getAssetContent(filename) {
|
||||
return fs.readFileSync(`${projectRoot}/public/${filename}`, 'utf8');
|
||||
}
|
||||
|
||||
function getChartData(analyzerOpts, ...args) {
|
||||
let chartData;
|
||||
const { logger } = analyzerOpts;
|
||||
|
||||
try {
|
||||
chartData = analyzer.getViewerData(...args, analyzerOpts);
|
||||
} catch (err) {
|
||||
logger.error(`Could't analyze webpack bundle:\n${err}`);
|
||||
logger.debug(err.stack);
|
||||
chartData = null;
|
||||
}
|
||||
|
||||
if (_.isPlainObject(chartData) && _.isEmpty(chartData)) {
|
||||
logger.error("Could't find any javascript bundles in provided stats file");
|
||||
chartData = null;
|
||||
}
|
||||
|
||||
return chartData;
|
||||
}
|
||||
Reference in New Issue
Block a user