This commit is contained in:
Connor Peet 2019-06-04 18:56:11 -07:00
Родитель 8517fd240e
Коммит 41041d0b3d
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: CF8FD2EA0DBC61BD
59 изменённых файлов: 2898 добавлений и 348 удалений

255
package-lock.json сгенерированный
Просмотреть файл

@ -51,6 +51,12 @@
"integrity": "sha1-G44zthqMCcvh+FEzBxuqDb+fpxo=",
"dev": true
},
"@types/cytoscape": {
"version": "3.4.3",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/cytoscape/-/cytoscape-3.4.3.tgz",
"integrity": "sha1-i5NTFU3IlSMc00TtHH7/LRORwQM=",
"dev": true
},
"@types/events": {
"version": "3.0.0",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/events/-/events-3.0.0.tgz",
@ -99,6 +105,12 @@
"hoist-non-react-statics": "^3.3.0"
}
},
"@types/js-base64": {
"version": "2.3.1",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/js-base64/-/js-base64-2.3.1.tgz",
"integrity": "sha1-w58U8SlAij2WoRBaZQ2LK27rQWg=",
"dev": true
},
"@types/minimatch": {
"version": "3.0.3",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/minimatch/-/minimatch-3.0.3.tgz",
@ -111,12 +123,27 @@
"integrity": "sha1-MV1XDMtWxTRS/4Y4c432BybVtuo=",
"dev": true
},
"@types/msgpack-lite": {
"version": "0.1.6",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/msgpack-lite/-/msgpack-lite-0.1.6.tgz",
"integrity": "sha1-J+Kn7qRRTwhO1Pm1P49j5ttNbVA=",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/node": {
"version": "12.0.4",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/node/-/node-12.0.4.tgz",
"integrity": "sha1-RoMhgxFckEQQwnXjTPlAOZKZnDI=",
"dev": true
},
"@types/pako": {
"version": "1.0.1",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/pako/-/pako-1.0.1.tgz",
"integrity": "sha1-M7I388mv9E0Pgv5jrP+ko2XvSmE=",
"dev": true
},
"@types/prop-types": {
"version": "15.7.1",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/prop-types/-/prop-types-15.7.1.tgz",
@ -175,6 +202,21 @@
"@types/react-router": "*"
}
},
"@types/react-tabs": {
"version": "2.3.1",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/react-tabs/-/react-tabs-2.3.1.tgz",
"integrity": "sha1-YtMyJmesIoxND582rJ+GmIwLOXE=",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/sigmajs": {
"version": "1.0.27",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/sigmajs/-/sigmajs-1.0.27.tgz",
"integrity": "sha1-NoA1/IS58pVNqTLuaMrG50tEd6w=",
"dev": true
},
"@types/tapable": {
"version": "1.0.4",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/tapable/-/tapable-1.0.4.tgz",
@ -1390,6 +1432,12 @@
}
}
},
"classnames": {
"version": "2.2.6",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/classnames/-/classnames-2.2.6.tgz",
"integrity": "sha1-Q5Nb/90pHzJtrQogUwmzjQD2UM4=",
"dev": true
},
"clean-css": {
"version": "4.2.1",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/clean-css/-/clean-css-4.2.1.tgz",
@ -1613,6 +1661,26 @@
"integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=",
"dev": true
},
"copy-webpack-plugin": {
"version": "5.0.3",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/copy-webpack-plugin/-/copy-webpack-plugin-5.0.3.tgz",
"integrity": "sha1-IXnjyP1p8Tr+dNoziJbx8BqHW1w=",
"dev": true,
"requires": {
"cacache": "^11.3.2",
"find-cache-dir": "^2.1.0",
"glob-parent": "^3.1.0",
"globby": "^7.1.1",
"is-glob": "^4.0.1",
"loader-utils": "^1.2.3",
"minimatch": "^3.0.4",
"normalize-path": "^3.0.0",
"p-limit": "^2.2.0",
"schema-utils": "^1.0.0",
"serialize-javascript": "^1.7.0",
"webpack-log": "^2.0.0"
}
},
"core-js": {
"version": "1.2.7",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/core-js/-/core-js-1.2.7.tgz",
@ -1625,6 +1693,15 @@
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
"dev": true
},
"cose-base": {
"version": "1.0.0",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/cose-base/-/cose-base-1.0.0.tgz",
"integrity": "sha1-pEGX6DWzCytb9Kt+ndBuYgHIeYw=",
"dev": true,
"requires": {
"layout-base": "^1.0.0"
}
},
"create-ecdh": {
"version": "4.0.3",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/create-ecdh/-/create-ecdh-4.0.3.tgz",
@ -1779,6 +1856,45 @@
"integrity": "sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA=",
"dev": true
},
"cytoscape": {
"version": "3.7.0",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/cytoscape/-/cytoscape-3.7.0.tgz",
"integrity": "sha1-+AAMoo3MD1FbStaRGXzzlo+SvS4=",
"dev": true,
"requires": {
"heap": "^0.2.6",
"lodash.debounce": "^4.0.8"
}
},
"cytoscape-dagre": {
"version": "2.2.2",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/cytoscape-dagre/-/cytoscape-dagre-2.2.2.tgz",
"integrity": "sha1-XzKoXAuoNfFn7+5THfnomsWP9BE=",
"dev": true,
"requires": {
"dagre": "^0.8.2"
}
},
"cytoscape-fcose": {
"version": "1.0.0",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/cytoscape-fcose/-/cytoscape-fcose-1.0.0.tgz",
"integrity": "sha1-/ZFReo0fhKpIfpX/o0tSWgrhmhs=",
"dev": true,
"requires": {
"cose-base": "^1.0.0",
"numeric": "1.2.6"
}
},
"dagre": {
"version": "0.8.4",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/dagre/-/dagre-0.8.4.tgz",
"integrity": "sha1-Jrn7j3vcYMYRCgRYw3UmGDZ4YGE=",
"dev": true,
"requires": {
"graphlib": "^2.1.7",
"lodash": "^4.17.4"
}
},
"dashdash": {
"version": "1.14.1",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/dashdash/-/dashdash-1.14.1.tgz",
@ -1997,6 +2113,32 @@
"randombytes": "^2.0.0"
}
},
"dir-glob": {
"version": "2.2.2",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/dir-glob/-/dir-glob-2.2.2.tgz",
"integrity": "sha1-+gnwaUFTyJGLGLoN6vrpR2n8UMQ=",
"dev": true,
"requires": {
"path-type": "^3.0.0"
},
"dependencies": {
"path-type": {
"version": "3.0.0",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/path-type/-/path-type-3.0.0.tgz",
"integrity": "sha1-zvMdyOCho7sNEFwM2Xzzv0f0428=",
"dev": true,
"requires": {
"pify": "^3.0.0"
}
},
"pify": {
"version": "3.0.0",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/pify/-/pify-3.0.0.tgz",
"integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
"dev": true
}
}
},
"dns-equal": {
"version": "1.0.0",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/dns-equal/-/dns-equal-1.0.0.tgz",
@ -2266,6 +2408,12 @@
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=",
"dev": true
},
"event-lite": {
"version": "0.1.2",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/event-lite/-/event-lite-0.1.2.tgz",
"integrity": "sha1-g4o+D93e+MyQ8SgAbI5VpOTkwRs=",
"dev": true
},
"eventemitter3": {
"version": "3.1.2",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/eventemitter3/-/eventemitter3-3.1.2.tgz",
@ -2790,6 +2938,12 @@
"is-buffer": "~2.0.3"
}
},
"flexboxgrid": {
"version": "6.3.1",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/flexboxgrid/-/flexboxgrid-6.3.1.tgz",
"integrity": "sha1-6ZiYr8B7cEdyK7galYpfuk1OIP0=",
"dev": true
},
"flush-write-stream": {
"version": "1.1.1",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/flush-write-stream/-/flush-write-stream-1.1.1.tgz",
@ -3630,6 +3784,28 @@
"which": "^1.2.14"
}
},
"globby": {
"version": "7.1.1",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/globby/-/globby-7.1.1.tgz",
"integrity": "sha1-+yzP+UAfhgCUXfral0QMypcrhoA=",
"dev": true,
"requires": {
"array-union": "^1.0.1",
"dir-glob": "^2.0.0",
"glob": "^7.1.2",
"ignore": "^3.3.5",
"pify": "^3.0.0",
"slash": "^1.0.0"
},
"dependencies": {
"pify": {
"version": "3.0.0",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/pify/-/pify-3.0.0.tgz",
"integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
"dev": true
}
}
},
"globule": {
"version": "1.2.1",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/globule/-/globule-1.2.1.tgz",
@ -3647,6 +3823,15 @@
"integrity": "sha1-/7cD4QZuig7qpMi4C6klPu77+wA=",
"dev": true
},
"graphlib": {
"version": "2.1.7",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/graphlib/-/graphlib-2.1.7.tgz",
"integrity": "sha1-tqafn0S9neOWPOaASi/J5z2Grsw=",
"dev": true,
"requires": {
"lodash": "^4.17.5"
}
},
"growl": {
"version": "1.10.5",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/growl/-/growl-1.10.5.tgz",
@ -3809,6 +3994,12 @@
"integrity": "sha1-hK5l+n6vsWX922FWauFLrwVmTw8=",
"dev": true
},
"heap": {
"version": "0.2.6",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/heap/-/heap-0.2.6.tgz",
"integrity": "sha1-CH4fELBGky/IWU3Z5tN4r8nR5aw=",
"dev": true
},
"history": {
"version": "4.9.0",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/history/-/history-4.9.0.tgz",
@ -4179,6 +4370,12 @@
"integrity": "sha1-xg7taebY/bazEEofy8ocGS3FtQE=",
"dev": true
},
"ignore": {
"version": "3.3.10",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/ignore/-/ignore-3.3.10.tgz",
"integrity": "sha1-Cpf7h2mG6AgcYxFg+PnziRV/AEM=",
"dev": true
},
"import-local": {
"version": "2.0.0",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/import-local/-/import-local-2.0.0.tgz",
@ -4244,6 +4441,12 @@
"integrity": "sha1-7uJfVtscnsYIXgwid4CD9Zar+Sc=",
"dev": true
},
"int64-buffer": {
"version": "0.1.10",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/int64-buffer/-/int64-buffer-0.1.10.tgz",
"integrity": "sha1-J3siiofZWtd30HwTgyAiQGpHNCM=",
"dev": true
},
"internal-ip": {
"version": "4.3.0",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/internal-ip/-/internal-ip-4.3.0.tgz",
@ -4662,6 +4865,12 @@
"integrity": "sha1-ARRrNqYhjmTljzqNZt5df8b20FE=",
"dev": true
},
"layout-base": {
"version": "1.0.0",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/layout-base/-/layout-base-1.0.0.tgz",
"integrity": "sha1-s1VjO+V/+ifZWd0kcD4Yfvg828M=",
"dev": true
},
"lcid": {
"version": "2.0.0",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/lcid/-/lcid-2.0.0.tgz",
@ -4725,6 +4934,12 @@
"integrity": "sha1-s56mIp72B+zYniyN8SU2iRysm40=",
"dev": true
},
"lodash.debounce": {
"version": "4.0.8",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=",
"dev": true
},
"lodash.tail": {
"version": "4.1.1",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/lodash.tail/-/lodash.tail-4.1.1.tgz",
@ -5099,6 +5314,18 @@
"integrity": "sha1-MKWGTrPrsKZvLr5tcnrwagnYbgo=",
"dev": true
},
"msgpack-lite": {
"version": "0.1.26",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/msgpack-lite/-/msgpack-lite-0.1.26.tgz",
"integrity": "sha1-3TxQsm8FnyXn7e42REGDWOKprYk=",
"dev": true,
"requires": {
"event-lite": "^0.1.1",
"ieee754": "^1.1.8",
"int64-buffer": "^0.1.9",
"isarray": "^1.0.0"
}
},
"multicast-dns": {
"version": "6.2.3",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/multicast-dns/-/multicast-dns-6.2.3.tgz",
@ -5422,6 +5649,12 @@
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
"dev": true
},
"numeric": {
"version": "1.2.6",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/numeric/-/numeric-1.2.6.tgz",
"integrity": "sha1-dlsCvvl5iPz4gNTrPza4D6MTNao=",
"dev": true
},
"oauth-sign": {
"version": "0.9.0",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/oauth-sign/-/oauth-sign-0.9.0.tgz",
@ -6267,6 +6500,16 @@
"tiny-warning": "^1.0.0"
}
},
"react-tabs": {
"version": "3.0.0",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/react-tabs/-/react-tabs-3.0.0.tgz",
"integrity": "sha1-YDEaF8dV62qpszEBI+Z9tCFgUSc=",
"dev": true,
"requires": {
"classnames": "^2.2.0",
"prop-types": "^15.5.0"
}
},
"read-pkg": {
"version": "1.1.0",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/read-pkg/-/read-pkg-1.1.0.tgz",
@ -7149,12 +7392,24 @@
"integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=",
"dev": true
},
"sigma": {
"version": "1.2.1",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/sigma/-/sigma-1.2.1.tgz",
"integrity": "sha1-Lr898pcXFa/kmtDAiUXFdLtsV3E=",
"dev": true
},
"signal-exit": {
"version": "3.0.2",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/signal-exit/-/signal-exit-3.0.2.tgz",
"integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=",
"dev": true
},
"slash": {
"version": "1.0.0",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/slash/-/slash-1.0.0.tgz",
"integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=",
"dev": true
},
"snapdragon": {
"version": "0.8.2",
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/snapdragon/-/snapdragon-0.8.2.tgz",

Просмотреть файл

@ -4,10 +4,10 @@
"description": "Tool to compare webpack bundle files",
"main": "index.js",
"scripts": {
"fmt": "prettier --write \"src/**/*.{json,ts}\" && npm run test:lint -- --fix",
"fmt": "prettier --write \"src/**/*.{tsx,ts}\" && npm run test:lint -- --fix",
"test": "mocha --opts mocha.opts && npm run test:fmt && npm run test:lint",
"test:fmt": "prettier --list-different \"src/**/*.{js,json,ts}\" || echo \"Run npm run fmt to fix formatting on these files\"",
"test:lint": "tslint --project tsconfig.json \"src/**/*.ts\"",
"test:fmt": "prettier --list-different \"src/**/*.{tsx,ts}\" || echo \"Run npm run fmt to fix formatting on these files\"",
"test:lint": "tslint --project tsconfig.json \"src/**/*.{ts,tsx}\"",
"prepare": "tsc",
"start": "webpack-dev-server"
},
@ -31,37 +31,53 @@
"devDependencies": {
"@mixer/retrieval": "^2.0.2",
"@types/chai": "^4.1.7",
"@types/cytoscape": "^3.4.3",
"@types/filesize": "^4.1.0",
"@types/fixed-data-table-2": "^0.8.3",
"@types/js-base64": "^2.3.1",
"@types/mocha": "^5.2.7",
"@types/msgpack-lite": "^0.1.6",
"@types/node": "^12.0.4",
"@types/pako": "^1.0.1",
"@types/react": "^16.8.19",
"@types/react-dom": "^16.8.4",
"@types/react-redux": "^7.0.9",
"@types/react-router-dom": "^4.3.3",
"@types/react-tabs": "^2.3.1",
"@types/sigmajs": "^1.0.27",
"@types/webpack": "^4.4.32",
"chai": "^4.2.0",
"copy-webpack-plugin": "^5.0.3",
"css-loader": "^2.1.1",
"cytoscape": "^3.7.0",
"cytoscape-dagre": "^2.2.2",
"cytoscape-fcose": "^1.0.0",
"dayjs": "^1.8.14",
"filesize": "^4.1.2",
"fixed-data-table-2": "^0.8.26",
"flexboxgrid": "^6.3.1",
"fs-extra": "^8.0.1",
"html-webpack-plugin": "^3.2.0",
"js-base64": "^2.5.1",
"mocha": "^6.1.4",
"msgpack-lite": "^0.1.26",
"node-sass": "^4.12.0",
"normalize.css": "^8.0.1",
"pako": "^1.0.10",
"prettier": "^1.17.1",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-icons": "^3.7.0",
"react-redux": "^7.0.3",
"react-router-dom": "^5.0.0",
"react-tabs": "^3.0.0",
"redux": "^4.0.1",
"redux-devtools-extension": "^2.13.8",
"redux-observable": "^1.1.0",
"reselect": "^4.0.0",
"rxjs": "^6.5.2",
"sass-loader": "^7.1.0",
"sigma": "^1.2.1",
"style-loader": "^0.23.1",
"ts-loader": "^6.0.2",
"ts-node": "^8.2.0",

Просмотреть файл

@ -11,3 +11,7 @@ To develop against the UI:
1. Create a folder called "public/samples", and place JSON files in there.
2. Set the `WBC_FILES` environment variable to a comma-delimited list of the filenames you placed in there.
3. Running the webpack dev server via `npm start` will now serve the files you have placed in there.
### Limitations
The main degraded area in our reporting deals with concatenated modules. Essentially, webpack gives us a list of modules and concatenated modules at the top level of their stats output. The concatenated modules are nested inside the parent module, and will contain the full set of module information. However, they don't have module IDs, and when Webpack reports that a given module was imported, it only reports the top-level concatenation. So we can drill into single modules and concatenated bundles fairly well, but we can't cross-reference imports cleanly.

Просмотреть файл

@ -0,0 +1,22 @@
.wrapper {
font-size: 0.9em;
color: rgba(255, 255, 255, 0.8);
:global .row {
margin-top: 16px;
}
p {
line-height: 1.5em;
}
}
.icons {
text-align: center;
margin-top: 16px;
font-size: 2.5em;
a {
margin-right: 0.5em;
}
}

Просмотреть файл

@ -0,0 +1,128 @@
import { Retrieval, RetrievalState, shouldAttempt } from '@mixer/retrieval';
import * as filesize from 'filesize';
import * as React from 'react';
import { IoLogoGithub, IoLogoNpm } from 'react-icons/io';
import { connect } from 'react-redux';
import { fetchBundlephobiaData } from '../redux/actions';
import { getBundlephobiaData, IAppState } from '../redux/reducer';
import { IBundlephobiaStats } from '../redux/services/bundlephobia-api';
import * as styles from './bundlephobia-stats.component.scss';
import { Errors } from './errors.component';
import { BasePanel } from './panels/base-panel.component';
import { BooleanPanel } from './panels/boolean-panel.component';
import { CounterPanel } from './panels/counter-panel.component';
import { Placeholder } from './placeholder.component';
import { IndefiniteProgressBar } from './progress-bar.component';
import { color } from './util';
interface IProps {
name: string;
stats: Retrieval<IBundlephobiaStats>;
retrieve(name: string): void;
}
class BundlephobiaStatsComponent extends React.PureComponent<IProps> {
public componentDidMount() {
if (shouldAttempt(this.props.stats)) {
this.props.retrieve(this.props.name);
}
}
public componentDidUpdate(prevProps: IProps) {
if (this.props.name !== prevProps.name && shouldAttempt(this.props.stats)) {
this.props.retrieve(this.props.name);
}
}
public render() {
const stats = this.props.stats;
switch (stats.state) {
case RetrievalState.Errored:
if (stats.error.statusCode === 404) {
return (
<Placeholder>
This package does not appear to be published on the npm registry.
</Placeholder>
);
}
return <Errors errors={stats.error} />;
case RetrievalState.Succeeded:
return (
<div className={styles.wrapper}>
<p>
The following information was retrieved from the latest version of this package. You
may be using an older version of the package in your build--Webpack stats do not tell
us your package version.{' '}
<a href={`https://bundlephobia.com/result?p=${this.props.name}`} target="_blank">
View this package on Bundlephobia
</a>{' '}
for more information.
</p>
<div className="row">
<div className="col-xs-6">
<BasePanel
value={stats.value.version}
color={color.blue}
title={'Latest Version'}
/>
</div>
<div className="col-xs-6">
<CounterPanel value={stats.value.dependencyCount} title={'Dependencies'} />
</div>
</div>
<div className="row">
<div className="col-xs-6">
<CounterPanel
value={stats.value.size}
title={'Minified Sized'}
formatter={filesize}
/>
</div>
<div className="col-xs-6">
<CounterPanel
value={stats.value.gzip}
title={'Gzipped Sized'}
formatter={filesize}
/>
</div>
</div>
<div className="row">
<div className="col-xs-6">
<BooleanPanel
value={stats.value.hasJSModule || stats.value.hasJSNext}
title={'Tree-Shakable'}
/>
</div>
<div className="col-xs-6">
<BooleanPanel value={!stats.value.hasSideEffects} title={'Side-Effect Free'} />
</div>
</div>
<div className={styles.icons}>
{stats.value.repository && (
<a href={stats.value.repository} target="_blank">
<IoLogoGithub />
</a>
)}
<a href={`https://www.npmjs.com/package/${this.props.name}`} target="_blank">
<IoLogoNpm />
</a>
</div>
</div>
);
default:
return <IndefiniteProgressBar />;
}
}
}
export const BundlephobiaStats = connect(
(state: IAppState, { name }: { name: string }) => ({
stats: getBundlephobiaData(state, name),
}),
dispatch => ({
retrieve(name: string) {
dispatch(fetchBundlephobiaData.request({ name }));
},
}),
)(BundlephobiaStatsComponent);

Просмотреть файл

@ -0,0 +1,89 @@
import * as filesize from 'filesize';
import * as React from 'react';
import { Stats } from 'webpack';
import {
compareNodeModules,
getNodeModuleCount,
getNodeModuleSize,
getTotalModuleCount,
getTreeShakablePercent,
} from '../stat-reducers';
import { ChangedModuleGraph } from './graphs/changed-module-graph.component';
import { ModuleTable } from './module-table.component';
import { CounterPanel } from './panels/counter-panel.component';
import { NodeModulePanel } from './panels/node-module-panel.component';
import { PanelArrangement } from './panels/panel-arrangement.component';
import { formatPercent } from './util';
import { TreeShakeHint } from './hints/hints.component';
export const DashboardChunkPage: React.FC<{
chunk: number;
first: Stats.ToJsonOutput;
last: Stats.ToJsonOutput;
}> = ({ first, last, chunk }) => {
const firstObj = first.chunks!.find(c => c.id === chunk);
const lastSize = last.chunks!.find(c => c.id === chunk);
return (
<>
<div className="row" style={{ padding: 1 }}>
<div className="col-xs-12 col-sm-6">
<h2>Chunk Stats</h2>
<PanelArrangement>
<CounterPanel
title="Total Size"
value={lastSize ? lastSize.size : 0}
oldValue={firstObj ? firstObj.size : 0}
formatter={filesize}
/>
<CounterPanel
title="Node Modules"
value={getTotalModuleCount(last, chunk)}
oldValue={getTotalModuleCount(first, chunk)}
/>
<CounterPanel
title="Node Modules"
value={getTotalModuleCount(last, chunk)}
oldValue={getTotalModuleCount(first, chunk)}
/>
<CounterPanel
title="Node Module Size"
value={getNodeModuleSize(last, chunk)}
oldValue={getNodeModuleSize(first, chunk)}
formatter={filesize}
/>
<CounterPanel
title="Tree-Shaken Node Modules"
hint={TreeShakeHint}
value={getTreeShakablePercent(last, chunk)}
oldValue={getTreeShakablePercent(first, chunk)}
formatter={formatPercent}
/>
<CounterPanel
title="Node Module Count"
value={getNodeModuleCount(last, chunk)}
oldValue={getNodeModuleCount(first, chunk)}
/>
</PanelArrangement>
<h2>Node Modules</h2>
<PanelArrangement>
{compareNodeModules(first, last)
.sort((a, b) => (b.new ? b.new.totalSize : 0) - (a.new ? a.new.totalSize : 0))
.map(comparison => (
<NodeModulePanel comparison={comparison} key={comparison.name} inChunk={chunk} />
))}
</PanelArrangement>
</div>
<div className="col-xs-12 col-sm-6">
<h2>Module List</h2>
<ModuleTable first={first} last={last} inChunk={chunk} />
</div>
</div>
<h2>
Bundle Tree<small>Only changed files, and their parents, are displayed.</small>
</h2>
<ChangedModuleGraph previous={first} stats={last} chunkId={chunk} />
</>
);
};

Просмотреть файл

@ -1,29 +1,35 @@
import * as React from 'react';
import {
IoIosCalendar,
IoIosArrowRoundForward,
IoIosWarning,
IoIosCloseCircleOutline,
IoIosArrowBack,
} from 'react-icons/io';
import { IconType } from 'react-icons';
import { Stats } from 'webpack';
import { Link } from 'react-router-dom';
import * as styles from './dashboard-header.component.scss';
import * as dayjs from 'dayjs';
import * as React from 'react';
import { IconType } from 'react-icons';
import {
IoIosArrowBack,
IoIosArrowRoundForward,
IoIosCalendar,
IoIosCloseCircleOutline,
IoIosWarning,
} from 'react-icons/io';
import { Link, withRouter } from 'react-router-dom';
import { Stats } from 'webpack';
import * as styles from './dashboard-header.component.scss';
const DashboardHeaderItem: React.FC<{ icon: IconType; href?: string }> = props => {
const DashboardHeaderItem: React.FC<{ icon: IconType; href?: string | (() => void) }> = props => {
const inner = (
<>
<props.icon className={styles.icon} /> {props.children}
</>
);
return props.href ? (
return typeof props.href === 'string' ? (
<Link to={props.href} className={styles.item}>
{inner}
</Link>
) : (
<span className={styles.item}>{inner}</span>
<span
className={styles.item}
onClick={props.href}
style={{ cursor: props.href ? 'pointer' : undefined }}
>
{inner}
</span>
);
};
@ -68,8 +74,8 @@ export const DashboardErrorCount: React.FC<{ last: Stats.ToJsonOutput }> = ({ la
</DashboardHeaderItem>
);
export const DashboardClose: React.FC = () => (
<DashboardHeaderItem icon={IoIosArrowBack} href="/">
Back to URLs
export const DashboardClose = withRouter(props => (
<DashboardHeaderItem icon={IoIosArrowBack} href={() => props.history.goBack()}>
Back
</DashboardHeaderItem>
);
));

Просмотреть файл

@ -0,0 +1,43 @@
import * as React from 'react';
import { Stats } from 'webpack';
import { getDirectImportsOfNodeModule } from '../stat-reducers';
import { BundlephobiaStats } from './bundlephobia-stats.component';
import { DependentGraph } from './graphs/dependent-graph.component';
import { ImportsList, IssuerTree } from './imports-list.component';
import { ImportsStatsRow } from './imports-stats-row.component';
export const DashboardNodeModulePage: React.FC<{
name: string;
first: Stats.ToJsonOutput;
last: Stats.ToJsonOutput;
}> = ({ first, last, name }) => {
const firstImports = getDirectImportsOfNodeModule(first, name);
const lastImports = getDirectImportsOfNodeModule(last, name);
return (
<>
<div className="row" style={{ padding: 1 }}>
<div className="col-xs-12 col-sm-8">
<ImportsStatsRow oldTargets={firstImports} newTargets={lastImports} />
<h2>Imports of "{name}"</h2>
<ImportsList targets={lastImports} />
</div>
<div className="col-xs-12 col-sm-4">
<h2>Bundlephobia Stats</h2>
<BundlephobiaStats name={name} />
<h2>
Issuer Tree
<small>The shortest from the entrypoint to this module</small>
</h2>
<IssuerTree targets={lastImports} />
</div>
</div>
<h2>
Import Tree<small>A graph of all files that depend on the module.</small>
</h2>
<DependentGraph previous={first} stats={last} name={name} />
</>
);
};

Просмотреть файл

@ -1,29 +1,112 @@
import * as filesize from 'filesize';
import * as React from 'react';
import { CounterPanel } from './stat-panels.component';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import { Stats } from 'webpack';
import { getTotalChunkSize, getNodeModuleCount } from '../stat-reducers';
import { ModulePlot } from './module-plot.component';
import {
getAverageChunkSize,
getEntryChunkSize,
getNodeModuleCount,
getNodeModuleSize,
getTotalChunkSize,
getTotalModuleCount,
getTreeShakablePercent,
} from '../stat-reducers';
import { ModuleTable } from './module-table.component';
import { OverviewSuggestions } from './overview-suggestions';
import { CounterPanel } from './panels/counter-panel.component';
import { PanelArrangement } from './panels/panel-arrangement.component';
import { formatDuration, formatPercent } from './util';
import * as tabStyles from './dashboard-tabs.component.scss';
import { ChunkGraph } from './graphs/chunk-graph.component';
import { TreeShakeHint, WhatIsAnEntrypoint, AverageChunkSize, TotalModules } from './hints/hints.component';
export const DashboardOverview: React.FC<{
first: Stats.ToJsonOutput;
last: Stats.ToJsonOutput;
}> = ({ first, last }) => {
return (
<div style={{ display: 'flex', flexDirection: 'row' }}>
<div style={{ flexBasis: 300, marginRight: 16 }}>
<CounterPanel
title="Total Size"
value={getTotalChunkSize(last)}
oldValue={getTotalChunkSize(first)}
filesize
/>
<CounterPanel
title="Node Modules"
value={getNodeModuleCount(last)}
oldValue={getNodeModuleCount(first)}
/>
<div className="row" style={{ padding: 1 }}>
<div className="col-xs-12 col-sm-6">
<h2>Suggestions</h2>
<OverviewSuggestions first={first} last={last} />
<h2 style={{ marginTop: 64 }}>Stats</h2>
<PanelArrangement>
<CounterPanel
title="Total Size"
value={getTotalChunkSize(last)}
oldValue={getTotalChunkSize(first)}
formatter={filesize}
/>
<CounterPanel
title="Download Time (3 Mbps)"
value={(getTotalChunkSize(last) / (3500 * 128)) * 1000}
oldValue={(getTotalChunkSize(first) / (3500 * 128)) * 1000}
formatter={formatDuration}
/>
<CounterPanel
title="Avg. Chunk Size"
value={getAverageChunkSize(last)}
oldValue={getAverageChunkSize(first)}
hint={AverageChunkSize}
formatter={filesize}
/>
<CounterPanel
title="Entrypoint Size"
hint={WhatIsAnEntrypoint}
value={getEntryChunkSize(last)}
oldValue={getEntryChunkSize(first)}
formatter={filesize}
/>
<CounterPanel
title="Total Modules"
hint={TotalModules}
value={getTotalModuleCount(last)}
oldValue={getTotalModuleCount(first)}
/>
<CounterPanel
title="Node Module Size"
value={getNodeModuleSize(last)}
oldValue={getNodeModuleSize(first)}
formatter={filesize}
/>
<CounterPanel
title="Tree-Shaken Node Modules"
hint={TreeShakeHint}
value={getTreeShakablePercent(last)}
oldValue={getTreeShakablePercent(first)}
formatter={formatPercent}
/>
<CounterPanel
title="Node Module Count"
value={getNodeModuleCount(last)}
oldValue={getNodeModuleCount(first)}
/>
<CounterPanel
title="Build Time"
value={last.time!}
oldValue={first.time!}
formatter={formatDuration}
/>
</PanelArrangement>
</div>
<div className="col-xs-12 col-sm-6">
<Tabs className={tabStyles.tabs}>
<TabList>
<Tab>Chunk Graph</Tab>
<Tab>Module List</Tab>
</TabList>
<TabPanel>
<ChunkGraph stats={last} previous={first} />
</TabPanel>
<TabPanel>
<ModuleTable first={first} last={last} />
</TabPanel>
</Tabs>
</div>
<ModulePlot first={first} last={last} />
</div>
);
};

Просмотреть файл

@ -0,0 +1,36 @@
@import './variables.scss';
.tabs {
:global {
.react-tabs__tab-list {
display: flex;
padding: 8px;
background: rgba(#000, 0.2);
}
.react-tabs__tab {
list-style-type: none;
padding: 8px 32px;
border-radius: 3px;
cursor: pointer;
text-transform: uppercase;
font-family: var(--font-family-mono);
font-size: 0.8em;
&--selected {
background: $color-pink;
color: $color-dark;
}
}
.react-tabs__tab-panel {
display: none;
background: rgba(#000, .1);
&--selected {
display: block;
}
}
}
}

Просмотреть файл

@ -3,15 +3,15 @@
.wrapper {
margin: 32px;
padding: 16px;
background: $color-dark;
background: rgba(#000, 0.4);
border-radius: 4px;
box-shadow: 0 10px 50px rgba(0, 0, 0, 0.3);
box-shadow: 0 10px 50px rgba(#000, 0.3);
}
.header {
display: flex;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: 1px solid rgba(#fff, 0.1);
padding-bottom: 16px;
margin-bottom: 16px;
}

Просмотреть файл

@ -1,44 +1,84 @@
import * as React from 'react';
import * as styles from './dashboard.component.scss';
import { connect } from 'react-redux';
import { IAppState, getKnownStats } from '../reducer';
import { Redirect, Route, RouteChildrenProps } from 'react-router';
import { Stats } from 'webpack';
import { getKnownStats, IAppState } from '../redux/reducer';
import { DashboardChunkPage } from './dashboard-chunk-page.component';
import {
DashboardBuildDate,
DashboardClose,
DashboardErrorCount,
DashboardWarningCount,
DashboardClose,
} from './dashboard-header.component';
import { Redirect, Route } from 'react-router';
import { DashboardNodeModulePage } from './dashboard-node-module-page.component';
import { DashboardOverview } from './dashboard-overview';
import * as styles from './dashboard.component.scss';
interface IProps {
stats: Stats.ToJsonOutput[];
}
const DashboardComponent: React.FC<IProps> = ({ stats }) => {
const first = stats[0];
const last = stats[stats.length - 1];
if (!first) {
return <Redirect to="/" />;
class DashboardComponent extends React.PureComponent<IProps> {
private get first() {
return this.props.stats[0];
}
return (
<div className={styles.wrapper}>
<div className={styles.header}>
<DashboardBuildDate first={first} last={last} />
<DashboardErrorCount last={last} />
<DashboardWarningCount last={last} />
<div style={{ flex: 1 }} />
<DashboardClose />
private get last() {
return this.props.stats[this.props.stats.length - 1];
}
public render() {
const { first, last } = this;
if (!first) {
return <Redirect to="/" />;
}
return (
<div className={styles.wrapper}>
<div className={styles.header}>
<DashboardBuildDate first={first} last={last} />
<DashboardErrorCount last={last} />
<DashboardWarningCount last={last} />
<div style={{ flex: 1 }} />
<DashboardClose />
</div>
<Route path="/dashboard" exact component={this.renderOverview} />
<Route path="/dashboard/chunk/:chunkId" exact component={this.renderChunkPage} />
<Route
path="/dashboard/nodemodule/:encodedModule"
exact
component={this.renderNodeModule}
/>
</div>
<Route path="/dashboard" exact>
<DashboardOverview first={first} last={last} />
</Route>
</div>
);
}
private readonly renderOverview: React.FC<void> = () => (
<DashboardOverview first={this.first} last={this.last} />
);
};
private readonly renderChunkPage: React.FC<RouteChildrenProps<{ chunkId: string }>> = ({
match,
}) =>
match && (
<DashboardChunkPage
chunk={Number(match.params.chunkId)}
first={this.first}
last={this.last}
/>
);
private readonly renderNodeModule: React.FC<RouteChildrenProps<{ encodedModule: string }>> = ({
match,
}) =>
match && (
<DashboardNodeModulePage
name={Base64.decode(match.params.encodedModule)}
first={this.first}
last={this.last}
/>
);
}
export const Dashboard = connect((state: IAppState) => ({
stats: getKnownStats(state),

Просмотреть файл

@ -6,7 +6,7 @@
left: 50%;
width: 600px;
max-width: 100vh;
background: $color-dark;
background: rgba(#000, 0.4);
z-index: 0;
text-align: center;
transform: translate(-50%, -50%);

Просмотреть файл

@ -1,19 +1,19 @@
import { IRetrievalError, RetrievalState } from '@mixer/retrieval';
import * as React from 'react';
import * as styles from './enter-urls.component.scss';
import { Button } from './button.component';
import { connect } from 'react-redux';
import {
getBundleUrls,
IAppState,
BundleStateMap,
getGroupedBundleState,
getBundleErrors,
} from '../reducer';
import { RetrievalState, IRetrievalError } from '@mixer/retrieval';
import { loadAllUrls, clearLoadedBundles } from '../actions';
import { ProgressBar } from './progress-bar.component';
import { Errors } from './errors.component';
import { Redirect } from 'react-router-dom';
import { clearLoadedBundles, loadAllUrls } from '../redux/actions';
import {
BundleStateMap,
getBundleErrors,
getBundleUrls,
getGroupedBundleState,
IAppState,
} from '../redux/reducer';
import { Button } from './button.component';
import * as styles from './enter-urls.component.scss';
import { Errors } from './errors.component';
import { ProgressBar } from './progress-bar.component';
interface IProps {
defaultUrls: string[];

Просмотреть файл

@ -1,5 +1,5 @@
import * as React from 'react';
import { IError, IRetrievalError } from '@mixer/retrieval';
import * as React from 'react';
import * as styles from './errors.component.scss';
import { classes } from './util';

Просмотреть файл

@ -0,0 +1,264 @@
import * as cytoscape from 'cytoscape';
import * as filesize from 'filesize';
import * as React from 'react';
import { Stats } from 'webpack';
import { IWebpackModuleComparisonOutput, normalizeIdentifier } from '../../stat-reducers';
import { formatPercentageDifference } from '../util';
// tslint:disable-next-line
cytoscape.use(require('cytoscape-fcose'));
interface IProps {
edges: cytoscape.EdgeDefinition[];
nodes: cytoscape.NodeDefinition[];
rootNode?: string | string[];
width: string | number;
height: string | number;
onClick?(nodeId: string): void;
}
export class BaseGraph extends React.PureComponent<IProps> {
private readonly container = React.createRef<HTMLDivElement>();
private mountTimeout?: number | NodeJS.Timeout;
private graph?: cytoscape.Core;
public componentDidMount() {
this.mountTimeout = setTimeout(() => this.draw(this.container.current!), 10);
}
public componentWillUnmount() {
clearTimeout(this.mountTimeout as number);
if (this.graph) {
this.graph.destroy();
}
}
public render() {
return (
<div ref={this.container} style={{ width: this.props.width, height: this.props.height }} />
);
}
private draw(container: HTMLDivElement) {
const graph = cytoscape({
container,
boxSelectionEnabled: false,
autounselectify: true,
layout: { name: 'fcose', animate: false } as any,
elements: { nodes: this.props.nodes, edges: this.props.edges },
style: [
{
selector: 'node',
style: {
label: 'data(label)',
width: 'data(width)',
height: 'data(width)',
color: 'data(fontColor)',
'font-size': 5,
'background-color': 'data(bgColor)',
},
},
{
selector: 'node.hover',
style: {
'background-color': '#fff',
},
},
{
selector: 'edge',
style: {
width: 1.5,
'line-color': '#5c2686',
'arrow-scale': 0.5,
'source-arrow-color': '#5c2686',
'source-arrow-shape': 'triangle',
'curve-style': 'bezier',
} as any,
},
{
selector: 'edge.highlighted',
style: {
'line-color': '#fff',
'source-arrow-color': '#fff',
},
},
],
});
if (this.graph) {
this.graph.destroy();
}
this.graph = graph;
this.attachPathHoverHandle(graph);
this.attachClickListeners(graph);
this.attachMouseoverStyles(graph);
}
private attachMouseoverStyles(graph: cytoscape.Core) {
graph.on('mouseover', 'node', ev => {
const target = ev.target as cytoscape.Collection;
target.addClass('hover');
});
graph.on('mouseout', 'node', ev => {
const target = ev.target as cytoscape.Collection;
target.removeClass('hover');
});
}
private attachClickListeners(graph: cytoscape.Core) {
const handler = this.props.onClick;
if (!handler) {
return;
}
graph.on('tap', 'node', ev => {
const target = ev.target as cytoscape.SingularData;
handler(target.id());
});
}
private attachPathHoverHandle(graph: cytoscape.Core) {
if (!this.props.rootNode) {
return;
}
const root = graph.collection(
typeof this.props.rootNode === 'string'
? [graph.getElementById(this.props.rootNode)]
: this.props.rootNode.map(node => graph.getElementById(node)),
);
if (root.length === 0) {
return;
}
let lastPath: cytoscape.CollectionReturnValue | null = null;
graph.on('mouseover', 'node', ev => {
lastPath = graph
.elements()
.dijkstra({ root: ev.target, directed: true })
.pathTo(root);
lastPath.addClass('highlighted');
});
graph.on('mouseout', 'node', () => {
if (lastPath) {
lastPath.removeClass('highlighted');
}
});
}
}
export const fileSizeNode = ({
fromSize,
toSize,
area,
...options
}: cytoscape.NodeDataDefinition & {
fromSize: number;
toSize: number;
area: number;
}): cytoscape.NodeDefinition => {
const hue = fromSize < toSize ? 0 : fromSize > toSize ? 110 : 55;
const saturation = 40 + Math.min(60, (Math.abs(toSize - fromSize) / (toSize || 1)) * 100);
return {
data: {
...options,
label: `${options.label} (${filesize(toSize)}), ${formatPercentageDifference(
fromSize,
toSize,
)}`,
fontColor: fromSize !== toSize ? '#fff' : '#666',
bgColor: fromSize !== toSize ? `hsl(${hue}, ${saturation}%, 50%)` : '#666',
width: Math.round(2 * Math.sqrt(area / Math.PI)),
height: Math.round(2 * Math.sqrt(area / Math.PI)),
},
};
};
export const expandNode = <T extends { identifier: string }>({
queue,
getReasons,
createNode,
}: {
queue: T[];
getReasons(node: T): T[];
createNode(node: T): cytoscape.NodeDefinition;
}) => {
const nodes: cytoscape.NodeDefinition[] = [];
const edges: cytoscape.EdgeDefinition[] = [];
const sources = new Set<string>();
while (queue.length > 0) {
const node = queue.pop()!;
sources.add(node.identifier);
for (const found of getReasons(node)) {
if (!sources.has(found.identifier)) {
queue.push(found);
}
edges.push({
data: {
id: `edge${node.identifier}to${found.identifier}`,
source: node.identifier,
target: found.identifier,
},
});
}
nodes.push(createNode(node));
}
return { nodes, edges };
};
export const expandModuleComparison = (
comparisons: { [name: string]: IWebpackModuleComparisonOutput },
roots: IWebpackModuleComparisonOutput[],
) => {
const maxBubbleArea = 150;
const minBubbleArea = 30;
const allComparisons = Object.values(comparisons);
const maxSize = allComparisons.reduce((max, cmp) => Math.max(max, cmp.toSize), 0);
const entries: string[] = [];
const { nodes, edges } = expandNode({
queue: roots,
getReasons(node) {
let reasons: Stats.Reason[] = [];
if (node.old) {
reasons = reasons.concat(node.old.reasons as any);
}
if (node.new) {
reasons = reasons.concat(node.new.reasons as any);
}
if (reasons.some(r => r.type.includes('entry'))) {
entries.push(node.identifier);
}
return reasons
.map(r => comparisons[normalizeIdentifier(r.moduleIdentifier || '')])
.filter(ok => !!ok);
},
createNode(node) {
const weight = node.toSize / maxSize;
const area = Math.max(minBubbleArea, maxBubbleArea * weight);
return fileSizeNode({
id: node.identifier,
label: node.name,
area,
fromSize: node.fromSize,
toSize: node.toSize,
});
},
});
return { nodes, edges, entries };
};

Просмотреть файл

@ -0,0 +1,55 @@
import * as cytoscape from 'cytoscape';
import * as React from 'react';
import { Redirect } from 'react-router';
import { Stats } from 'webpack';
import { compareAllModules } from '../../stat-reducers';
import { BaseGraph, expandModuleComparison } from './base-graph.component';
interface IProps {
previous: Stats.ToJsonOutput;
stats: Stats.ToJsonOutput;
chunkId: number;
}
interface IState {
nodes: cytoscape.NodeDefinition[];
edges: cytoscape.EdgeDefinition[];
entries: string[];
redirect?: string;
}
export class ChangedModuleGraph extends React.PureComponent<IProps, IState> {
public state: IState = { redirect: undefined, ...this.buildData() };
public render() {
return this.state.redirect ? (
<Redirect to={this.state.redirect} push={true} />
) : (
<BaseGraph
edges={this.state.edges}
nodes={this.state.nodes}
rootNode={this.state.entries}
width="100%"
height={window.innerHeight * 0.9}
onClick={this.onClick}
/>
);
}
private buildData() {
const comparisons = compareAllModules(
this.props.previous,
this.props.stats,
this.props.chunkId,
);
const allComparisons = Object.values(comparisons);
return expandModuleComparison(
comparisons,
Object.values(allComparisons).filter(c => c.toSize !== c.fromSize),
);
}
private readonly onClick = (nodeId: string) =>
this.setState({ redirect: `/dashboard/chunk/${nodeId}` });
}

Просмотреть файл

@ -0,0 +1,84 @@
import * as cytoscape from 'cytoscape';
import * as React from 'react';
import { Redirect } from 'react-router';
import { Stats } from 'webpack';
import { BaseGraph, fileSizeNode } from './base-graph.component';
interface IProps {
previous: Stats.ToJsonOutput;
stats: Stats.ToJsonOutput;
maxBubbleArea?: number;
minBubbleArea?: number;
}
interface IState {
nodes: cytoscape.NodeDefinition[];
edges: cytoscape.EdgeDefinition[];
entries: string[];
redirect?: string;
}
export class ChunkGraph extends React.PureComponent<IProps, IState> {
public state: IState = { redirect: undefined, ...this.buildData() };
public render() {
return this.state.redirect ? (
<Redirect to={this.state.redirect} push={true} />
) : (
<BaseGraph
edges={this.state.edges}
nodes={this.state.nodes}
rootNode={this.state.entries}
width="100%"
height={500}
onClick={this.onClick}
/>
);
}
private buildData() {
const { stats, maxBubbleArea = 150, minBubbleArea = 30 } = this.props;
const chunks = stats.chunks!;
const maxSize = chunks.reduce((max, c) => Math.max(max, c.size), 0);
const entries: string[] = [];
const nodes: cytoscape.ElementDefinition[] = chunks.map(chunk => {
if (chunk.entry) {
entries.push(String(chunk.id));
}
const weight = chunk.size / maxSize;
const area = Math.max(minBubbleArea, maxBubbleArea * weight);
const previous = this.props.previous.chunks!.find(c => c.id === chunk.id);
return fileSizeNode({
id: String(chunk.id),
chunkId: chunk.id,
shortLabel: '' + chunk.id,
label: `Chunk ${chunk.id}`,
fromSize: previous ? previous.size : 0,
toSize: chunk.size,
area,
});
});
const edges: cytoscape.EdgeDefinition[] = [];
for (const chunk of chunks) {
for (const parent of chunk.parents) {
edges.push({
data: {
id: `edge${parent}to${chunk.id}`,
source: String(chunk.id),
target: String(parent),
},
});
}
}
return { nodes, edges, entries };
}
private readonly onClick = (nodeId: string) =>
this.setState({ redirect: `/dashboard/chunk/${nodeId}` });
}

Просмотреть файл

@ -0,0 +1,88 @@
import * as cytoscape from 'cytoscape';
import * as React from 'react';
import { Redirect } from 'react-router';
import { Stats } from 'webpack';
import {
compareAllModules,
getDirectImportsOfNodeModule,
normalizeIdentifier,
} from '../../stat-reducers';
import { color } from '../util';
import { BaseGraph, expandModuleComparison } from './base-graph.component';
interface IProps {
previous: Stats.ToJsonOutput;
stats: Stats.ToJsonOutput;
name: string;
chunkId?: number;
}
interface IState {
nodes: cytoscape.NodeDefinition[];
edges: cytoscape.EdgeDefinition[];
entries: string[];
redirect?: string;
}
export class DependentGraph extends React.PureComponent<IProps, IState> {
public state: IState = { redirect: undefined, ...this.buildData() };
public render() {
return this.state.redirect ? (
<Redirect to={this.state.redirect} push={true} />
) : (
<BaseGraph
edges={this.state.edges}
nodes={this.state.nodes}
rootNode={this.state.entries}
width="100%"
height={window.innerHeight * 0.9}
onClick={this.onClick}
/>
);
}
private buildData() {
const directImports = getDirectImportsOfNodeModule(this.props.previous, this.props.name);
const comparisons = compareAllModules(
this.props.previous,
this.props.stats,
this.props.chunkId,
);
const { nodes, edges } = expandModuleComparison(
comparisons,
directImports.map(imp => comparisons[normalizeIdentifier(imp.identifier)]).filter(ok => !!ok),
);
for (const edge of edges) {
[edge.data.source, edge.data.target] = [edge.data.target, edge.data.source];
}
nodes.push({
data: {
id: 'index',
label: this.props.name,
fontColor: '#fff',
bgColor: color.blue,
width: 20,
height: 20,
},
});
for (const direct of directImports) {
edges.push({
data: {
id: `${direct.identifier}toIndex`,
source: direct.identifier,
target: 'index',
},
});
}
return { nodes, edges, entries: ['index'] };
}
private readonly onClick = (nodeId: string) =>
this.setState({ redirect: `/dashboard/chunk/${nodeId}` });
}

Просмотреть файл

@ -0,0 +1,69 @@
@import '../variables.scss';
.button {
background: none;
border: none;
color: currentColor;
padding: 0;
margin: 0;
cursor: pointer;
}
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
overflow-y: auto;
}
.backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(#000, 0.3);
}
.contents {
position: relative;
z-index: 1;
max-width: 600px;
margin: 50px;
padding: 50px;
border-radius: 4px;
box-shadow: 0 10px 50px rgba(0, 0, 0, 0.3);
background: linear-gradient($color-medium, $color-dark);
h2:first-child {
margin-top: 0;
}
p {
line-height: 1.5em;
}
h3 {
margin: 32px 0 16px;
}
li {
margin-left: 20px;
+ li {
margin-top: 4px;
}
}
code {
font-size: 0.8em;
background: rgba(0, 0, 0, 0.5);
border-radius: 2px;
padding: 0.1em;
}
}

Просмотреть файл

@ -0,0 +1,40 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import * as styles from './hint-button.component.scss';
import { IoIosInformationCircleOutline } from 'react-icons/io';
import { classes } from '../util';
export class HintButton extends React.PureComponent<
{ hint: React.ComponentType<{}>; className?: string },
{ open: boolean }
> {
public state = { open: false };
public render() {
return (
<>
<button onClick={this.open} className={classes(styles.button, this.props.className)}>
<IoIosInformationCircleOutline />
</button>
{this.state.open &&
ReactDOM.createPortal(
<div className={styles.overlay}>
<div className={styles.backdrop} onClick={this.close} />
<div className={styles.contents}>
<this.props.hint />
</div>
</div>,
document.body,
)}
</>
);
}
private readonly open = () => {
this.setState({ open: true });
};
private readonly close = () => {
this.setState({ open: false });
};
}

Просмотреть файл

@ -0,0 +1,142 @@
import * as React from 'react';
export const TreeShakeHint: React.FC = () => (
<>
<h2>Tree Shaking</h2>
<p>
Tree shaking eliminates dead code by only importing the code you use from modules. You want to
choose (and build) dependencies that can be tree shaken to avoid having to deliver more code.
Google/Chrome{' '}
<a
href="https://developers.google.com/web/fundamentals/performance/optimizing-javascript/tree-shaking/"
target="_blank"
>
has a great article
</a>{' '}
pertaining to tree shaking in general, and the Webpack documentation publishes{' '}
<a href="https://webpack.js.org/guides/tree-shaking/" target="_blank">
some further guidance
</a>
.
</p>
<h3>tl;dr</h3>
<ul>
<li>
If you import a module, check{' '}
<a href="https://bundlephobia.com/" target="_blank">
on Bundlephobia
</a>{' '}
to see if it's tree-shakable. Shakable modules will have a green leaf icon below their name.
</li>
<li>
If you author a module, make sure you publish a build that uses ES modules. Also, add the{' '}
<code>sideEffects: false</code> flag into your <code>package.json</code>.
</li>
</ul>
</>
);
export const WhatIsAnEntrypoint: React.FC = () => (
<>
<h2>Entrypoints</h2>
<p>
An entrypoint is the first JavaScript that's loaded when a user navigates to your website.
This is the bundle that needs to be downloaded before any of your own code in Webpack actually
runs. You still might need to load more data, such as a route, before you show anything
meaningful. You should try to keep your entrypoint size as small as possible to reduce your
time to{' '}
<a
href="https://developers.google.com/web/tools/lighthouse/audits/first-meaningful-paint"
target="_blank"
>
time to first meaningful paint
</a>
.
</p>
</>
);
export const AverageChunkSize: React.FC = () => (
<>
<h2>Chunks and Sizes</h2>
<p>
A chunk is one bundle of code downloaded by webpack. You should make use of the available{' '}
<a href="https://webpack.js.org/guides/lazy-loading/" target="_blank">
lazy loading
</a>{' '}
tools available to you--such as{' '}
<a href="https://reactjs.org/docs/code-splitting.html" target="_blank">
React.lazy
</a>
, for instance--in order to have small, modular bundles. Loading only the code you need keeps
your website fast and responsive. The{' '}
<a
href="https://developers.google.com/web/updates/2017/04/devtools-release-notes#coverage"
target="_blank"
>
Chrome code coverage tool
</a>{' '}
can help you find sections of your code which are good candidates for code splitting.
</p>
</>
);
export const TotalModules: React.FC = () => (
<>
<h2>Total Modules</h2>
<p>
The total modules is a count of the number of source files that Webpack read in your build. It
does not directly have an effect on your output, but is a rough measure of complexity and
monolithism.
</p>
</>
);
export const UniqueEntrypoints: React.FC = () => (
<>
<h2>Unique Entrypoints</h2>
<p>
This is the number of different files that your code requires from the module. Usually you
want to only import the file by its entrypoint, and allow tree shaking to prune unneeded code.
What you should pay careful attention to here is making sure that you don't accidentally
include multiple versions of the same dependency. This can easily happen if you have a
dependency depends on a different version of this module that another one.
</p>
</>
);
export const DependentModules: React.FC = () => (
<>
<h2>Dependent Modules</h2>
<p>
This is the number of files, in your code or other dependency, that depend on this module. This number lets you easily see if you added or reduced coupling on this dependency.
</p>
</>
);
export const TotalNodeModuleSize: React.FC = () => (
<>
<h2>Total Module Size</h2>
<p>
This is how much filesize this module contributes to your code. This may differ from the
Bundlephobia number shown on the page, for a few reasons:
<ol>
<li>The number here takes into account your unique tree-shaking graph.</li>
<li>
If you import a submodule from this dependency, it will not appear in Bundlephobia's
analysis.
</li>
<li>
Bundlephobia may not include certain processing that your custom webpack configuration
applies.
</li>
</ol>
<p>
The number shown here is the accurate, precise filesize that this dependency contributes to
your bundle.
</p>
</p>
</>
);

Просмотреть файл

@ -0,0 +1,52 @@
.importBox {
background: rgba(#000, 0.1);
font-family: var(--font-family-mono);
+ .importBox {
margin-top: 32px;
}
}
.identifier {
font-size: 0.9em;
padding: 8px;
border-bottom: 1px solid rgba(#fff, 0.1);
}
.title {
align-items: center;
display: flex;
}
.filename {
font-size: 0.9em;
margin-bottom: 0.3em;
}
.reason {
padding: 12px 8px;
&:nth-child(odd) {
background-color: rgba(#fff, 0.05);
}
}
.fakeLine, .issuer {
font-size: 0.8em;
em {
width: 30px;
display: inline-block;
text-align: right;
margin-right: 1em;
color: rgba(#fff, 0.5);
font-style: normal;
}
}
.issuer {
margin-top: 8px;
li {
list-style-type: none;
}
}

Просмотреть файл

@ -0,0 +1,72 @@
import * as React from 'react';
import { Stats } from 'webpack';
import { getImportType, replaceLoaderInIdentifier } from '../stat-reducers';
import * as styles from './imports-list.component.scss';
import { ModuleTypeBadge } from './panels/node-module-panel.component';
import { Placeholder } from './placeholder.component';
/**
* Prints the list of modules that import the target modules.
*/
export const ImportsList: React.FC<{ targets: Stats.FnModules[] }> = ({ targets }) =>
targets.length === 0 ? (
<Placeholder>This module is not imported in the lastest build.</Placeholder>
) : (
<>
{targets.map(target => (
<div key={target.identifier} className={styles.importBox}>
<div className={styles.title}>
{target.name}
<span style={{ flex: 1 }} />
<ModuleTypeBadge type={getImportType(target)} />
</div>
{(target.reasons as any).map((reason: Stats.Reason, i: number) => (
<ImportReason key={i} reason={reason} />
))}
</div>
))}
</>
);
/**
* Prints the list of issuers that import any of the target modules.
*/
export const IssuerTree: React.FC<{ targets: Stats.FnModules[] }> = ({ targets }) =>
targets.length === 0 ? (
<Placeholder>This module is not imported in the lastest build.</Placeholder>
) : (
<>
{targets.map(
target =>
target.issuerPath && (
<div key={target.identifier} className={styles.importBox}>
<div className={styles.title}>
{target.name}
<span style={{ flex: 1 }} />
<ModuleTypeBadge type={getImportType(target)} />
</div>
<ol className={styles.issuer}>
{target.issuerPath.map((issuer, i) => (
<li key={i}>
<em>{i + 1}.</em> {issuer.name}
</li>
))}
</ol>
</div>
),
)}
</>
);
const ImportReason: React.FC<{ reason: Stats.Reason }> = ({ reason }) => {
const request = replaceLoaderInIdentifier(reason.userRequest);
return (
<div className={styles.reason}>
<div className={styles.filename}>{reason.module}</div>
<div className={styles.fakeLine}>
<em>{reason.loc.split(':')[0]}</em>
{reason.type.includes('harmony') ? `import "${request}"` : `require("${request}")`}
</div>
</div>
);
};

Просмотреть файл

@ -0,0 +1,38 @@
import * as filesize from 'filesize';
import * as React from 'react';
import { Stats } from 'webpack';
import { CounterPanel } from './panels/counter-panel.component';
import { PanelArrangement } from './panels/panel-arrangement.component';
import { color } from './util';
import { TotalNodeModuleSize, UniqueEntrypoints, DependentModules } from './hints/hints.component';
/**
* Prints the list of modules that import the target modules.
*/
export const ImportsStatsRow: React.FC<{
oldTargets: Stats.FnModules[];
newTargets: Stats.FnModules[];
}> = ({ oldTargets, newTargets }) => (
<PanelArrangement>
<CounterPanel
title="Total Size"
hint={TotalNodeModuleSize}
value={newTargets.reduce((acc, t) => acc + t.size, 0)}
oldValue={oldTargets.reduce((acc, t) => acc + t.size, 0)}
formatter={filesize}
/>
<CounterPanel
title="Unique Entrypoints"
hint={UniqueEntrypoints}
value={newTargets.length}
oldValue={oldTargets.length}
color={newTargets.length > 0 ? color.pink : undefined}
/>
<CounterPanel
title="Dependent Modules"
hint={DependentModules}
value={newTargets.reduce((acc, t) => acc + (t.reasons as any).length, 0)}
oldValue={oldTargets.reduce((acc, t) => acc + (t.reasons as any).length, 0)}
/>
</PanelArrangement>
);

Просмотреть файл

@ -8,37 +8,37 @@
.plot :global {
.public_fixedDataTable_header,
.public_fixedDataTable_hasBottomBorder {
border-color: $color-dark;
border-color: rgba(#000, 0.1);
}
.public_fixedDataTable_scrollbarSpacer {
background: $color-dark;
background: rgba(#000, 0.1);
}
.public_fixedDataTable_header,
.public_fixedDataTable_header .public_fixedDataTableCell_main {
background: darken($color-dark, 3%);
background: none;
}
.public_fixedDataTable_footer .public_fixedDataTableCell_main {
background: #f6f7f8;
border-color: $color-dark;
border-color: rgba(#000, 0.1);
}
.public_fixedDataTable_horizontalScrollbar .public_Scrollbar_mainHorizontal,
.public_fixedDataTableRow_main {
background-color: $color-dark;
background-color: rgba(#000, 0.1);
}
.public_fixedDataTableCell_main {
background-color: $color-dark;
border-color: darken($color-dark, 3%);
background: none;
border-color: rgba(#000, 0.3);
}
.public_fixedDataTableCell_highlighted,
.public_fixedDataTableRow_highlighted,
.public_fixedDataTableRow_highlighted .public_fixedDataTableCell_main {
background-color: darken($color-dark, 2%);
background: none;
}
.public_fixedDataTableCell_cellContent {
@ -47,18 +47,24 @@
}
.public_fixedDataTable_header .public_fixedDataTableCell_main {
font-weight: bold;
font-family: var(--font-family-mono);
color: #fff;
text-transform: uppercase;
font-weight: normal;
}
.public_Scrollbar_main,
.public_Scrollbar_mainOpaque:hover,
.public_Scrollbar_faceActive:after {
border: none;
background: $color-dark;
background: rgba(#000, 0.1);
}
.public_Scrollbar_face:after {
background: rgba(#fff, 0.3);
}
}
.muted {
color: rgba(#fff, 0.3);
}

Просмотреть файл

@ -1,17 +1,18 @@
import * as React from 'react';
import { IModuleDiffEntry, getModulesDiff } from '../stat-reducers';
import { Stats } from 'webpack';
import * as styles from './module-plot.component.scss';
import { Table, Column, Cell, ColumnCellProps } from 'fixed-data-table-2';
import * as filesize from 'filesize';
import { Cell, Column, ColumnCellProps, Table } from 'fixed-data-table-2';
import * as React from 'react';
import { Stats } from 'webpack';
import { compareAllModules, IModuleDiffEntry } from '../stat-reducers';
import * as styles from './module-table.component.scss';
import 'fixed-data-table-2/dist/fixed-data-table.css';
import { formatFileSizeDifference, formatPercentageDifference } from './util';
import { IoIosArrowRoundUp, IoIosArrowRoundDown } from 'react-icons/io';
import { IoIosArrowRoundDown, IoIosArrowRoundUp } from 'react-icons/io';
import { formatDifference, formatPercentageDifference } from './util';
interface IProps {
first: Stats.ToJsonOutput;
last: Stats.ToJsonOutput;
inChunk?: number;
}
const enum Sort {
@ -24,10 +25,16 @@ const enum Sort {
interface IState {
diffs: IModuleDiffEntry[];
order: Sort;
width: number;
}
export class ModulePlot extends React.PureComponent<IProps, IState> {
public state: IState = { diffs: this.orderDiff(Sort.DiffDesc), order: Sort.DiffDesc };
export class ModuleTable extends React.PureComponent<IProps, IState> {
public state: IState = { diffs: this.orderDiff(Sort.DiffDesc), order: Sort.DiffDesc, width: 500 };
private containerRef = React.createRef<HTMLDivElement>();
public componentDidMount() {
setTimeout(this.resize, 10);
}
public componentDidUpdate(prevProps: IProps) {
if (prevProps.last !== this.props.last) {
@ -37,27 +44,39 @@ export class ModulePlot extends React.PureComponent<IProps, IState> {
public render() {
return (
<Table
rowHeight={30}
width={1000}
maxHeight={window.innerHeight * 2 / 3}
headerHeight={40}
rowsCount={this.state.diffs.length}
className={styles.plot}
>
<Column cell={this.nameCell} width={500} flexGrow={1} header="Module Name" />
<Column cell={this.totalSizeCell} width={150} header={this.totalHeader} />
<Column cell={this.diffCell} width={200} header={this.diffHeader} />
</Table>
<div ref={this.containerRef}>
<Table
rowHeight={30}
width={this.state.width}
maxHeight={(window.innerHeight * 2) / 3}
headerHeight={40}
rowsCount={this.state.diffs.length}
className={styles.plot}
rowClassNameGetter={this.getRowClassName}
>
<Column cell={this.nameCell} width={100} flexGrow={1} header="Module Name" />
<Column cell={this.totalSizeCell} width={150} header={this.totalHeader} />
<Column cell={this.diffCell} width={180} header={this.diffHeader} />
</Table>
</div>
);
}
private readonly resize = () => {
this.setState({ width: this.containerRef.current!.clientWidth });
};
private readonly getRowClassName = (index: number) => {
const { fromSize, toSize } = this.state.diffs[index];
return fromSize === toSize ? styles.muted : null;
};
private readonly diffCell = (props: ColumnCellProps) => {
const { rowIndex, ...rest } = props;
const target = this.state.diffs[rowIndex];
return (
<Cell {...rest} height={40}>
{formatFileSizeDifference(target.fromSize, target.toSize)}
{formatDifference(target.fromSize, target.toSize, filesize)}
{target.fromSize > 0 && ` (${formatPercentageDifference(target.fromSize, target.toSize)})`}
</Cell>
);
@ -98,18 +117,22 @@ export class ModulePlot extends React.PureComponent<IProps, IState> {
};
private readonly toggleOrderByDiff = () => {
const order = Math.abs(this.state.order) !== Sort.DiffAsc ? Sort.DiffDesc : this.state.order * -1;
const order =
Math.abs(this.state.order) !== Sort.DiffAsc ? Sort.DiffDesc : this.state.order * -1;
this.setState({ order, diffs: this.orderDiff(order, this.state.diffs) });
};
private readonly toggleOrderByTotal = () => {
const order = Math.abs(this.state.order) !== Sort.TotalAsc ? Sort.TotalDesc : this.state.order * -1;
const order =
Math.abs(this.state.order) !== Sort.TotalAsc ? Sort.TotalDesc : this.state.order * -1;
this.setState({ order, diffs: this.orderDiff(order, this.state.diffs) });
};
private orderDiff(
sort: Sort,
list: IModuleDiffEntry[] = getModulesDiff(this.props.first, this.props.last),
list: IModuleDiffEntry[] = Object.values(
compareAllModules(this.props.first, this.props.last, this.props.inChunk),
),
) {
const invert = sort < 0 ? -1 : 1;

Просмотреть файл

@ -0,0 +1,37 @@
@import './variables.scss';
.tip {
font-size: 1.1em;
line-height: 1.5em;
padding-left: 32px;
position: relative;
padding-top: 1em;
+ .tip {
margin-top: 1em;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
small {
font-size: 0.7em;
color: rgba(#fff, 0.7);
display: block;
font-size: 0.9em;
}
}
.icon {
position: absolute;
top: 1em;
left: 0;
font-size: 1.5em;
.suggestion & {
color: $color-yellow;
}
.awesome & {
color: $color-blue;
}
}

Просмотреть файл

@ -0,0 +1,110 @@
import * as filesize from 'filesize';
import * as React from 'react';
import { IoIosInformationCircleOutline, IoIosThumbsUp } from 'react-icons/io';
import { Stats } from 'webpack';
import {
getEntryChunkSize,
getNodeModuleSize,
getTotalChunkSize,
getTreeShakablePercent,
} from '../stat-reducers';
import * as styles from './overview-suggestions.component.scss';
import { classes, formatPercent } from './util';
interface IProps {
first: Stats.ToJsonOutput;
last: Stats.ToJsonOutput;
}
const epsilon = 1024 * 2;
function nodeModuleSizeTip(first: Stats.ToJsonOutput, last: Stats.ToJsonOutput) {
const firstNodeModuleSize = getNodeModuleSize(first);
const lastNodeModuleSize = getNodeModuleSize(last);
if (lastNodeModuleSize > firstNodeModuleSize + epsilon) {
return (
<div className={classes(styles.tip, styles.suggestion)}>
<IoIosInformationCircleOutline className={styles.icon} />
Try to use smaller node modules, or eliminate ones you don't need.{' '}
<a href="https://bundlephobia.com/" target="_blank" rel="nofollow noopener">
BundlePhobia
</a>{' '}
can help you find smaller modules.
<small>
The size of your node modules grew from {filesize(firstNodeModuleSize)} to{' '}
{filesize(lastNodeModuleSize)}
</small>
</div>
);
}
if (lastNodeModuleSize < firstNodeModuleSize - epsilon) {
return (
<div className={classes(styles.tip, styles.awesome)}>
You dropped {filesize(firstNodeModuleSize - lastNodeModuleSize)} from your node modules
size!
<small>Way to go!</small>
</div>
);
}
return null;
}
function entrypointTip(last: Stats.ToJsonOutput) {
const totalSize = getTotalChunkSize(last);
const entrySize = getEntryChunkSize(last);
const isMajority = entrySize > totalSize / 2;
if ((isMajority || entrySize > 1024 * 512) && totalSize > 1024 * 128) {
return (
<div className={classes(styles.tip, styles.suggestion)}>
<IoIosInformationCircleOutline className={styles.icon} />
Your entrypoint size is pretty big. Investigate code splitting and lazy loading to import
only the code you need.
<small>
Your entrypoint{' '}
{isMajority
? `contains the majority (${filesize(entrySize)}) of your code.`
: `is fairly large (${filesize(entrySize)}).`}
</small>
</div>
);
} else if (entrySize < totalSize / 5) {
return (
<div className={classes(styles.tip, styles.awesome)}>
<IoIosThumbsUp className={styles.icon} />
Your code is split up well, your entrypoint is {formatPercent(entrySize / totalSize)} of
your total code size.
<small>Way to go!</small>
</div>
);
}
return null;
}
function treeShakeTip(last: Stats.ToJsonOutput) {
const percent = getTreeShakablePercent(last);
if (percent > 0.8) {
return;
}
return (
<div className={classes(styles.tip, styles.suggestion)}>
<IoIosInformationCircleOutline className={styles.icon} />
Some of your modules aren't tree shaken. Choose ones that can be tree-shaken to help reduce
your bundle size.
<small>{formatPercent(1 - percent)} of your dependencies aren't tree shaken.</small>
</div>
);
}
export const OverviewSuggestions: React.FC<IProps> = ({ first, last }) => {
return (
<>
{nodeModuleSizeTip(first, last)}
{entrypointTip(last)}
{treeShakeTip(last)}
</>
);
};

Просмотреть файл

@ -0,0 +1,41 @@
import * as React from 'react';
import { classes } from '../util';
import * as styles from './panels.component.scss';
import { HintButton } from '../hints/hint-button.component';
export const enum ArrowDirection {
Up,
Down,
}
interface IProps {
title: React.ReactNode;
value: React.ReactNode;
hint?: React.ComponentType<{}>;
footer?: React.ReactNode;
color: string;
arrow?: ArrowDirection;
onClick?(): void;
}
export const BasePanel: React.FC<IProps> = ({
onClick,
value,
footer,
arrow,
title,
color,
hint,
}) => (
<div className={classes(onClick && styles.clickable, styles.panel)} onClick={onClick}>
<span className={styles.title}>{title}:</span>
{hint && <HintButton hint={hint} className={styles.hint} />}
<span className={styles.value} style={{ color }}>
{value}
{arrow !== undefined && (
<div className={classes(styles.arrow, arrow === ArrowDirection.Up && styles.up)} />
)}
</span>
{footer}
</div>
);

Просмотреть файл

@ -0,0 +1,25 @@
import * as React from 'react';
import { color } from '../util';
import { BasePanel } from './base-panel.component';
interface IProps {
title: React.ReactNode;
value: boolean;
goodValue?: boolean;
formatter?: (value: boolean) => string;
}
const defaultFormatter = (value: boolean) => (value ? 'Yes' : 'No');
export const BooleanPanel: React.FC<IProps> = ({
value,
title,
goodValue = true,
formatter = defaultFormatter,
}) => (
<BasePanel
title={title}
color={!!value === goodValue ? color.blue : color.pink}
value={formatter(value)}
/>
);

Просмотреть файл

@ -0,0 +1,60 @@
import * as React from 'react';
import { color, formatValue } from '../util';
import { BasePanel } from './base-panel.component';
import * as styles from './panels.component.scss';
import { StatDelta } from './stat-delta.component';
interface IProps {
title: React.ReactNode;
value: number;
hint?: React.ComponentType<{}>;
color?: string;
oldValue?: number;
moreIsBetter?: boolean;
formatter?: (value: number) => string;
}
export const CounterPanel: React.FC<IProps> = ({
value,
oldValue,
title,
formatter,
hint,
color: explicitColor,
moreIsBetter,
}) => (
<BasePanel
title={title}
color={explicitColor || getColor(value, oldValue, moreIsBetter)}
hint={hint}
footer={
<StatDelta
newValue={value}
oldValue={oldValue}
className={styles.delta}
formatter={formatter}
/>
}
value={formatValue(value, formatter)}
/>
);
const getColor = (newValue: number, oldValue?: number, moreIsBetter?: boolean) => {
if (oldValue === undefined) {
return color.blue;
}
if (moreIsBetter) {
[oldValue, newValue] = [newValue, oldValue];
}
if (newValue <= oldValue) {
return color.blue;
}
if (newValue / oldValue < 1.02) {
return color.yellow;
}
return color.pink;
};

Просмотреть файл

@ -0,0 +1,92 @@
import * as filesize from 'filesize';
import * as React from 'react';
import { IoIosLeaf, IoIosSad } from 'react-icons/io';
import { RouteComponentProps, withRouter } from 'react-router';
import { ImportType, INodeModule, INodeModuleComparisonOutput } from '../../stat-reducers';
import { classes, color, linkToNodeModule } from '../util';
import { BasePanel } from './base-panel.component';
import * as styles from './panels.component.scss';
import { StatDelta } from './stat-delta.component';
interface IProps {
comparison: INodeModuleComparisonOutput;
inChunk?: number;
}
const getSizeInChunk = (nodeModule?: INodeModule, chunk?: number) => {
if (!nodeModule) {
return 0;
}
if (chunk === undefined) {
return nodeModule.totalSize;
}
let sum = 0;
for (const m of nodeModule.modules) {
if (m.chunks.includes(chunk)) {
sum += m.size;
}
}
return sum;
};
export const NodeModulePanel = withRouter<IProps & RouteComponentProps<{}>>(
({ comparison, inChunk, history }) => {
const oldSize = getSizeInChunk(comparison.old, inChunk);
const newSize = getSizeInChunk(comparison.new, inChunk);
if (oldSize === 0 && newSize === 0) {
return null;
}
return (
<BasePanel
title={comparison.name}
value={filesize(newSize)}
color={newSize > oldSize ? color.yellow : color.blue}
onClick={() => history.push(linkToNodeModule(comparison.name))}
footer={
<span className={styles.delta}>
{!comparison.old ? (
'New (+100%)'
) : !comparison.new ? (
`Deleted (-${filesize(oldSize)}`
) : (
<StatDelta oldValue={oldSize} newValue={newSize} formatter={filesize} />
)}
<span style={{ flex: 1 }} />
{comparison.new && <ModuleTypeBadge type={comparison.new.importType} />}
</span>
}
/>
);
},
);
export const ModuleTypeBadge: React.FC<{ type: number }> = ({ type }) => {
if (type & ImportType.CommonJs && type & ImportType.EsModule) {
return <span className={classes(styles.moduleType, styles.mixed)}>Mixed Tree-Shaking</span>;
}
if (type & ImportType.EsModule) {
return (
<span className={classes(styles.moduleType, styles.shake)}>
<IoIosLeaf />
Tree-Shaken
</span>
);
}
if (type & ImportType.CommonJs) {
return (
<span className={classes(styles.moduleType, styles.cjs)}>
<IoIosSad />
Not Tree-Shaken
</span>
);
}
return null;
};

Просмотреть файл

@ -0,0 +1,6 @@
import * as React from 'react';
import * as styles from './panels.component.scss';
export const PanelArrangement: React.FC = props => (
<div className={styles.arrangement}>{props.children}</div>
);

Просмотреть файл

@ -0,0 +1,105 @@
@import '../variables.scss';
.panel {
color: rgba(#fff, 0.4);
background: rgba(#000, 0.1);
padding: 8px;
position: relative;
+ .panel {
margin-top: 16px;
}
&.clickable {
cursor: pointer;
&:hover {
background: rgba(#fff, 0.1);
}
}
}
.hint {
position: absolute;
top: 8px;
right: 8px;
font-size: 24px;
}
.title {
color: #fff;
display: block;
text-transform: uppercase;
font-family: var(--font-family-mono);
font-size: 0.8em;
}
.value,
.delta {
display: block;
font-weight: 200;
font-size: 20px;
margin-top: 8px;
}
.delta {
cursor: pointer;
display: flex;
}
.value {
font-size: 40px;
}
.arrangement {
display: flex;
flex-wrap: wrap;
margin: -1rem -0.5rem;
.panel {
flex-basis: 100%;
margin: 1rem 0.5rem;
@media only screen and (min-width: 48em) {
flex-basis: calc(33% - 1rem);
}
}
}
.arrow {
border: 0.5em solid transparent;
border-bottom-color: currentColor;
width: 1px;
height: 1px;
transform: scaleX(0.5) translateY(33%) rotate(180deg);
display: inline-block;
&.up {
transform: scaleX(0.5) translateY(-10%) rotate(0deg);
}
}
.moduleType {
font-size: 0.7em;
color: $color-dark;
border-radius: 3px;
display: flex;
align-items: center;
padding: 4px;
svg {
font-size: 1.5em;
}
&.shake {
color: $color-blue;
}
&.cjs {
background: $color-pink;
}
&.mixed {
background: $color-yellow;
}
}

Просмотреть файл

@ -0,0 +1,32 @@
import * as React from 'react';
import { formatDifference, formatPercentageDifference } from '../util';
interface IProps {
newValue: number;
oldValue?: number;
className?: string;
formatter?: (value: number) => string;
}
export class StatDelta extends React.PureComponent<IProps, { percent: boolean }> {
public state = { percent: true };
public render() {
const { oldValue, newValue, className, formatter } = this.props;
if (oldValue === undefined) {
return null;
}
return (
<span className={className} onClick={this.toggleMode}>
{this.state.percent
? formatPercentageDifference(oldValue, newValue)
: formatDifference(oldValue, newValue, formatter)}
</span>
);
}
private readonly toggleMode = () => {
this.setState({ percent: !this.state.percent });
};
}

Просмотреть файл

@ -0,0 +1,3 @@
import * as React from 'react';
export const Placeholder: React.FC = props => <div>{props.children}</div>;

Просмотреть файл

@ -1,6 +1,7 @@
@import './variables.scss';
$bar-height: 9px;
$indefinite-width: 10%;
.progressbar {
padding: 5px;
@ -17,3 +18,20 @@ $bar-height: 9px;
box-shadow: inset 0 1px rgba(#fff, 0.1);
}
}
.indefinite {
> div {
width: $indefinite-width;
animation: indeterminate-animation alternate infinite 1s cubic-bezier(0.645, 0.045, 0.355, 1);
}
}
@keyframes indeterminate-animation {
0% {
transform: translateX(0%);
}
100% {
transform: translateX((100% / $indefinite-width - 1) * 100%);
}
}

Просмотреть файл

@ -1,5 +1,6 @@
import * as React from 'react';
import * as styles from './progress-bar.component.scss';
import { classes } from './util';
interface IProps {
progress: number;
@ -16,3 +17,9 @@ export const ProgressBar: React.FC<IProps> = props => (
<div style={{ width: `${props.progress * 100}%` }} />
</div>
);
export const IndefiniteProgressBar: React.FC = () => (
<div className={classes(styles.progressbar, styles.indefinite)}>
<div />
</div>
);

Просмотреть файл

@ -1,11 +1,11 @@
import * as React from 'react';
import { EnterUrls } from './enter-urls.component';
import { HashRouter as Router, Route } from 'react-router-dom';
import { Dashboard } from './dashboard.component';
import { EnterUrls } from './enter-urls.component';
export const Root: React.FC = () => (
<Router>
<Route path="/" exact component={EnterUrls} />
<Route path="/dashboard" exact component={Dashboard} />
<Route path="/dashboard" component={Dashboard} />
</Router>
);

Просмотреть файл

@ -1,33 +0,0 @@
.panel {
max-width: 300px;
color: #1c0658;
padding: 8px;
+ .panel {
margin-top: 16px;
}
}
.title {
display: block;
text-transform: uppercase;
font-weight: bold;
}
.value,
.delta {
display: block;
text-align: right;
font-family: var(--font-family-mono);
font-size: 20px;
margin-top: 8px;
}
.delta {
cursor: pointer;
}
.value {
font-size: 40px;
margin-top: 16px;
}

Просмотреть файл

@ -1,72 +0,0 @@
import * as React from 'react';
import * as styles from './stat-panels.component.scss';
import * as filesize from 'filesize';
import {
color,
formatPercentageDifference,
formatNumberDifference,
defaultFormatter,
formatFileSizeDifference,
} from './util';
interface IProps {
title: React.ReactNode;
value: number;
oldValue?: number;
filesize?: boolean;
}
export class CounterPanel extends React.PureComponent<IProps, { percent: boolean }> {
public state = { percent: true };
public render() {
return (
<div className={styles.panel} style={{ backgroundColor: this.getColor() }}>
<span className={styles.title}>{this.props.title}</span>
<span className={styles.value}>{this.formatValue(this.props.value)}</span>
{this.props.oldValue !== undefined && (
<span className={styles.delta} onClick={this.toggleMode}>
{this.difference(this.props.oldValue)}
</span>
)}
</div>
);
}
private getColor() {
if (!this.props.oldValue) {
return color.blue;
}
if (this.props.value <= this.props.oldValue) {
return color.blue;
}
if (this.props.value / this.props.oldValue < 1.02) {
return color.yellow;
}
return color.pink;
}
private difference(oldValue: number) {
const delta = this.props.value - oldValue;
return (
<>
{delta !== 0 && <div className={styles.arrow} data-up={delta > 0} />}
{this.state.percent
? formatPercentageDifference(oldValue, this.props.value)
: this.props.filesize
? formatFileSizeDifference(oldValue, this.props.value)
: formatNumberDifference(oldValue, this.props.value)}
</>
);
}
private formatValue(value: number) {
return this.props.filesize ? filesize(value) : defaultFormatter.format(value);
}
private readonly toggleMode = () => {
this.setState({ percent: !this.state.percent });
};
}

Просмотреть файл

@ -1,9 +1,9 @@
import { Base64 } from 'js-base64';
import * as styles from './util.component.scss';
import * as filesize from 'filesize';
export const classes = (...classes: (string | null | undefined)[]) => {
export const classes = (...classList: Array<string | null | undefined>) => {
let str = '';
for (const cls of classes) {
for (const cls of classList) {
if (cls) {
str += ' ' + cls;
}
@ -25,28 +25,67 @@ export const percentageFormatter = new Intl.NumberFormat(undefined, {
maximumSignificantDigits: 3,
});
export const formatValue = (
value: number,
formatter: null | ((value: number) => string) = v => defaultFormatter.format(v),
) => (formatter === null ? String(value) : formatter(value));
/**
* Formats the difference between two numeric values.
*/
export const formatNumberDifference = (a: number, b: number) => {
export const formatDifference = (
a: number,
b: number,
formatter: null | ((value: number) => string) = v => defaultFormatter.format(v),
) => {
const delta = b - a;
const str = defaultFormatter.format(Math.abs(delta));
return `${delta < 0 ? '-' : '+'}${str}`;
return `${delta < 0 ? '-' : '+'}${formatValue(Math.abs(delta), formatter)}`;
};
/**
* Formats the difference between two file sizes.
* Formats a percentage (0-1).
*/
export const formatFileSizeDifference = (a: number, b: number) => {
const delta = b - a;
const str = filesize(Math.abs(delta));
return `${delta < 0 ? '-' : '+'}${str}`;
};
export const formatPercent = (percent: number) =>
`${percentageFormatter.format(Math.abs(percent * 100))}%`;
/**
* Formats the difference between two file sizes.
*/
export const formatPercentageDifference = (a: number, b: number) => {
const delta = (b / a - 1) * 100;
return `${delta < 0 ? '-' : '+'}${percentageFormatter.format(Math.abs(delta))}%`;
if (a === b) {
return '+0%';
}
const delta = b / a - 1;
return `${delta < 0 ? '-' : '+'}${formatPercent(Math.abs(delta))}`;
};
/**
* Formats a duration.
*/
export const formatDuration = (durationInMs: number) => {
if (durationInMs < 1000) {
return `${durationInMs.toFixed(0)} ms`;
}
if (durationInMs < 10000) {
return `${(durationInMs / 1000).toFixed(2)} s`;
}
if (durationInMs < 60000) {
return `${(durationInMs / 1000).toFixed(1)} s`;
}
return `${(durationInMs / 1000 / 60).toFixed(1)} m`;
};
/**
* Creates a link to the given module.
*/
export const linkToModule = (identifier: string) =>
`/dashboard/ownmodule/${Base64.encodeURI(identifier)}`;
/**
* Creates a link to the given node module.
*/
export const linkToNodeModule = (name: string) => `/dashboard/nodemodule/${Base64.encodeURI(name)}`;

Просмотреть файл

@ -1,15 +0,0 @@
import { Epic as PlainEpic, combineEpics } from 'redux-observable';
import { CompareAction, loadAllUrls, doAnalysis } from './actions';
import { IAppState } from './reducer';
import { filter, mergeMap } from 'rxjs/operators';
import { isActionOf } from 'typesafe-actions';
type Epic = PlainEpic<CompareAction, CompareAction, IAppState, {}>;
const loadAllUrlsEpic: Epic = actions =>
actions.pipe(
filter(isActionOf(loadAllUrls)),
mergeMap(action => action.payload.urls.map(url => doAnalysis.request({ url }))),
);
export const epics = combineEpics(loadAllUrlsEpic);

Просмотреть файл

@ -48,3 +48,18 @@ input {
textarea:focus {
border-color: #ff1690;
}
h2 {
margin: 32px 0 16px;
}
h2 small {
display: block;
font-size: 0.6em;
margin-top: 0.5em;
}
a {
color: #36cdc4;
text-decoration: none;
}

Просмотреть файл

@ -1,17 +1,19 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import { applyMiddleware, createStore, Middleware } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import { createEpicMiddleware } from 'redux-observable';
import Worker from 'worker-loader!./worker/index.worker';
import { Root } from './components/root.component';
import { reducer, IAppState } from './reducer';
import { createEpicMiddleware } from 'redux-observable';
import { epics } from './epics';
import { CompareAction } from './actions'
import { CompareAction } from './redux/actions';
import { epics, IServices } from './redux/epics';
import { IAppState, reducer } from './redux/reducer';
import '../../node_modules/flexboxgrid/css/flexboxgrid.css';
import '../../node_modules/normalize.css/normalize.css';
import './index.css';
import { fetchBundlephobiaApi } from './redux/services/bundlephobia-api';
const worker = new Worker();
const workerMiddlware: Middleware = _store => next => action => {
@ -19,13 +21,19 @@ const workerMiddlware: Middleware = _store => next => action => {
next(action);
};
const epicMw = createEpicMiddleware<CompareAction, CompareAction, IAppState>();
const epicMw = createEpicMiddleware<CompareAction, CompareAction, IAppState, IServices>({
dependencies: { bundlephobia: fetchBundlephobiaApi },
});
const store = createStore(
reducer,
undefined,
composeWithDevTools(applyMiddleware(workerMiddlware, epicMw)),
);
worker.onmessage = ev => store.dispatch(ev.data);
worker.onmessage = ev => {
console.log('data', ev.data);
store.dispatch(ev.data);
}
epicMw.run(epics);

Просмотреть файл

@ -1,6 +1,7 @@
import { createStandardAction, createAsyncAction, ActionType } from 'typesafe-actions';
import { IError } from '@mixer/retrieval';
import { ActionType, createAsyncAction, createStandardAction } from 'typesafe-actions';
import { Stats } from 'webpack';
import { IBundlephobiaStats } from './services/bundlephobia-api';
/**
* Requests analysis to run for a bundle.
@ -10,7 +11,21 @@ export const doAnalysis = createAsyncAction(
'doAnalysisSuccess',
'doAnalysisFailure',
'doAnalysisCancel',
)<{ url: string }, { url: string; data: Stats.ToJsonOutput }, IError & { url: string }, { url: string }>();
)<
{ url: string },
{ url: string; data: Stats.ToJsonOutput },
IError & { url: string },
{ url: string }
>();
/**
* Requests analysis to run for a bundle.
*/
export const fetchBundlephobiaData = createAsyncAction(
'getBundlephobiaRequest',
'getBundlephobiaSuccess',
'getBundlephobiaFailure',
)<{ name: string }, IBundlephobiaStats, IError & { name: string }>();
/**
* Loads bundles for all the requested urls.
@ -32,4 +47,5 @@ export type CompareAction = ActionType<
| typeof webworkerErrored
| typeof loadAllUrls
| typeof clearLoadedBundles
| typeof fetchBundlephobiaData
>;

35
src/client/redux/epics.ts Normal file
Просмотреть файл

@ -0,0 +1,35 @@
import { combineEpics, Epic as PlainEpic } from 'redux-observable';
import { of } from 'rxjs';
import { catchError, filter, map, mergeMap } from 'rxjs/operators';
import { isActionOf } from 'typesafe-actions';
import { CompareAction, doAnalysis, fetchBundlephobiaData, loadAllUrls } from './actions';
import { IAppState } from './reducer';
import { IBundlephobiaApi } from './services/bundlephobia-api';
import { HttpError } from './services/http-error';
export interface IServices {
bundlephobia: IBundlephobiaApi;
}
type Epic = PlainEpic<CompareAction, CompareAction, IAppState, IServices>;
const loadAllUrlsEpic: Epic = actions =>
actions.pipe(
filter(isActionOf(loadAllUrls)),
mergeMap(action => action.payload.urls.map(url => doAnalysis.request({ url }))),
);
const loadBundlephobiaInfo: Epic = (actions, _, { bundlephobia }) =>
actions.pipe(
filter(isActionOf(fetchBundlephobiaData.request)),
mergeMap(action =>
bundlephobia.getBundleStats(action.payload.name).pipe(
map(fetchBundlephobiaData.success),
catchError((err: HttpError) =>
of(fetchBundlephobiaData.failure({ ...err.retrievalError, name: action.payload.name })),
),
),
),
);
export const epics = combineEpics(loadAllUrlsEpic, loadBundlephobiaInfo);

Просмотреть файл

@ -1,16 +1,17 @@
import {
error,
idleRetrieval,
IRetrievalError,
Retrieval,
RetrievalState,
idleRetrieval,
workingRetrival,
success,
error,
IRetrievalError,
workingRetrival,
} from '@mixer/retrieval';
import { createSelector } from 'reselect';
import { CompareAction, doAnalysis, clearLoadedBundles } from './actions';
import { getType } from 'typesafe-actions';
import { Stats } from 'webpack';
import { clearLoadedBundles, CompareAction, doAnalysis, fetchBundlephobiaData } from './actions';
import { IBundlephobiaStats } from './services/bundlephobia-api';
/**
* Possible error codes.
@ -25,15 +26,21 @@ export interface IAppState {
* List of bundle loading states.
*/
bundles: Readonly<{ [url: string]: Retrieval<Stats.ToJsonOutput> }>;
/**
* Mapping of bundlephobia dependency information.
*/
bundlephobiaData: { [moduleName: string]: Retrieval<IBundlephobiaStats> };
}
declare const INITIAL_FILES: string[];
const initialState: IAppState = {
bundles: INITIAL_FILES.reduce((acc, key) => ({ ...acc, [key]: idleRetrieval }), {}),
bundlephobiaData: {},
};
export const reducer = (state = initialState, action: CompareAction) => {
export const reducer = (state = initialState, action: CompareAction): IAppState => {
switch (action.type) {
case getType(clearLoadedBundles):
return {
@ -66,6 +73,27 @@ export const reducer = (state = initialState, action: CompareAction) => {
...state,
bundles: { ...state.bundles, [action.payload.url]: idleRetrieval },
};
case getType(fetchBundlephobiaData.request):
return {
...state,
bundlephobiaData: { ...state.bundlephobiaData, [action.payload.name]: workingRetrival },
};
case getType(fetchBundlephobiaData.success):
return {
...state,
bundlephobiaData: {
...state.bundlephobiaData,
[action.payload.name]: success(action.payload),
},
};
case getType(fetchBundlephobiaData.failure):
return {
...state,
bundlephobiaData: {
...state.bundlephobiaData,
[action.payload.name]: error(action.payload),
},
};
default:
return state;
}
@ -145,3 +173,9 @@ export const getGroupedBundleState = createSelector(
return status;
},
);
/**
* Retrieves bundlephovia data for the given module name.
*/
export const getBundlephobiaData = (state: IAppState, moduleName: string) =>
state.bundlephobiaData[moduleName] || idleRetrieval;

Просмотреть файл

@ -0,0 +1,62 @@
import { from, Observable } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
import { HttpError } from './http-error';
export interface IDependencyStat {
name: string;
approximateSize: number;
}
/**
* Stats returned from the bundlephobia API.
*/
export interface IBundlephobiaStats {
dependencyCount: number;
size: number;
gzip: number;
name: string;
hasJSNext: boolean;
hasJSModule: boolean;
hasSideEffects: true;
version: string;
repository: string;
topLevelExports?: string[];
dependencySizes: IDependencyStat[];
}
/**
* Type that gets bundle stats from the bundlephobia API.
*/
export interface IBundlephobiaApi {
getBundleStats(bundle: string): Observable<IBundlephobiaStats>;
}
/**
* Bundlephoba API implementation using browser fetch.
*/
export const fetchBundlephobiaApi: IBundlephobiaApi = {
getBundleStats(bundle) {
const url = `https://bundlephobia.com/api/size?package=${bundle}`;
return from(
fetch(url, {
headers: {
'X-Bundlephobia-User': '@mixer/webpack-bundle-compare',
},
}),
).pipe(
mergeMap(async res => {
if (res.ok) {
return res.json();
}
throw new HttpError(url, {
statusCode: res.status,
serviceError: {
errorCode: 0,
errorMessage: await res.text(),
},
});
}),
);
},
};

Просмотреть файл

@ -0,0 +1,11 @@
import { IError } from '@mixer/retrieval';
export class HttpError extends Error {
constructor(public readonly url: string, public readonly retrievalError: IError) {
super(
`An unexpected ${retrievalError.statusCode} occurred calling ${url}: "${
retrievalError.serviceError ? retrievalError.serviceError.errorMessage : 'unknown'
}"`,
);
}
}

Просмотреть файл

@ -7,24 +7,36 @@ const anyPathSeparator = /\/|\\/;
* Replaces the loader path in an identifier.
* '(<loader expression>!)?/path/to/module.js'
*/
const replaceLoaderInIdentifier = (identifier: string) => identifier.replace(loaderRegex, '');
export const replaceLoaderInIdentifier = (identifier: string) =>
identifier.replace(loaderRegex, '');
/**
* Normalizes an identifier so that it carries over time.
*/
const normalizeIdentifier = (identifier: string) =>
export const normalizeIdentifier = (identifier: string) => identifier.replace(/ [a-z0-9]+$/, '');
/**
* Normalizes an identifier so that it carries over time.
*/
const humanReadableIdentifier = (identifier: string) =>
replaceLoaderInIdentifier(identifier).replace(/ [a-z0-9]+$/, '');
// tslint:disable-next-line
const cacheByArg = <T extends Function>(fn: T): T => {
const cacheMap = new WeakMap<any, any>();
return function(this: any, arg: any) {
if (!cacheMap.has(arg)) {
return function(this: any, arg1: any, arg2: any) {
let valueMap = cacheMap.get(arg1);
if (!valueMap) {
valueMap = arg2 && typeof arg2 === 'object' ? new WeakMap<any, any>() : new Map<any, any>();
}
if (!valueMap.has(arg2)) {
const value = fn.apply(this, arguments);
cacheMap.set(arg, value);
valueMap.set(arg2, value);
return value;
}
return cacheMap.get(arg);
return valueMap.get(arg2);
} as any;
};
@ -35,6 +47,22 @@ export const getTotalChunkSize = cacheByArg((stats: Stats.ToJsonOutput) =>
stats.chunks!.reduce((sum, chunk) => sum + chunk.size, 0),
);
/**
* Returns the size of the entry chunk(s).
*/
export const getEntryChunkSize = cacheByArg((stats: Stats.ToJsonOutput) =>
stats.chunks!.filter(c => c.entry).reduce((sum, chunk) => sum + chunk.size, 0),
);
/**
* Bitfield of module types.
*/
export const enum ImportType {
Unknown = 0,
EsModule = 1 << 1,
CommonJs = 1 << 2,
}
/**
* Type containing metadata about an external node module.
*/
@ -53,50 +81,273 @@ export interface INodeModule {
* Modules imported from this dependency.
*/
modules: Stats.FnModules[];
/**
* Type of the node module.
*/
importType: ImportType;
}
const getNodeModuleFromIdentifier = (identifier: string): string | null => {
const parts = replaceLoaderInIdentifier(identifier).split(anyPathSeparator);
for (let i = parts.length - 1; i >= 0; i--) {
if (parts[i] !== 'node_modules') {
continue;
}
let packageName = parts[i + 1];
if (packageName[0] === '@') {
packageName += '/' + parts[i + 2];
}
return packageName;
}
return null;
};
/**
* Get webpack modules, either globally ro in a single chunk.
*/
export const getWebpackModules = (stats: Stats.ToJsonOutput, filterToChunk?: number) => {
let modules: Stats.FnModules[] = [];
if (!stats.modules) {
return modules;
}
for (const parent of stats.modules) {
if (filterToChunk !== undefined && !parent.chunks.includes(filterToChunk)) {
continue;
}
// If it has nested modules, it's a concatened chunk. Remove the
// concatenation and only emit children.
if (parent.modules) {
modules = modules.concat(parent.modules);
} else {
modules.push(parent);
}
}
return modules;
};
/**
* Gets the type of import of the given module.
*/
export const getImportType = (importedModule: Stats.FnModules) =>
(importedModule.reasons as any).reduce(
(flags: number, reason: { type: string }) =>
(flags |= reason.type.includes('cjs')
? ImportType.CommonJs
: reason.type.includes('harmony')
? ImportType.EsModule
: ImportType.Unknown),
0,
);
/**
* Returns the number of dependencies.
*/
export const getNodeModules = cacheByArg(
(stats: Stats.ToJsonOutput): ReadonlyArray<INodeModule> => {
const modules: { [key: string]: INodeModule } = {};
export const getNodeModules = cacheByArg((stats: Stats.ToJsonOutput, inChunk?: number) => {
const modules: { [key: string]: INodeModule } = {};
for (const importedModule of stats.modules!) {
const parts = replaceLoaderInIdentifier(importedModule.identifier).split(anyPathSeparator);
for (let i = parts.length - 1; i >= 0; i--) {
if (parts[i] !== 'node_modules') {
continue;
}
let packageName = parts[i + 1];
if (packageName[0] === '@') {
packageName += '/' + parts[i + 2];
}
const previous = modules[packageName];
if (!previous) {
modules[packageName] = {
name: packageName,
totalSize: importedModule.size,
modules: [importedModule],
};
} else {
previous.totalSize += importedModule.size;
previous.modules.push(importedModule);
}
break;
}
for (const importedModule of getWebpackModules(stats, inChunk)) {
const packageName = getNodeModuleFromIdentifier(importedModule.identifier);
if (!packageName) {
continue;
}
return Object.values(modules);
},
const moduleType = getImportType(importedModule);
const previous = modules[packageName];
if (!previous) {
modules[packageName] = {
name: packageName,
totalSize: importedModule.size,
modules: [importedModule],
importType: moduleType,
};
} else {
previous.totalSize += importedModule.size;
previous.importType |= moduleType;
previous.modules.push(importedModule);
}
}
return modules;
});
/**
* Types of importable modules.
*/
export const enum ModuleType {
Javascript,
Style,
External,
NodeModule,
}
/**
* Identifies a module type, given an ID.
*/
export const identifyModuleType = (id: string): ModuleType => {
if (id.includes('style-loader') || id.includes('css-loader')) {
return ModuleType.Style;
}
if (id.startsWith('external ')) {
return ModuleType.External;
}
if (id.includes('node_modules')) {
return ModuleType.NodeModule;
}
return ModuleType.Javascript;
};
/**
* Grouped output comparing an old and new node module.
*/
export interface IWebpackModuleComparisonOutput {
identifier: string;
readableId: string;
name: string;
type: ModuleType;
fromSize: number;
toSize: number;
nodeModule?: string;
old?: Stats.FnModules;
new?: Stats.FnModules;
}
/**
* Returns a grouped comparison of the old and new modules from the stats.
*/
export const compareAllModules = (
oldStats: Stats.ToJsonOutput,
newStats: Stats.ToJsonOutput,
inChunk?: number,
) => {
const oldModules = getWebpackModules(oldStats, inChunk);
const newModules = getWebpackModules(newStats, inChunk);
const output: { [name: string]: IWebpackModuleComparisonOutput } = {};
for (const m of oldModules) {
const normalized = normalizeIdentifier(m.identifier);
output[normalized] = {
identifier: normalized,
readableId: humanReadableIdentifier(m.identifier),
name: replaceLoaderInIdentifier(m.name),
type: identifyModuleType(m.identifier),
nodeModule: getNodeModuleFromIdentifier(m.identifier) || undefined,
toSize: 0,
fromSize: m.size,
old: m,
};
}
for (const m of newModules) {
const normalized = normalizeIdentifier(m.identifier);
if (output[normalized]) {
output[normalized].new = m;
output[normalized].toSize = m.size;
} else {
output[normalized] = {
identifier: normalized,
readableId: humanReadableIdentifier(m.identifier),
name: replaceLoaderInIdentifier(m.name),
type: identifyModuleType(m.identifier),
nodeModule: getNodeModuleFromIdentifier(m.identifier) || undefined,
fromSize: 0,
toSize: m.size,
new: m,
};
}
}
return output;
};
/**
* Grouped output comparing an old and new node module.
*/
export interface INodeModuleComparisonOutput {
name: string;
old?: INodeModule;
new?: INodeModule;
}
/**
* Returns a grouped comparison of the old and new modules from the stats.
*/
export const compareNodeModules = (
oldStats: Stats.ToJsonOutput,
newStats: Stats.ToJsonOutput,
inChunk?: number,
) => {
const oldModules = getNodeModules(oldStats, inChunk);
const newModules = getNodeModules(newStats, inChunk);
const output: { [name: string]: INodeModuleComparisonOutput } = {};
for (const name of Object.keys(oldModules)) {
output[name] = { name, old: oldModules[name] };
}
for (const name of Object.keys(newModules)) {
if (output[name]) {
output[name].new = newModules[name];
} else {
output[name] = { name, new: oldModules[name] };
}
}
return Object.values(output);
};
/**
* Gets the number of node modules.
*/
export const getNodeModuleSize = cacheByArg((stats: Stats.ToJsonOutput, inChunk?: number) =>
Object.values(getNodeModules(stats, inChunk)).reduce((acc, m) => m.totalSize + acc, 0),
);
/**
* Gets the number of node modules.
*/
export const getNodeModuleCount = (stats: Stats.ToJsonOutput) => getNodeModules(stats).length;
export const getNodeModuleCount = (stats: Stats.ToJsonOutput, inChunk?: number) =>
Object.keys(getNodeModules(stats, inChunk)).length;
/**
* Gets the number of node modules.
*/
export const getTreeShakablePercent = cacheByArg((stats: Stats.ToJsonOutput, inChunk?: number) => {
const modules = Object.values(getNodeModules(stats, inChunk));
if (modules.length === 0) {
return 1;
}
let harmony = 0;
for (const { importType: moduleType } of modules) {
if (moduleType & ImportType.EsModule && !(moduleType & ImportType.CommonJs)) {
harmony++;
}
}
return harmony / modules.length;
});
/**
* Gets the number of node modules.
*/
export const getTotalModuleCount = (stats: Stats.ToJsonOutput, inChunk?: number) =>
getWebpackModules(stats, inChunk)!.length;
/**
* Gets the number of node modules.
*/
export const getAverageChunkSize = (stats: Stats.ToJsonOutput) =>
stats.chunks!.reduce((acc, c) => acc + c.size / stats.chunks!.length, 0);
export interface IModuleTreeNode {
/**
@ -122,7 +373,8 @@ export interface IModuleTreeNode {
export const getModuleTree = cacheByArg(
(stats: Stats.ToJsonOutput): IModuleTreeNode => {
const building: IModuleTreeNode[] = stats.modules!.map(m => ({
const modules = getWebpackModules(stats);
const building: IModuleTreeNode[] = modules.map(m => ({
id: Number(m.id),
name: m.name,
size: m.size,
@ -130,7 +382,7 @@ export const getModuleTree = cacheByArg(
}));
const entries: IModuleTreeNode[] = [];
for (const item of stats.modules!) {
for (const item of modules) {
const self = building[Number(item.id)];
const { reasons } = item as any;
if (reasons.some((r: any) => r.type === 'single entry')) {
@ -163,35 +415,7 @@ export interface IModuleDiffEntry {
}
/**
* Gets metadata about the size difference of all modules.
* Gets all direct imports of the given node module.
*/
export const getModulesDiff = (
first: Stats.ToJsonOutput,
last: Stats.ToJsonOutput,
): IModuleDiffEntry[] => {
const moduleMap: { [id: string]: IModuleDiffEntry } = {};
for (const m of first.modules!) {
moduleMap[normalizeIdentifier(m.identifier)] = {
name: replaceLoaderInIdentifier(m.name),
fromSize: m.size,
toSize: 0,
};
}
const changed: IModuleDiffEntry[] = [];
for (const m of last.modules!) {
const id = normalizeIdentifier(m.identifier);
const existing = moduleMap[id];
if (existing) {
existing.toSize = m.size;
} else {
moduleMap[id] = {
name: replaceLoaderInIdentifier(m.name),
fromSize: 0,
toSize: m.size,
};
}
}
return changed.concat(Object.values(moduleMap));
};
export const getDirectImportsOfNodeModule = (stats: Stats.ToJsonOutput, name: string) =>
stats.modules!.filter(m => getNodeModuleFromIdentifier(m.name) === name);

16
src/client/types/webpack.d.ts поставляемый Normal file
Просмотреть файл

@ -0,0 +1,16 @@
import { Stats } from 'webpack';
declare module 'webpack' {
namespace Stats {
// tslint:disable-next-line
export interface Reason {
moduleId: number;
moduleIdentifier: string;
module: string;
moduleName: string;
type: string;
loc: string;
userRequest: string;
}
}
}

2
src/client/types/worker.d.ts поставляемый
Просмотреть файл

@ -1,4 +1,4 @@
declare module "worker-loader!*" {
declare module 'worker-loader!*' {
class WebpackWorker extends Worker {
constructor();
}

Просмотреть файл

@ -1,7 +1,10 @@
import { Observable, from } from 'rxjs';
import { CompareAction, doAnalysis } from '../actions';
import { ErrorCode } from '../reducer';
import { from, Observable } from 'rxjs';
import { Stats } from 'webpack';
import { CompareAction, doAnalysis } from '../redux/actions';
import { ErrorCode } from '../redux/reducer';
import { Semaphore } from './semaphore';
import { inflate } from 'pako';
import { decode } from 'msgpack-lite';
const downloadSemaphore = new Semaphore(1);
@ -13,7 +16,7 @@ export function download(url: string): Observable<CompareAction> {
try {
const res = await fetch(url);
return res.ok
? doAnalysis.success({ url, data: await res.json() })
? doAnalysis.success({ url, data: processDownload(await res.arrayBuffer()) })
: doAnalysis.failure({
url,
statusCode: res.status,
@ -23,6 +26,7 @@ export function download(url: string): Observable<CompareAction> {
},
});
} catch (e) {
console.log('failed', e.stack);
return doAnalysis.failure({
url,
statusCode: 500,
@ -37,3 +41,16 @@ export function download(url: string): Observable<CompareAction> {
})(),
);
}
function processDownload(rawResponse: ArrayBuffer): Stats.ToJsonOutput {
const asArray = new Uint8Array(rawResponse);
const data = asArray[0] === 0x1f && asArray[1] === 0x8b ? inflate(asArray) : asArray;
const stats: Stats.ToJsonOutput =
data[0] === '{'.charCodeAt(0) ? JSON.parse(new TextDecoder().decode(data)) : decode(data);
if (stats.modules) {
stats.modules = stats.modules.filter(m => m.chunks.length !== 0);
}
return stats;
}

Просмотреть файл

@ -1,7 +1,7 @@
import { Observable, EMPTY } from 'rxjs';
import { CompareAction, webworkerErrored, doAnalysis } from '../actions';
import { ErrorCode } from '../reducer';
import { EMPTY, Observable } from 'rxjs';
import { getType } from 'typesafe-actions';
import { CompareAction, doAnalysis, webworkerErrored } from '../redux/actions';
import { ErrorCode } from '../redux/reducer';
import { download } from './download';
const ctx: Worker = self as any;

Просмотреть файл

@ -1,4 +1,4 @@
import { Observable, Subject, of } from 'rxjs';
import { Observable, of, Subject } from 'rxjs';
import { filter, take } from 'rxjs/operators';
/**

Просмотреть файл

@ -3,7 +3,7 @@
"extends": ["tslint:recommended", "tslint-config-prettier"],
"rules": {
"object-literal-sort-keys": false,
"no-var-requires": false,
"no-unused-expression": false
"no-bitwise": false,
"variable-name": false
}
}

Просмотреть файл

@ -1,6 +1,7 @@
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
const { DefinePlugin } = require('webpack');
const CopyPlugin = require('copy-webpack-plugin');
module.exports = {
mode: 'development',
@ -47,6 +48,7 @@ module.exports = {
new HtmlWebpackPlugin({
title: 'Webpack Bundle Compare',
}),
new CopyPlugin([{ from: path.join(__dirname, 'public'), to: 'public' }]),
new DefinePlugin({
INITIAL_FILES: process.env.WBC_FILES
? JSON.stringify(process.env.WBC_FILES.split(','))
@ -54,6 +56,6 @@ module.exports = {
}),
],
devServer: {
contentBase: path.join(__dirname, 'public'),
disableHostCheck: true,
},
};