├── .babelrc ├── .gitignore ├── README.md ├── package-lock.json ├── package.json └── src ├── webpackBundleOptimizeHelper.js └── webpackBundleOptimizeHelper.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webpack optimize helper 2 | 3 | This is a JavaScript library that reads a webpack `stats.json` file and gives back useful statistics about the file that helps the dev to reduce the bundle size. 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack-optimize-helper", 3 | "version": "1.1.0", 4 | "description": "", 5 | "main": "src/webpackBundleOptimizeHelper.js", 6 | "scripts": { 7 | "test": "jest" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "babel-core": "^6.26.3", 14 | "babel-jest": "^23.6.0", 15 | "babel-preset-env": "^1.7.0", 16 | "jest": "^23.6.0", 17 | "regenerator-runtime": "^0.13.1" 18 | } 19 | } -------------------------------------------------------------------------------- /src/webpackBundleOptimizeHelper.js: -------------------------------------------------------------------------------- 1 | const concat = (x, y) => x.concat(y); 2 | 3 | const flatMap = (f, xs) => xs.map(f).reduce(concat, []); 4 | 5 | //https://stackoverflow.com/questions/15900485/correct-way-to-convert-size-in-bytes-to-kb-mb-gb-in-javascript 6 | function formatBytes(a, b) { 7 | if (0 === a) return "0 Bytes"; 8 | var c = 1024, 9 | d = b || 2, 10 | e = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"], 11 | f = Math.floor(Math.log(a) / Math.log(c)); 12 | return parseFloat((a / Math.pow(c, f)).toFixed(d)) + " " + e[f]; 13 | } 14 | 15 | function trimJsonString(json) { 16 | return json.substring(json.indexOf("{")); 17 | } 18 | 19 | const getDependencyNameFromModuleName = (m) => { 20 | // eslint-disable-next-line 21 | const regexResult = /.\/node_modules\/([^\/]+)\/.*/.exec(m); 22 | return regexResult && regexResult.length > 0 ? regexResult[1] : m; 23 | //return m 24 | }; 25 | 26 | function getEntrypointAssets(entrypoints) { 27 | const entrypointKeys = Object.keys(entrypoints); 28 | return flatMap( 29 | (entrypoint) => entrypoints[entrypoint].assets, 30 | entrypointKeys 31 | ).map((asset) => { 32 | // Webpack 5 uses an object and webpack 4 a string 33 | return typeof asset == "string" ? asset : asset.name; 34 | }); 35 | } 36 | 37 | function entrypointsContainsJS(entrypoints) { 38 | return ( 39 | getEntrypointAssets(entrypoints).filter((asset) => asset.indexOf(".js") > 0) 40 | .length > 0 41 | ); 42 | } 43 | function isValidStatsFile(json) { 44 | return !!json.entrypoints && !!json.assets; 45 | } 46 | 47 | function childHasJsEntry(child) { 48 | if (isValidStatsFile(child)) { 49 | return entrypointsContainsJS(child.entrypoints); 50 | } 51 | return false; 52 | } 53 | function findChildWithJSEntry(json) { 54 | if (childHasJsEntry(json)) { 55 | return json; 56 | } else { 57 | if (json.children) { 58 | return json.children.find((child) => childHasJsEntry(child)); 59 | } else { 60 | return null; 61 | } 62 | } 63 | } 64 | 65 | function getDependenciesNotEs6(modulesRaw) { 66 | if (!modulesRaw) { 67 | return []; 68 | } 69 | const modules = modulesRaw.map((m) => { 70 | const optimizationBailout = m.optimizationBailout 71 | ? m.optimizationBailout.filter( 72 | (b) => 73 | b === 74 | "ModuleConcatenation bailout: Module is not an ECMAScript module" 75 | ) 76 | : []; 77 | return { 78 | name: m.name, 79 | notEs6: optimizationBailout.length !== 0, 80 | size: m.size, 81 | }; 82 | }); 83 | // const removeDuplicate = (elem, pos, arr) => arr.indexOf(elem) === pos 84 | const dependenciesNotEs6 = modules 85 | .filter((m) => !m.name.startsWith("(webpack)")) 86 | .filter( 87 | (m) => 88 | m.name.startsWith("./node_modules") || 89 | m.name.startsWith("../node_modules") 90 | ) 91 | .map((m) => 92 | Object.assign({}, m, { 93 | name: getDependencyNameFromModuleName(m.name), 94 | }) 95 | ) 96 | .filter((m) => m.notEs6) 97 | //remove duplicates 98 | .reduce((result = [], object) => { 99 | const existing = result.find((r) => r.name === object.name); 100 | 101 | if (existing) { 102 | const newObject = { 103 | name: object.name, 104 | notEs6: object.notEs6, 105 | size: existing.size + object.size, 106 | }; 107 | return concat( 108 | result.filter((r) => r.name !== object.name), 109 | newObject 110 | ); 111 | } else { 112 | return result.concat(object); 113 | } 114 | }, []); 115 | //.map(m => m.name) 116 | //.filter(removeDuplicate) 117 | 118 | return dependenciesNotEs6; 119 | } 120 | 121 | function endsWith(str, suffix) { 122 | return str.indexOf(suffix, str.length - suffix.length) !== -1; 123 | } 124 | 125 | function getDataFromStatsJson(statsJson) { 126 | if (!statsJson) { 127 | return null; 128 | } 129 | const json = findChildWithJSEntry(statsJson); 130 | if (!json) { 131 | return null; 132 | } 133 | const entrypointAssets = getEntrypointAssets(json.entrypoints); 134 | 135 | const entrypointAssetSizes = json.assets 136 | .filter((a) => entrypointAssets.includes(a.name)) 137 | .filter((a) => !endsWith(a.name, ".map")) //don't show sourcemaps 138 | .map((a) => ({ 139 | name: a.name, 140 | size: formatBytes(a.size), 141 | sizeByte: a.size, 142 | })); 143 | 144 | const entrypointAssetSizeTotal = entrypointAssetSizes.reduce( 145 | (acc, o) => acc + o.sizeByte, 146 | 0 147 | ); 148 | 149 | //TODO check if moduleconcatenation plugin is in use 150 | 151 | //console.log('modules', dependenciesNotEs6) 152 | //console.log('entrypointAssetSizes', entrypointAssetSizes) 153 | 154 | return { 155 | dependenciesNotEs6: getDependenciesNotEs6(json.modules), 156 | entrypointAssetSizes, 157 | entrypointAssetSizeTotal, 158 | }; 159 | } 160 | /* 161 | This could be a webpack plugin. Then the code would go here 162 | */ 163 | class BundleOptimizeHelperWebpackPlugin { 164 | apply(compiler) { 165 | compiler.hooks.done.tap("BundleOptimizeHelperWebpackPlugin", (stats) => { 166 | const json = stats.toJson({ source: false }); 167 | const data = getDataFromStatsJson(json); 168 | // do stuffs here 169 | }); 170 | } 171 | } 172 | 173 | module.exports = { 174 | formatBytes, 175 | trimJsonString, 176 | entrypointsContainsJS, 177 | isValidStatsFile, 178 | findChildWithJSEntry, 179 | getDataFromStatsJson, 180 | BundleOptimizeHelperWebpackPlugin, 181 | }; 182 | -------------------------------------------------------------------------------- /src/webpackBundleOptimizeHelper.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | entrypointsContainsJS, 3 | findChildWithJSEntry, 4 | trimJsonString, 5 | } from './webpackBundleOptimizeHelper' 6 | 7 | test('trimJsonString should trim json', () => { 8 | const jsonString = `some stuff here 9 | { 10 | "errors": [] 11 | }` 12 | expect(trimJsonString(jsonString)).toEqual(`{ 13 | "errors": [] 14 | }`) 15 | }) 16 | 17 | test('entrypointContainsJS contains js should return true', () => { 18 | const entrypointWithJs = { 19 | app: { 20 | chunks: [3, 2], 21 | assets: [ 22 | 'static/vendor.a9426aeed76ba0927898.js', 23 | 'styles/styles.a9426aeed76ba0927898.css', 24 | 'static/vendor.a9426aeed76ba0927898.js.map', 25 | 'styles/styles.a9426aeed76ba0927898.css.map', 26 | 'static/app.a9426aeed76ba0927898.js', 27 | 'styles/styles.a9426aeed76ba0927898.css', 28 | 'static/app.a9426aeed76ba0927898.js.map', 29 | 'styles/styles.a9426aeed76ba0927898.css.map', 30 | ], 31 | isOverSizeLimit: true, 32 | }, 33 | } 34 | 35 | expect(entrypointsContainsJS(entrypointWithJs)).toBe(true) 36 | }) 37 | 38 | test('entrypointContainsJS contains no js should return false', () => { 39 | const entrypointWithJs = { 40 | undefined: { 41 | chunks: [0], 42 | assets: ['index.html'], 43 | }, 44 | } 45 | 46 | expect(entrypointsContainsJS(entrypointWithJs)).toBe(false) 47 | }) 48 | 49 | test('findChildWithJSEntry: stats file with no js entry should return null', () => { 50 | const statsJson = {} 51 | 52 | expect(findChildWithJSEntry(statsJson)).toBe(null) 53 | }) 54 | 55 | test('findChildWithJSEntry: stats file with js entry, modules and assets should return itself', () => { 56 | const statsJson = { 57 | entrypoints: { 58 | app: { 59 | chunks: [3, 2], 60 | assets: [ 61 | 'static/vendor.a9426aeed76ba0927898.js', 62 | 'styles/styles.a9426aeed76ba0927898.css', 63 | ], 64 | isOverSizeLimit: true, 65 | }, 66 | }, 67 | assets: [ 68 | { 69 | name: 'static/DynamicPage.a9426aeed76ba0927898.js.map', 70 | size: 810, 71 | chunks: [1], 72 | chunkNames: ['DynamicPage'], 73 | emitted: true, 74 | }, 75 | ], 76 | modules: [ 77 | { 78 | id: 0, 79 | identifier: 80 | '/home/jlind/dev/boilerplates/react-starter-boilerplate-hmr/node_modules/react/index.js', 81 | name: './node_modules/react/index.js', 82 | index: 90, 83 | index2: 88, 84 | size: 190, 85 | cacheable: true, 86 | built: true, 87 | }, 88 | ], 89 | } 90 | 91 | expect(findChildWithJSEntry(statsJson)).toBe(statsJson) 92 | }) 93 | 94 | test('findChildWithJSEntry: stats file with NO js entry, but modules and assets and no children should return null', () => { 95 | const statsJson = { 96 | entrypoints: { 97 | app: { 98 | chunks: [3, 2], 99 | assets: ['styles/styles.a9426aeed76ba0927898.css'], 100 | isOverSizeLimit: true, 101 | }, 102 | }, 103 | assets: [ 104 | { 105 | name: 'static/DynamicPage.a9426aeed76ba0927898.js.map', 106 | size: 810, 107 | chunks: [1], 108 | chunkNames: ['DynamicPage'], 109 | emitted: true, 110 | }, 111 | ], 112 | modules: [ 113 | { 114 | id: 0, 115 | identifier: 116 | '/home/jlind/dev/boilerplates/react-starter-boilerplate-hmr/node_modules/react/index.js', 117 | name: './node_modules/react/index.js', 118 | index: 90, 119 | index2: 88, 120 | size: 190, 121 | cacheable: true, 122 | built: true, 123 | }, 124 | ], 125 | } 126 | 127 | expect(findChildWithJSEntry(statsJson)).toBe(null) 128 | }) 129 | 130 | test('findChildWithJSEntry: invalid root, but first child valid should return first child ', () => { 131 | const statsJson = { 132 | children: [ 133 | { 134 | entrypoints: { 135 | app: { 136 | chunks: [3, 2], 137 | assets: ['styles/styles.a9426aeed76ba0927898.js'], 138 | isOverSizeLimit: true, 139 | }, 140 | }, 141 | assets: [ 142 | { 143 | name: 'static/DynamicPage.a9426aeed76ba0927898.js.map', 144 | size: 810, 145 | chunks: [1], 146 | chunkNames: ['DynamicPage'], 147 | emitted: true, 148 | }, 149 | ], 150 | modules: [ 151 | { 152 | id: 0, 153 | identifier: 154 | '/home/jlind/dev/boilerplates/react-starter-boilerplate-hmr/node_modules/react/index.js', 155 | name: './node_modules/react/index.js', 156 | index: 90, 157 | index2: 88, 158 | size: 190, 159 | cacheable: true, 160 | built: true, 161 | }, 162 | ], 163 | }, 164 | ], 165 | } 166 | 167 | expect(findChildWithJSEntry(statsJson)).toBe(statsJson.children[0]) 168 | }) 169 | 170 | test('findChildWithJSEntry: invalid root, but second child valid should return second child ', () => { 171 | const statsJson = { 172 | children: [ 173 | { 174 | entrypoints: { 175 | undefined: { 176 | chunks: [0], 177 | assets: ['index.html'], 178 | }, 179 | }, 180 | assets: [ 181 | { 182 | name: 'index.html', 183 | size: 544488, 184 | chunks: [0], 185 | chunkNames: [], 186 | }, 187 | ], 188 | modules: [ 189 | { 190 | id: 0, 191 | identifier: '/usr/lib/node_modules/webpack/buildin/module.js', 192 | name: '(webpack)/buildin/module.js', 193 | index: 3, 194 | index2: 1, 195 | size: 519, 196 | cacheable: true, 197 | built: true, 198 | }, 199 | ], 200 | }, 201 | { 202 | entrypoints: { 203 | app: { 204 | chunks: [3, 2], 205 | assets: ['styles/styles.a9426aeed76ba0927898.js'], 206 | isOverSizeLimit: true, 207 | }, 208 | }, 209 | assets: [ 210 | { 211 | name: 'static/DynamicPage.a9426aeed76ba0927898.js.map', 212 | size: 810, 213 | chunks: [1], 214 | chunkNames: ['DynamicPage'], 215 | emitted: true, 216 | }, 217 | ], 218 | modules: [ 219 | { 220 | id: 0, 221 | identifier: 222 | '/home/jlind/dev/boilerplates/react-starter-boilerplate-hmr/node_modules/react/index.js', 223 | name: './node_modules/react/index.js', 224 | index: 90, 225 | index2: 88, 226 | size: 190, 227 | cacheable: true, 228 | built: true, 229 | }, 230 | ], 231 | }, 232 | ], 233 | } 234 | 235 | expect(findChildWithJSEntry(statsJson)).toBe(statsJson.children[1]) 236 | }) 237 | --------------------------------------------------------------------------------