├── .babelrc ├── .eslintrc.js ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc.json ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── public └── index.html ├── scripts └── convert2webp.js ├── src ├── components │ ├── ImageWithResourceHint │ │ └── ImageViewer.tsx │ ├── Layout.tsx │ ├── LazyLoadComponent │ │ ├── EmojiPicker.tsx │ │ └── EmojiPickerContainer.tsx │ ├── LazyLoadImage │ │ └── LazyLoadImage.tsx │ ├── LazyLoadModule │ │ └── RandomNumberCard.tsx │ ├── WebWorker │ │ ├── README.md │ │ ├── WebWorkerExample.tsx │ │ └── worker.ts │ └── WebpImage │ │ └── WebpImage.tsx ├── index.tsx ├── serviceWorker │ ├── register.ts │ └── sw.js ├── static │ └── images │ │ ├── night-sky.jpg │ │ ├── night-sky.webp │ │ ├── prefetch │ │ ├── sea.jpg │ │ └── sea.webp │ │ └── preload │ │ ├── mountain.jpg │ │ └── mountain.webp ├── styles │ └── index.css ├── types │ ├── images.d.ts │ └── webworker.d.ts └── utils │ └── numbers.ts ├── tsconfig.json └── webpack ├── dev.config.js └── prod.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-syntax-dynamic-import", 9 | "@babel/plugin-proposal-class-properties", 10 | "@babel/plugin-transform-runtime" 11 | ] 12 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | 'prettier' 11 | ], 12 | "overrides": [ 13 | ], 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "ecmaVersion": "latest", 17 | "sourceType": "module" 18 | }, 19 | "plugins": [ 20 | "react", 21 | "@typescript-eslint", 22 | "prettier" 23 | ], 24 | "rules": { 25 | "prettier/prettier": 'error' 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run lint 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "tabWidth": 4 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 David Dias 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Web performance optimization with Webpack 2 | A webpack boilerplate with configurations, plugins and best practice tips to improve the performance of your front-end app and make it load faster. 3 | 4 | ## Contents 5 | - [HTML](#html) 6 | - [Minified HTML](#minified-html) 7 | - [CSS](#css) 8 | - [Extracts CSS](#extracts-css) 9 | - [Minified CSS](#minified-css) 10 | - [Inline Critical CSS](#inline-critical-css) 11 | - [Images](#images) 12 | - [Compress images](#compress-images) 13 | - [Use WebP Images](#use-webp-images) 14 | - [Preload and prefetch images](#preload-and-prefetch-images) 15 | - [Lazy loading images](#lazy-loading-images) 16 | 19 | - [JavaScript](#javascript) 20 | - [Split chunks](#split-chunks) 21 | - [Minified JS](#minified-js) 22 | - [Lazy Loading JS](#lazy-loading-js) 23 | - [Use web worker](#use-web-worker) 24 | - [Fonts](#fonts) 25 | 28 | - [Cache font resources](#cache-font-resources) 29 | - [Caching](#caching) 30 | - [Use `[contenthash]` in output filenames](#use-contenthash-in-output-filenames) 31 | - [Others](#others) 32 | - [Compress text files](#compress-text-files) 33 | 34 | ## Getting Started 35 | 1. Make sure you have a fresh version of [Node.js](https://nodejs.org/en/) and NPM installed. The current Long Term Support (LTS) release is an ideal starting point 36 | 37 | 2. Clone this repository to your computer: 38 | ```sh 39 | git clone https://github.com/vannizhang/web-performance-optimization-with-webpack.git 40 | ``` 41 | 42 | 43 | 3. From the project's root directory, install the required packages (dependencies): 44 | 45 | ```sh 46 | npm install 47 | ``` 48 | 49 | 4. To run and test the app on your local machine (http://localhost:8080): 50 | 51 | ```sh 52 | # it will start a server instance and begin listening for connections from localhost on port 8080 53 | npm run start 54 | ``` 55 | 56 | 5. To build/deploye the app, you can run: 57 | 58 | ```sh 59 | # it will place all files needed for deployment into the /dist directory 60 | npm run build 61 | ``` 62 | 63 | ## HTML 64 | 65 | ### Minified HTML 66 | Minify the HTML by removing unnecessary spaces, comments and attributes to reduce the size of output HTML file and speed up load times. 67 | 68 | The [`HtmlWebpackPlugin`](https://webpack.js.org/plugins/html-webpack-plugin/) has the [`minify`](https://github.com/jantimon/html-webpack-plugin#minification) option to control how the output html shoud be minified: 69 | 70 | [`webpack.config.js`](./webpack/prod.config.js) 71 | ```js 72 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 73 | 74 | module.exports = { 75 | //... 76 | plugins: [ 77 | new HtmlWebpackPlugin({ 78 | ... 79 | minify: { 80 | html5 : true, 81 | collapseWhitespace : true, 82 | minifyCSS : true, 83 | minifyJS : true, 84 | minifyURLs : false, 85 | removeComments : true, 86 | removeEmptyAttributes : true, 87 | removeOptionalTags : true, 88 | // Remove attributes when value matches default. 89 | removeRedundantAttributes : true, 90 | // Remove type="text/javascript" from script tags. 91 | // Other type attribute values are left intact 92 | removeScriptTypeAttributes : true, 93 | // Remove type="text/css" from style and link tags. 94 | // Other type attribute values are left intact 95 | removeStyleLinkTypeAttributese : true, 96 | // Replaces the doctype with the short (HTML5) doctype 97 | useShortDoctype : true 98 | } 99 | }) 100 | ] 101 | } 102 | ``` 103 | 104 | ## CSS 105 | 106 | ### Extracts CSS 107 | The extracted css stylesheets can be cached separately. Therefore if your app code changes, the browser only needs to fetch the JS files that changed. 108 | 109 | Use [`MiniCssExtractPlugin`](https://webpack.js.org/plugins/mini-css-extract-plugin/) to extract CSS into separate files: 110 | 111 | [`webpack.config.js`](./webpack/prod.config.js) 112 | ```js 113 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 114 | 115 | module.exports = { 116 | //... 117 | module: { 118 | rules: [ 119 | //... 120 | { 121 | test: /\.css$/i, 122 | include: path.resolve(__dirname, '..', 'src'), 123 | use: [ 124 | MiniCssExtractPlugin.loader, 125 | { 126 | loader: "css-loader", options: { 127 | sourceMap: true 128 | } 129 | }, 130 | { 131 | loader: 'postcss-loader' 132 | } 133 | ], 134 | } 135 | ] 136 | }, 137 | plugins: [ 138 | new MiniCssExtractPlugin({ 139 | filename: '[name].[contenthash].css' 140 | }), 141 | ] 142 | } 143 | ``` 144 | 145 | ### Minify CSS 146 | 147 | Remove unnecessary characters, such as comments, whitespaces, and indentation to reduce the size of output CSS files and speed up how long it takes for the browser to download and execute it. 148 | 149 | Use the [`css-minimizer-webpack-plugin`](https://webpack.js.org/plugins/css-minimizer-webpack-plugin/) to optimize and minify the output CSS. 150 | 151 | [`webpack.config.js`](./webpack/prod.config.js) 152 | ```js 153 | const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); 154 | 155 | module.exports = { 156 | //... 157 | optimization: { 158 | minimizer: [ 159 | new CssMinimizerPlugin(), 160 | ], 161 | }, 162 | }; 163 | ``` 164 | 165 | ### Inline Critical CSS 166 | Inlining extracted CSS for critical (above-the-fold) content in the `` of the HTML document eliminates the need to make an additional request to fetch these styles, which can help to speed up render times. 167 | 168 | Use the [`html-critical-webpack-plugin`](https://github.com/anthonygore/html-critical-webpack-plugin) to extracts, minifies and inlines above-the-fold CSS. 169 | 170 | [`webpack.config.js`](./webpack/prod.config.js) 171 | ```js 172 | const HtmlCriticalPlugin = require("html-critical-webpack-plugin"); 173 | 174 | module.exports = { 175 | //... 176 | plugins: [ 177 | new HtmlWebpackPlugin({ ... }), 178 | new MiniCssExtractPlugin({ ... }), 179 | new HtmlCriticalPlugin({ 180 | base: path.join(path.resolve(__dirname), '..', 'dist/'), 181 | src: 'index.html', 182 | dest: 'index.html', 183 | inline: true, 184 | minify: true, 185 | extract: true, 186 | width: 1400, 187 | height: 900, 188 | penthouse: { 189 | blockJSRequests: false, 190 | } 191 | }), 192 | ] 193 | } 194 | ``` 195 | 196 | ## Images 197 | According to [Ilya Grigorik](https://www.igvita.com/): 198 | > Images often account for most of the downloaded bytes on a web page and also often occupy a significant amount of visual space. As a result, optimizing images can often yield some of the largest byte savings and performance improvements for your website. [More details](https://web.dev/compress-images/) 199 | 200 | ### Compress images 201 | 202 | Use [`ImageMinimizerWebpackPlugin`](https://webpack.js.org/plugins/image-minimizer-webpack-plugin/) to minify PNG, JPEG, GIF, SVG and WEBP images with [`imagemin`](https://github.com/imagemin/imagemin), [squoosh](https://github.com/GoogleChromeLabs/squoosh), [sharp](https://github.com/lovell/sharp) or [svgo](https://github.com/svg/svgo). 203 | 204 | [`webpack.config.js`](./webpack/prod.config.js) 205 | ```js 206 | const ImageMinimizerPlugin = require("image-minimizer-webpack-plugin"); 207 | 208 | module.exports = { 209 | //... 210 | optimization: { 211 | //.. 212 | minimizer: [ 213 | //.. 214 | new ImageMinimizerPlugin({ 215 | minimizer: { 216 | implementation: ImageMinimizerPlugin.squooshMinify, 217 | options: { 218 | // encodeOptions: { 219 | // mozjpeg: { 220 | // // That setting might be close to lossless, but it’s not guaranteed 221 | // // https://github.com/GoogleChromeLabs/squoosh/issues/85 222 | // quality: 100, 223 | // }, 224 | // } 225 | } 226 | } 227 | }) 228 | ] 229 | } 230 | } 231 | ``` 232 | 233 | ### Use WebP Images 234 | WebP images are smaller than their JPEG and PNG counterparts - usually on the magnitude of a 25–35% reduction in filesize. This decreases page sizes and improves performance. [More details](https://web.dev/serve-images-webp/) 235 | 236 | Use `imagemin` and `imagemin-webp` to convert images to WebP, here is a script that converts all JPEG and PNG images in the `./src/static/images` folder to WebP: 237 | 238 | [convert2webp.js](./scripts/convert2webp.js) 239 | ```js 240 | const path = require('path'); 241 | const imageFolder = path.join(__dirname, '..', 'src', 'static', 'images') 242 | const { promises } = require('node:fs') 243 | const { promisify } = require('node:util') 244 | const fs = require('graceful-fs'); 245 | 246 | const fsPromises = promises; 247 | const writeFile = promisify(fs.writeFile); 248 | 249 | const move2originalDir = async(files)=>{ 250 | 251 | for(const file of files){ 252 | const currDestinationPath = file.destinationPath.replace(/\\/g, '/'); 253 | 254 | const source = path.parse(file.sourcePath); 255 | const destination = path.parse(currDestinationPath); 256 | const newDestinationPath = `${source.dir}/${destination.name}${destination.ext}`; 257 | 258 | // console.log(currDestinationPath, newDestinationPath) 259 | 260 | if(currDestinationPath === newDestinationPath){ 261 | continue 262 | } 263 | 264 | await fsPromises.mkdir(path.dirname(newDestinationPath), { recursive: true }); 265 | 266 | // save a webp file in the original directory 267 | await writeFile(newDestinationPath, file.data); 268 | 269 | // remove the original webp file because it's no longer needed 270 | await fsPromises.unlink(currDestinationPath) 271 | } 272 | } 273 | 274 | const run = async () => { 275 | const imagemin = (await import("imagemin")).default; 276 | const webp = (await import("imagemin-webp")).default; 277 | 278 | const processedPNGs = await imagemin([`${imageFolder}/**/*.png`], { 279 | destination: imageFolder, 280 | preserveDirectories: true, 281 | plugins: [ 282 | webp({ 283 | lossless: true, 284 | }), 285 | ], 286 | }); 287 | 288 | await move2originalDir(processedPNGs) 289 | console.log("PNGs processed"); 290 | 291 | const processedJPGs = await imagemin([`${imageFolder}/**/*.{jpg,jpeg}`], { 292 | destination: imageFolder, 293 | preserveDirectories: true, 294 | plugins: [ 295 | webp({ 296 | quality: 65, 297 | }), 298 | ], 299 | }); 300 | 301 | await move2originalDir(processedJPGs) 302 | console.log("JPGs and JPEGs processed"); 303 | } 304 | 305 | run(); 306 | ``` 307 | 308 | Modify `"scripts"` section in `package.json` to add `"pre"` scripts, so npm can automatically run `convert2webp` before `npm run build` or `npm run start`. 309 | ```js 310 | { 311 | //... 312 | "scripts": { 313 | "convert2webp": "node ./scripts/convert2webp.js", 314 | "prestart": "npm run convert2webp", 315 | "start": "webpack serve --mode development --open --config webpack/dev.config.js", 316 | "prebuild": "npm run convert2webp", 317 | "build": "webpack --mode production --config webpack/prod.config.js" 318 | }, 319 | } 320 | ``` 321 | 322 | Here is an example of serving WebP images to WebP to newer browsers and a fallback image to older browsers: 323 | ```js 324 | import React from 'react' 325 | import nightSkyWebP from '../../static/images/night-sky.webp' 326 | import nightSkyJPG from '../../static/images/night-sky.jpg' 327 | 328 | const WebpImage = () => { 329 | return ( 330 | 331 | 332 | 333 | 334 | 335 | ) 336 | } 337 | 338 | export default WebpImage 339 | ``` 340 | 341 | ### Preload and prefetch images 342 | 343 | Preload lets you tell the browser about critical resources that you want to load as soon as possible, before they are discovered in HTML, CSS or JavaScript files. This is especially useful for resources that are critical but not easily discoverable, such as banner images included in JavaScript or CSS file. 344 | 345 | Use [`@vue/preload-webpack-plugin`](https://github.com/vuejs/preload-webpack-plugin) to automatically inject resource hints tags `` or `` into the document ``. 346 | 347 | It's important to use `` **sparingly** and only preload the **most critical** resources. 348 | 349 | To do this, we can keep all images that need to be preloaded in the `./src/static/images/preload` folder, then modify `file-loader` to add prefix `"preload."` to the output name for the images in this folder, after that, we can set `fileWhitelist` option of `preload-webpack-plugin` to only inject `` for images with `"preload."` prefix in their names. 350 | 351 | and we can repeat the step above to inject `` for images that are less important but will very likely be needed later. 352 | 353 | [`webpack.config.js`](./webpack/prod.config.js) 354 | ```js 355 | const PreloadWebpackPlugin = require('@vue/preload-webpack-plugin'); 356 | 357 | module.exports = { 358 | //... 359 | module: { 360 | rules: [ 361 | { 362 | test: /\.(png|jpg|gif|svg|webp)$/, 363 | use : [ 364 | { 365 | loader: "file-loader", 366 | options: { 367 | name(resourcePath, resourceQuery){ 368 | 369 | // add "preload." prefix to images in preload folder 370 | if(resourcePath.includes('preload')){ 371 | return 'preload.[contenthash].[ext]'; 372 | } 373 | 374 | // add "prefetch." prefix to images in prefetch folder 375 | if (resourcePath.includes('prefetch')){ 376 | return 'prefetch.[contenthash].[ext]'; 377 | } 378 | 379 | return '[contenthash].[ext]'; 380 | }, 381 | } 382 | } 383 | ] 384 | }, 385 | ] 386 | }, 387 | plugins: [ 388 | new PreloadWebpackPlugin({ 389 | rel: 'preload', 390 | as(entry) { 391 | if (/\.(png|jpg|gif|svg|webp)$/.test(entry)) { 392 | return 'image'; 393 | } 394 | }, 395 | // only inject `` for images with `"preload."` prefix in their names 396 | fileWhitelist: [ 397 | /preload.*\.(png|jpg|gif|svg|webp)$/ 398 | ], 399 | include: 'allAssets' 400 | }), 401 | new PreloadWebpackPlugin({ 402 | rel: 'prefetch', 403 | as(entry) { 404 | if (/\.(png|jpg|gif|svg|webp)$/.test(entry)) { 405 | return 'image'; 406 | } 407 | }, 408 | // only inject `` for images with `"prefetch."` prefix in their names 409 | fileWhitelist: [ 410 | /prefetch.*\.(png|jpg|gif|svg|webp)$/ 411 | ], 412 | include: 'allAssets' 413 | }), 414 | ] 415 | } 416 | ``` 417 | 418 | ### Lazy loading images 419 | Lazy load offscreen images will improve the response time of the current page and then avoid loading unnecessary images that the user may not need. 420 | 421 | Fortunately we don't need to tune webpack to enable lazy load image, just use browser-level lazy-loading with the `loading` attribute, use `lazy` as the value to tell the browser to load the image immediately if it is in the viewport, and to fetch other images when the user scrolls near them. 422 | 423 | You can also use `Intersection Observer` or `event handlers` to polyfill lazy-loading of ``: [more details](https://web.dev/lazy-loading-images/#images-inline-intersection-observer) 424 | 425 | Here is an example of `` with `loading="lazy"`: 426 | ```html 427 | … 428 | 429 | 430 | 431 | 432 | ``` 433 | 434 | 441 | 442 | ## JavaScript 443 | ### Split chunks 444 | Code split vendors (dependencies) into a separate bundle to improve caching. Our application code changes more often than the vendor code because we adjust versions of your dependencies less frequently. Split vendor bundles allows the broswer to continue using cached vendor bundle as long as it's not change. 445 | 446 | Use out of the box `SplitChunksPlugin` to split chunks, and we tune the [`optimization.splitChunks`](https://webpack.js.org/plugins/split-chunks-plugin/#optimizationsplitchunks) configuration to split vendor bundles. 447 | 448 | [`webpack.config.js`](./webpack/prod.config.js) 449 | ```js 450 | module.exports = { 451 | //... 452 | optimization: { 453 | splitChunks: { 454 | cacheGroups: { 455 | // vendor chunk 456 | vendor: { 457 | // sync + async chunks 458 | chunks: 'all', 459 | name: 'vendor', 460 | // import file path containing node_modules 461 | test: /node_modules/ 462 | } 463 | } 464 | }, 465 | } 466 | } 467 | ``` 468 | 469 | ### Minified JS 470 | Like HTML and CSS files, removing all unnecessary spaces, comments and break will reduce the size of your JavaScript files and speed up your site's page load times. 471 | 472 | Use [`TerserWebpackPlugin`](https://webpack.js.org/plugins/terser-webpack-plugin/) to minify/minimize the output JavaScript files: 473 | 474 | [`webpack.config.js`](./webpack/prod.config.js) 475 | ```js 476 | const TerserPlugin = require('terser-webpack-plugin'); 477 | 478 | module.exports = { 479 | //... 480 | optimization: { 481 | minimize: true, 482 | minimizer: [ 483 | new TerserPlugin({ 484 | extractComments: true, 485 | terserOptions: { 486 | compress: { 487 | drop_console: true, 488 | } 489 | } 490 | }), 491 | ], 492 | }, 493 | }; 494 | ``` 495 | 496 | ### Lazy loading JS 497 | Split the non-critical codes into its own bundle and reduce the size of initial bundle can make the initial load faster. Then dynamically import these non-critical codes on demand. 498 | 499 | Use `react.lazy` to dynamic import a component. The components or modules that we know are likely to be used at some point in the application can be prefetched, according to [this ariticle](https://www.patterns.dev/posts/prefetch/): 500 | 501 | > Modules that are prefetched are requested and loaded by the browser even before the user requested the resource. When the browser is idle and calculates that it's got enough bandwidth, it will make a request in order to load the resource, and cache it. Having the resource cached can reduce the loading time significantly. 502 | 503 | We can let Webpack know that certain bundles need to be prefetched, by adding a magic comment to the import statement: `/* webpackPrefetch: true */`. 504 | 505 | here is an example of lazy loading a [React component](./src/components/LazyLoadComponent/EmojiPicker.tsx) 506 | ```js 507 | import React, { Suspense, lazy, useState, useEffect } from "react"; 508 | 509 | const EmojiPicker = lazy(()=>import( 510 | /* webpackPrefetch: true */ 511 | /* webpackChunkName: "emoji-picker" */ 512 | "./EmojiPicker" 513 | )) 514 | 515 | const ChatInput = () => { 516 | const [ showEmojiPicker, setShowEmojiPicker ] = useState(false) 517 | 518 | return ( 519 |
520 |
521 | 522 | 523 |
524 | 525 | Loading...}> 526 | { showEmojiPicker && } 527 | 528 |
529 | ) 530 | } 531 | 532 | export default ChatInput 533 | ``` 534 | 535 | Here is an example of lazy loading a [module](./src/utils/numbers.ts): 536 | ```js 537 | import React, { useState } from 'react' 538 | 539 | const RandomNumberCard = () => { 540 | 541 | const [ randomNum, setRandomNum ] = useState() 542 | 543 | const getRandomNum = async()=>{ 544 | 545 | // load numbers module dynamically 546 | const { generateRandomNumber } = await import( 547 | '../../utils/numbers' 548 | ) 549 | setRandomNum(generateRandomNumber(50, 100)) 550 | } 551 | 552 | return ( 553 |
554 | 555 | { randomNum !== undefined && { randomNum } } 556 |
557 | ) 558 | } 559 | 560 | export default RandomNumberCard 561 | ``` 562 | 563 | ### Use web worker 564 | JavaScript runs on the browser’s main thread, right alongside style calculations, layout, and, in many cases, paint. If your JavaScript runs for a long time, it will block these other tasks, potentially causing frames to be missed. Move pure computational work (code doesn’t require DOM access) to Web Workers and run it off the browser's main thread can thus improve the rendering performance significantly. 565 | 566 | Use worker loader to load web worker file, and communicate with the web worker by sending messages via the postMessage API: 567 | 568 | Here is an example of using web worker in a [React Component](./src/components/WebWorker/WebWorkerExample.tsx): 569 | 570 | ```js 571 | import React, { useEffect, useState } from 'react'; 572 | 573 | // load web worker 574 | import MyWorker from 'worker-loader!./worker'; 575 | 576 | const n = 1e6; 577 | 578 | const WebWorkerExample = () => { 579 | const [ count, setCount ] = useState() 580 | 581 | const getCountOfPrimeNumbers = async ()=> { 582 | 583 | // create a web worker 584 | const worker = new MyWorker(); 585 | 586 | // add message event listener to receive message returned by web worker 587 | worker.addEventListener( 588 | 'message', 589 | function (e) { 590 | setCount(e.data.message); 591 | }, 592 | false 593 | ); 594 | 595 | // post message and have the web woker start doing geavy tasks in a separate thread 596 | worker.postMessage(n); 597 | }; 598 | 599 | return ( 600 |
601 | { count === undefined 602 | ? 603 | :

{count} prime numbers found

604 | } 605 |
606 | 607 | ); 608 | }; 609 | 610 | export default WebWorkerExample; 611 | ``` 612 | 613 | 614 | here is the [worker.ts](./src/components/WebWorker/worker.ts): 615 | ```js 616 | const ctx: Worker = self as any; 617 | 618 | // receive message from the main thread 619 | ctx.onmessage = async (e) => { 620 | 621 | if(!e.data){ 622 | return 0 623 | } 624 | 625 | let count = 0; 626 | 627 | // codes that get count of primes that can take long time to run... 628 | 629 | // send message back to the main thred 630 | ctx.postMessage({ message: count }); 631 | }; 632 | ``` 633 | 634 | 637 | 638 | ## Fonts 639 | 664 | 665 | ### Cache font resources 666 | Font resources are, typically, static resources that don't see frequent updates. As a result, they are ideally suited for a long max-age expiry. 667 | 668 | In addition to the browser cache, using service worker to serve font resources with a cache-first strategy is appropriate for most use cases: 669 | 670 | [sw.js](./src/serviceWorker/sw.js) 671 | ```js 672 | //... 673 | self.addEventListener('fetch', (event)=>{ 674 | 675 | // Let the browser do its default thing 676 | // for non-GET requests. 677 | if (event.request.method != "GET") { 678 | return; 679 | }; 680 | 681 | // Check if this is a request for a font file 682 | if (event.request.destination === 'font') { 683 | 684 | // console.log('service worker fetching', event.request) 685 | event.respondWith(caches.open(cacheName).then((cache) => { 686 | // Go to the cache first 687 | return cache.match(event.request.url).then((cachedResponse) => { 688 | // Return a cached response if we have one 689 | if (cachedResponse) { 690 | return cachedResponse; 691 | } 692 | 693 | // Otherwise, hit the network 694 | return fetch(event.request).then((fetchedResponse) => { 695 | // Add the network response to the cache for later visits 696 | cache.put(event.request, fetchedResponse.clone()); 697 | 698 | // Return the network response 699 | return fetchedResponse; 700 | }); 701 | }); 702 | })); 703 | 704 | } else { 705 | return; 706 | } 707 | }) 708 | ``` 709 | 710 | ## Caching 711 | ### Use contenthash in output filenames 712 | 713 | We always want to have our static files be cahced by the browser with a long expiry time to improve the load speed. However, everytime when we make a change to our static files, we will want to make sure the browser can get the latest version of that file instead of retrieving the old one from the cache. 714 | 715 | This can be achieved by adding `[contenthash]` to the output filenames, `[contenthash]` is unique hash based on the content of an asset. When the asset's content changes, `[contenthash]` will change as well. 716 | 717 | [`webpack.config.js`](./webpack/prod.config.js) 718 | ```js 719 | module.exports = { 720 | //... 721 | output: { 722 | path: path.resolve(__dirname, '..', './dist'), 723 | filename: '[name].[contenthash].js', 724 | chunkFilename: '[name].[contenthash].js', 725 | clean: true 726 | } 727 | } 728 | ``` 729 | 730 | 738 | 739 | ## Others 740 | 741 | ### Compress text files 742 | Compress text files and reduce the size of these files can improve load speed, normally, this is handled by a server like Apache or Nginx on runtime, but you might want to pre-build compressed assets to save the runtime cost. 743 | 744 | Use [`CompressionWebpackPlugin`](https://webpack.js.org/plugins/compression-webpack-plugin/) to prepare compressed versions of assets. 745 | 746 | ```js 747 | const CompressionPlugin = require("compression-webpack-plugin"); 748 | 749 | module.exports = { 750 | //... 751 | plugins: [ 752 | new CompressionPlugin() 753 | ] 754 | }; 755 | ``` 756 | 759 | 760 | ## Resources 761 | - [Fast load times](https://web.dev/fast/) 762 | - [Front-End Performance Checklist](https://github.com/thedaviddias/Front-End-Performance-Checklist) 763 | - [Awesome Webpack Perf ](https://github.com/iamakulov/awesome-webpack-perf) 764 | - [Critical CSS and Webpack: Automatically Minimize Render-Blocking CSS](https://vuejsdevelopers.com/2017/07/24/critical-css-webpack/) 765 | - [Webpack - How to convert jpg/png to webp via image-webpack-loader](https://stackoverflow.com/questions/58827843/webpack-how-to-convert-jpg-png-to-webp-via-image-webpack-loader) 766 | - [Best practices for fonts](https://web.dev/font-best-practices/) 767 | - [Web performance](https://developer.mozilla.org/en-US/docs/Learn/Performance) 768 | - [An in-depth guide to performance optimization with webpack](https://blog.logrocket.com/guide-performance-optimization-webpack/) 769 | 770 | ## Contribute 771 | Please feel free to open an issue or a pull request to suggest changes, improvements or fixes. 772 | 773 | ## License 774 | [MIT](./LICENSE) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend-performance-optimization-tips", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "convert2webp": "node ./scripts/convert2webp.js", 9 | "prestart": "npm run convert2webp", 10 | "start": "webpack serve --mode development --open --config webpack/dev.config.js", 11 | "dev": "webpack --mode development --config webpack/dev.config.js", 12 | "prebuild": "npm run convert2webp", 13 | "build": "webpack --mode production --config webpack/prod.config.js", 14 | "lint": "eslint src --ext .tsx,.ts --cache --fix", 15 | "prepare": "husky install" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/vannizhang/frontend-performance-optimization-tips.git" 20 | }, 21 | "keywords": [], 22 | "author": "", 23 | "license": "ISC", 24 | "bugs": { 25 | "url": "https://github.com/vannizhang/frontend-performance-optimization-tips/issues" 26 | }, 27 | "homepage": "https://github.com/vannizhang/frontend-performance-optimization-tips#readme", 28 | "devDependencies": { 29 | "@babel/core": "^7.18.6", 30 | "@babel/plugin-proposal-class-properties": "^7.18.6", 31 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 32 | "@babel/plugin-transform-runtime": "^7.18.6", 33 | "@babel/preset-env": "^7.18.6", 34 | "@babel/preset-react": "^7.18.6", 35 | "@babel/preset-typescript": "^7.18.6", 36 | "@squoosh/lib": "^0.5.3", 37 | "@types/react": "^18.0.15", 38 | "@types/react-dom": "^18.0.6", 39 | "@typescript-eslint/eslint-plugin": "^5.33.1", 40 | "@typescript-eslint/parser": "^5.33.1", 41 | "@vue/preload-webpack-plugin": "^2.0.0", 42 | "babel-loader": "^8.2.5", 43 | "compression-webpack-plugin": "^10.0.0", 44 | "css-loader": "^6.7.1", 45 | "css-minimizer-webpack-plugin": "^4.0.0", 46 | "eslint": "^8.22.0", 47 | "eslint-config-prettier": "^8.5.0", 48 | "eslint-plugin-prettier": "^4.2.1", 49 | "eslint-plugin-react": "^7.30.1", 50 | "file-loader": "^6.2.0", 51 | "html-critical-webpack-plugin": "^2.1.0", 52 | "html-webpack-plugin": "^5.5.0", 53 | "html-webpack-preconnect-plugin": "^1.2.1", 54 | "husky": "^8.0.1", 55 | "image-minimizer-webpack-plugin": "^3.8.2", 56 | "imagemin": "^8.0.1", 57 | "imagemin-webp": "^7.0.0", 58 | "mini-css-extract-plugin": "^2.6.1", 59 | "postcss-loader": "^7.0.1", 60 | "prettier": "^2.7.1", 61 | "style-loader": "^3.3.1", 62 | "terser-webpack-plugin": "^5.3.3", 63 | "typescript": "^4.7.4", 64 | "url-loader": "^4.1.1", 65 | "webpack": "^5.73.0", 66 | "webpack-cli": "^4.10.0", 67 | "webpack-dev-server": "^4.9.3", 68 | "workbox-webpack-plugin": "^6.5.3", 69 | "worker-loader": "^3.0.8" 70 | }, 71 | "dependencies": { 72 | "react": "^18.2.0", 73 | "react-dom": "^18.2.0" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= htmlWebpackPlugin.options.title %> 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /scripts/convert2webp.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const imageFolder = path.join(__dirname, '..', 'src', 'static', 'images') 3 | const { promises } = require('node:fs') 4 | const { promisify } = require('node:util') 5 | const fs = require('graceful-fs'); 6 | 7 | const fsPromises = promises; 8 | const writeFile = promisify(fs.writeFile); 9 | 10 | const move2originalDir = async(files)=>{ 11 | 12 | for(const file of files){ 13 | const currDestinationPath = file.destinationPath.replace(/\\/g, '/'); 14 | 15 | const source = path.parse(file.sourcePath); 16 | const destination = path.parse(currDestinationPath); 17 | const newDestinationPath = `${source.dir}/${destination.name}${destination.ext}`; 18 | 19 | // console.log(currDestinationPath, newDestinationPath) 20 | 21 | if(currDestinationPath === newDestinationPath){ 22 | continue 23 | } 24 | 25 | await fsPromises.mkdir(path.dirname(newDestinationPath), { recursive: true }); 26 | 27 | // save a webp file in the original directory 28 | await writeFile(newDestinationPath, file.data); 29 | 30 | // remove the original webp file because it's no longer needed 31 | await fsPromises.unlink(currDestinationPath) 32 | } 33 | } 34 | 35 | const run = async () => { 36 | const imagemin = (await import("imagemin")).default; 37 | const webp = (await import("imagemin-webp")).default; 38 | 39 | const processedPNGs = await imagemin([`${imageFolder}/**/*.png`], { 40 | destination: imageFolder, 41 | preserveDirectories: true, 42 | plugins: [ 43 | webp({ 44 | lossless: true, 45 | }), 46 | ], 47 | }); 48 | 49 | await move2originalDir(processedPNGs) 50 | console.log("PNGs processed"); 51 | 52 | const processedJPGs = await imagemin([`${imageFolder}/**/*.{jpg,jpeg}`], { 53 | destination: imageFolder, 54 | preserveDirectories: true, 55 | plugins: [ 56 | webp({ 57 | quality: 65, 58 | }), 59 | ], 60 | }); 61 | 62 | await move2originalDir(processedJPGs) 63 | console.log("JPGs and JPEGs processed"); 64 | } 65 | 66 | run(); -------------------------------------------------------------------------------- /src/components/ImageWithResourceHint/ImageViewer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | import mountainImg from '../../static/images/preload/mountain.jpg' 4 | import seaImage from '../../static/images/prefetch/sea.jpg' 5 | 6 | const TopBanner = () => { 7 | const [shouldDisplaySecondImg, setShouldDisplaySecondImg] = useState(false) 8 | 9 | return ( 10 | <> 11 | 12 | 13 | {shouldDisplaySecondImg && } 14 | 15 |
16 | show prefetched image 17 |
18 | 19 | ) 20 | } 21 | 22 | export default TopBanner 23 | -------------------------------------------------------------------------------- /src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import LazyLoadComponent from './LazyLoadComponent/EmojiPickerContainer' 3 | import LazyLoadModule from './LazyLoadModule/RandomNumberCard' 4 | import ImageWithResourceHint from './ImageWithResourceHint/ImageViewer' 5 | import WebWorkerExample from './WebWorker/WebWorkerExample' 6 | import WebpImage from './WebpImage/WebpImage' 7 | import LazyLoadImage from './LazyLoadImage/LazyLoadImage' 8 | 9 | const Layout = () => { 10 | return ( 11 | <> 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ) 20 | } 21 | 22 | export default Layout 23 | -------------------------------------------------------------------------------- /src/components/LazyLoadComponent/EmojiPicker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | type EmojiData = { 4 | name: string 5 | decimal: number 6 | } 7 | 8 | const emojis: EmojiData[] = [ 9 | { 10 | name: 'soccer-ball', 11 | decimal: 9917 12 | }, 13 | { 14 | name: 'earth', 15 | decimal: 127758 16 | }, 17 | { 18 | name: 'beer', 19 | decimal: 127866 20 | }, 21 | { 22 | name: 'monkey', 23 | decimal: 128053 24 | }, 25 | { 26 | name: 'laugh', 27 | decimal: 128514 28 | } 29 | ] 30 | 31 | const EmojiPicker = () => { 32 | return ( 33 |
34 | {emojis.map(({ name, decimal }) => ( 35 | {String.fromCodePoint(decimal)} 36 | ))} 37 |
38 | ) 39 | } 40 | 41 | export default EmojiPicker 42 | -------------------------------------------------------------------------------- /src/components/LazyLoadComponent/EmojiPickerContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense, lazy, useState, useEffect } from 'react' 2 | 3 | const EmojiPicker = lazy( 4 | () => 5 | import( 6 | /* webpackPrefetch: true */ 7 | /* webpackChunkName: "emoji-picker" */ 8 | './EmojiPicker' 9 | ) 10 | ) 11 | 12 | const ChatInput = () => { 13 | const [showEmojiPicker, setShowEmojiPicker] = useState(false) 14 | 15 | return ( 16 |
17 |
18 | 19 | 22 |
23 | 24 | Loading...}> 25 | {showEmojiPicker && } 26 | 27 |
28 | ) 29 | } 30 | 31 | export default ChatInput 32 | -------------------------------------------------------------------------------- /src/components/LazyLoadImage/LazyLoadImage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const LazyLoadImage = () => { 4 | const getImages = () => { 5 | const images: JSX.Element[] = [] 6 | 7 | for (let i = 0; i < 10; i++) { 8 | images.push( 9 |
10 | 17 |
18 | ) 19 | } 20 | 21 | return images 22 | } 23 | return
{getImages()}
24 | } 25 | 26 | export default LazyLoadImage 27 | -------------------------------------------------------------------------------- /src/components/LazyLoadModule/RandomNumberCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | const RandomNumberCard = () => { 4 | const [randomNum, setRandomNum] = useState() 5 | 6 | const getRandomNum = async () => { 7 | const { generateRandomNumber } = await import( 8 | /* webpackPrefetch: true */ 9 | '../../utils/numbers' 10 | ) 11 | setRandomNum(generateRandomNumber(50, 100)) 12 | } 13 | 14 | return ( 15 |
16 |
get a random number
17 | {randomNum !== undefined && {randomNum} } 18 |
19 | ) 20 | } 21 | 22 | export default RandomNumberCard 23 | -------------------------------------------------------------------------------- /src/components/WebWorker/README.md: -------------------------------------------------------------------------------- 1 | ## How to use Web Worker with TypeScript and Webpack? 2 | 3 | This [issue](https://github.com/webpack-contrib/worker-loader/issues/189) provides detailed explaination: 4 | 5 | 1. install `worker-loader` 6 | ``` 7 | npm install worker-loader --save-dev 8 | ``` 9 | 10 | 2. added below to `custom.d.ts`: 11 | ```js 12 | declare module "worker-loader!*" { 13 | class WebpackWorker extends Worker { 14 | constructor(); 15 | } 16 | 17 | export default WebpackWorker; 18 | } 19 | ``` 20 | 21 | 3. create `worker.js`: 22 | ```js 23 | const ctx: Worker = self as any; 24 | 25 | ctx.onmessage = async(e) => { 26 | console.log(e) 27 | 28 | console.log('Worker is about to start some work'); 29 | 30 | let count: number = 0; 31 | 32 | for (let i: number = 0; i <= e.data; i++) { 33 | if(i % 791 === 0){ 34 | count++ 35 | } 36 | } 37 | 38 | ctx.postMessage({ message: count }); 39 | } 40 | 41 | // ctx.addEventListener('message', ) 42 | ``` 43 | 44 | 4. import and use it: 45 | ```js 46 | import MyWorker from 'worker-loader!./worker' 47 | 48 | const worker = new MyWorker() 49 | 50 | worker.addEventListener('message', function (e) { 51 | 52 | console.log(e.data); 53 | }, false); 54 | 55 | worker.postMessage(1e7); 56 | ``` 57 | 58 | -------------------------------------------------------------------------------- /src/components/WebWorker/WebWorkerExample.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | 3 | import MyWorker from 'worker-loader!./worker' 4 | 5 | const worker = new MyWorker() 6 | 7 | const n = 1e6 8 | 9 | const WebWorkerExample = () => { 10 | const [count, setCount] = useState() 11 | 12 | const getCountOfPrimeNumbers = async () => { 13 | worker.addEventListener( 14 | 'message', 15 | function (e) { 16 | setCount(e.data.message) 17 | }, 18 | false 19 | ) 20 | 21 | worker.postMessage(n) 22 | } 23 | 24 | return ( 25 |
26 | {count === undefined ? ( 27 | 30 | ) : ( 31 |

{count} prime numbers found

32 | )} 33 |
34 | ) 35 | } 36 | 37 | export default WebWorkerExample 38 | -------------------------------------------------------------------------------- /src/components/WebWorker/worker.ts: -------------------------------------------------------------------------------- 1 | const ctx: Worker = self as any 2 | 3 | const countPrimes = function (n: number) { 4 | if (n <= 1) { 5 | return 0 6 | } 7 | 8 | const dp = new Array(n).fill(true) 9 | 10 | for (let num = 2; num < Math.sqrt(n); num++) { 11 | if (dp[num] === false) { 12 | continue 13 | } 14 | 15 | for (let i = num * num; i < n; i += num) { 16 | dp[i] = false 17 | } 18 | } 19 | 20 | let count = 0 21 | 22 | for (let i = 2; i < n; i++) { 23 | if (dp[i] === true) { 24 | count++ 25 | } 26 | } 27 | 28 | return count 29 | } 30 | 31 | ctx.onmessage = async (e) => { 32 | if (!e.data) { 33 | return 0 34 | } 35 | 36 | const count = countPrimes(e.data) 37 | ctx.postMessage({ message: count }) 38 | } 39 | 40 | // ctx.addEventListener('message', ) 41 | -------------------------------------------------------------------------------- /src/components/WebpImage/WebpImage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import nightSkyWebP from '../../static/images/night-sky.webp' 3 | import nightSkyJPG from '../../static/images/night-sky.jpg' 4 | 5 | const WebpImage = () => { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | ) 13 | } 14 | 15 | export default WebpImage 16 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import './styles/index.css' 2 | 3 | import React from 'react' 4 | import { createRoot } from 'react-dom/client' 5 | import Layout from './components/Layout' 6 | import { registerServiceWorker } from './serviceWorker/register' 7 | ;(async () => { 8 | await registerServiceWorker() 9 | 10 | const root = createRoot(document.getElementById('root')) 11 | 12 | root.render() 13 | })() 14 | -------------------------------------------------------------------------------- /src/serviceWorker/register.ts: -------------------------------------------------------------------------------- 1 | export const registerServiceWorker = 2 | async (): Promise => { 3 | if ('serviceWorker' in navigator) { 4 | try { 5 | const register = await navigator.serviceWorker.register( 6 | './sw.js' 7 | ) 8 | console.log('Service worker successfully registered.') 9 | return register 10 | } catch (err) { 11 | console.error('Unable to register service worker.', err) 12 | return null 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/serviceWorker/sw.js: -------------------------------------------------------------------------------- 1 | const cacheName = 'v1'; 2 | 3 | self.addEventListener('install', (evt)=>{ 4 | console.log(`service worker installed`); 5 | }) 6 | 7 | self.addEventListener('activate', (evt)=>{ 8 | console.log(`service worker activated`) 9 | // remove old caches 10 | 11 | evt.waitUntil( 12 | caches.keys().then(keys => { 13 | return Promise.all( 14 | keys.map(key=>{ 15 | if(key !== cacheName){ 16 | return caches.delete(keys) 17 | } 18 | }) 19 | ) 20 | }) 21 | ) 22 | }) 23 | 24 | self.addEventListener('fetch', (event)=>{ 25 | 26 | // Let the browser do its default thing 27 | // for non-GET requests. 28 | if (event.request.method != "GET") { 29 | return; 30 | }; 31 | 32 | // Check if this is a request for a font file 33 | if (event.request.destination === 'font') { 34 | 35 | // console.log('service worker fetching', event.request) 36 | event.respondWith(caches.open(cacheName).then((cache) => { 37 | // Go to the cache first 38 | return cache.match(event.request.url).then((cachedResponse) => { 39 | // Return a cached response if we have one 40 | if (cachedResponse) { 41 | return cachedResponse; 42 | } 43 | 44 | // Otherwise, hit the network 45 | return fetch(event.request).then((fetchedResponse) => { 46 | // Add the network response to the cache for later visits 47 | cache.put(event.request, fetchedResponse.clone()); 48 | 49 | // Return the network response 50 | return fetchedResponse; 51 | }); 52 | }); 53 | })); 54 | 55 | } else { 56 | return; 57 | } 58 | }) -------------------------------------------------------------------------------- /src/static/images/night-sky.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vannizhang/web-performance-optimization-with-webpack/d4a1422b84a4cef005245016c0b1e65d706bb25a/src/static/images/night-sky.jpg -------------------------------------------------------------------------------- /src/static/images/night-sky.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vannizhang/web-performance-optimization-with-webpack/d4a1422b84a4cef005245016c0b1e65d706bb25a/src/static/images/night-sky.webp -------------------------------------------------------------------------------- /src/static/images/prefetch/sea.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vannizhang/web-performance-optimization-with-webpack/d4a1422b84a4cef005245016c0b1e65d706bb25a/src/static/images/prefetch/sea.jpg -------------------------------------------------------------------------------- /src/static/images/prefetch/sea.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vannizhang/web-performance-optimization-with-webpack/d4a1422b84a4cef005245016c0b1e65d706bb25a/src/static/images/prefetch/sea.webp -------------------------------------------------------------------------------- /src/static/images/preload/mountain.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vannizhang/web-performance-optimization-with-webpack/d4a1422b84a4cef005245016c0b1e65d706bb25a/src/static/images/preload/mountain.jpg -------------------------------------------------------------------------------- /src/static/images/preload/mountain.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vannizhang/web-performance-optimization-with-webpack/d4a1422b84a4cef005245016c0b1e65d706bb25a/src/static/images/preload/mountain.webp -------------------------------------------------------------------------------- /src/styles/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Open+Sans&display=swap'); 2 | 3 | body { 4 | font-family: 'Open Sans', sans-serif; 5 | margin: 0; 6 | padding: 0; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/images.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.jpg' 2 | declare module '*.png' 3 | declare module '*.svg' 4 | declare module '*.webp' 5 | -------------------------------------------------------------------------------- /src/types/webworker.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'worker-loader!*' { 2 | class WebpackWorker extends Worker { 3 | constructor() 4 | } 5 | 6 | export default WebpackWorker 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/numbers.ts: -------------------------------------------------------------------------------- 1 | // export const add = (a:number, b:number)=>a+b; 2 | 3 | export const generateRandomNumber = (min = 0, max = 1) => { 4 | const range = max - min 5 | return Math.floor(Math.random() * range) + min 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "allowJs": false, 5 | "jsx": "react", 6 | "module": "ESNext", 7 | "moduleResolution": "node", 8 | "noImplicitAny": true, 9 | "preserveConstEnums": true, 10 | "removeComments": true, 11 | "sourceMap": true, 12 | "target": "es5", 13 | "esModuleInterop": true , 14 | "suppressImplicitAnyIndexErrors": true, 15 | "typeRoots": [ 16 | "./node_modules/@types" 17 | ], 18 | "lib": [ "es2015", "dom" ], 19 | }, 20 | "exclude": [ 21 | "**/node_modules", 22 | "**/.*/" 23 | ], 24 | "include": [ 25 | "./src/**/**/*" 26 | ] 27 | } -------------------------------------------------------------------------------- /webpack/dev.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | 4 | module.exports = { 5 | mode: 'development', 6 | entry: path.resolve(__dirname, '..', './src/index.tsx'), 7 | output: { 8 | path: path.resolve(__dirname, '..', './dist'), 9 | filename: '[name].js', 10 | chunkFilename: '[name].js', 11 | }, 12 | devtool: 'inline-source-map', 13 | resolve: { 14 | extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'] 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.(ts|tsx)$/, 20 | loader: 'babel-loader', 21 | }, 22 | { 23 | test: /\.css$/i, 24 | include: path.resolve(__dirname, '..', 'src'), 25 | use: [ 26 | 'style-loader', 27 | { 28 | loader: "css-loader", options: { 29 | sourceMap: true 30 | } 31 | }, 32 | { 33 | loader: 'postcss-loader' 34 | } 35 | ], 36 | }, 37 | { 38 | test: /\.(woff|woff2|ttf|eot)$/, 39 | loader: "file-loader" 40 | }, 41 | { 42 | test: /\.(png|jpg|gif|svg|webp)$/, 43 | loader: "file-loader" 44 | }, 45 | ] 46 | }, 47 | plugins: [ 48 | new HtmlWebpackPlugin({ 49 | template: path.resolve(__dirname, '..', './public/index.html'), 50 | filename: 'index.html' 51 | }) 52 | ] 53 | }; -------------------------------------------------------------------------------- /webpack/prod.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 4 | const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); 5 | const HtmlCriticalPlugin = require("html-critical-webpack-plugin"); 6 | const TerserPlugin = require('terser-webpack-plugin'); 7 | const CompressionPlugin = require("compression-webpack-plugin"); 8 | const PreloadWebpackPlugin = require('@vue/preload-webpack-plugin'); 9 | const HtmlWebpackPreconnectPlugin = require('html-webpack-preconnect-plugin'); 10 | const ImageMinimizerPlugin = require("image-minimizer-webpack-plugin"); 11 | 12 | module.exports = { 13 | mode: 'production', 14 | entry: { 15 | main: path.resolve(__dirname, '..', './src/index.tsx'), 16 | sw: path.resolve(__dirname, '..', './src/serviceWorker/sw.js'), 17 | }, 18 | output: { 19 | path: path.resolve(__dirname, '..', './dist'), 20 | filename: ({runtime}) => { 21 | // Check if the current filename is for the service worker: 22 | if (runtime === 'sw') { 23 | // Output a service worker in the root of the dist directory 24 | // Also, ensure the output file name doesn't have a hash in it 25 | return '[name].js'; 26 | } 27 | 28 | // Otherwise, output files as normal 29 | return '[name].[contenthash].js'; 30 | }, 31 | chunkFilename: '[name].[contenthash].js', 32 | clean: true 33 | }, 34 | devtool: 'source-map', 35 | resolve: { 36 | extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'] 37 | }, 38 | module: { 39 | rules: [ 40 | { 41 | test: /\.(ts|tsx)$/, 42 | loader: 'babel-loader', 43 | }, 44 | { 45 | test: /\.css$/i, 46 | include: path.resolve(__dirname, '..', 'src'), 47 | use: [ 48 | MiniCssExtractPlugin.loader, 49 | { 50 | loader: "css-loader", options: { 51 | sourceMap: true 52 | } 53 | }, 54 | { 55 | loader: 'postcss-loader' 56 | } 57 | ], 58 | }, 59 | { 60 | test: /\.(woff|woff2|ttf|eot)$/, 61 | loader: "file-loader", 62 | options: { 63 | name: '[name].[contenthash].[ext]', 64 | } 65 | }, 66 | { 67 | test: /\.(png|jpg|gif|svg|webp)$/, 68 | use : [ 69 | { 70 | loader: "file-loader", 71 | options: { 72 | name(resourcePath, resourceQuery){ 73 | 74 | if(resourcePath.includes('preload')){ 75 | return 'preload.[name].[contenthash].[ext]'; 76 | } 77 | 78 | if (resourcePath.includes('prefetch')){ 79 | return 'prefetch.[name].[contenthash].[ext]'; 80 | } 81 | 82 | return '[name].[contenthash].[ext]'; 83 | }, 84 | } 85 | } 86 | ] 87 | }, 88 | ] 89 | }, 90 | plugins: [ 91 | new MiniCssExtractPlugin({ 92 | filename: '[name].[contenthash].css', 93 | chunkFilename: '[name].[contenthash].css' 94 | }), 95 | new HtmlWebpackPlugin({ 96 | // inject: false, 97 | // hash: true, 98 | template: path.resolve(__dirname, '..', './public/index.html'), 99 | filename: 'index.html', 100 | chunks: ['main'], 101 | minify: { 102 | html5 : true, 103 | collapseWhitespace : true, 104 | minifyCSS : true, 105 | minifyJS : true, 106 | minifyURLs : false, 107 | removeComments : true, 108 | removeEmptyAttributes : true, 109 | removeOptionalTags : true, 110 | removeRedundantAttributes : true, 111 | removeScriptTypeAttributes : true, 112 | removeStyleLinkTypeAttributese : true, 113 | useShortDoctype : true 114 | }, 115 | preconnect: [ 116 | // 'https://fonts.googleapis.com', 117 | ] 118 | }), 119 | new HtmlCriticalPlugin({ 120 | base: path.join(path.resolve(__dirname), '..', 'dist/'), 121 | src: 'index.html', 122 | dest: 'index.html', 123 | inline: true, 124 | minify: true, 125 | extract: true, 126 | width: 1400, 127 | height: 900, 128 | penthouse: { 129 | blockJSRequests: false, 130 | } 131 | }), 132 | new PreloadWebpackPlugin({ 133 | rel: 'preload', 134 | as(entry) { 135 | if (/\.(png|jpg|gif|svg|webp)$/.test(entry)) { 136 | return 'image'; 137 | } 138 | }, 139 | fileWhitelist: [ 140 | /preload.*\.(png|jpg|gif|svg|webp)$/ 141 | ], 142 | include: 'allAssets' 143 | }), 144 | new PreloadWebpackPlugin({ 145 | rel: 'prefetch', 146 | as(entry) { 147 | if (/\.(png|jpg|gif|svg|webp)$/.test(entry)) { 148 | return 'image'; 149 | } 150 | }, 151 | fileWhitelist: [ 152 | /prefetch.*\.(png|jpg|gif|svg|webp)$/ 153 | ], 154 | include: 'allAssets' 155 | }), 156 | new HtmlWebpackPreconnectPlugin(), 157 | new CompressionPlugin() 158 | ], 159 | optimization: { 160 | splitChunks: { 161 | cacheGroups: { 162 | // vendor chunk 163 | vendor: { 164 | // sync + async chunks 165 | chunks: 'all', 166 | name: 'vendor', 167 | // import file path containing node_modules 168 | test: /node_modules/ 169 | } 170 | } 171 | }, 172 | // Tell webpack to minimize the bundle using the TerserPlugin 173 | minimize: true, 174 | minimizer: [ 175 | new CssMinimizerPlugin(), 176 | new TerserPlugin({ 177 | extractComments: true, 178 | terserOptions: { 179 | compress: { 180 | drop_console: true, 181 | } 182 | } 183 | }), 184 | new ImageMinimizerPlugin({ 185 | minimizer: { 186 | implementation: ImageMinimizerPlugin.squooshMinify, 187 | options: { 188 | // encodeOptions: { 189 | // mozjpeg: { 190 | // // That setting might be close to lossless, but it’s not guaranteed 191 | // // https://github.com/GoogleChromeLabs/squoosh/issues/85 192 | // quality: 100, 193 | // }, 194 | // } 195 | } 196 | } 197 | }) 198 | ], 199 | } 200 | } --------------------------------------------------------------------------------