Optimize the bundle size of an Angular application

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
SPEAKER: One of the fundamental goals of web development is to create applications that people enjoy using. Delivering an enjoyable experience hinges on web performance, which becomes increasingly difficult to control as an app grows in complexity. End-users expect content to flow quickly and respond to user interaction instantly. But that's easier said than done when an app has dozens of features and multiple megabytes of dependencies in its production JavaScript bundle. When it comes to performance, every millisecond counts. A recent study conducted by Deloitte observed nontrivial gains in user interaction and revenue based on a seemingly small load-time improvement of just 1/10 of a second, or 100 milliseconds. Luckily, Angular developers have multiple tools at their disposal for analyzing and avoiding performance bottlenecks. The goal of this video is to analyze and optimize a production JavaScript bundle in Angular. we'll use tools like source map explorer to understand what's inside the bundle, then look at a variety of common pitfalls that may be making it too large. We'll look at architectural patterns like lazy loading or code splitting, and once the app is optimized, we'll use budgets to prevent performance regressions in the future. In order to address performance, you first need to understand how your app performs in a real environment. If you have an existing application deployed on the web, one of the easiest ways to analyze it is with the Lighthouse tool in Chrome. Visit your site, then find the Lighthouse tool in Chrome DevTools and generate a report. The tool will emulate mobile devices and slow connections, then generate a report that contains a variety of metrics, like how long it takes for content to first appear on the page, and how long it takes your JavaScript code to become interactive. It'll also detect opportunities for improvement, where you can determine which assets are creating performance bottlenecks. And if you want to dive deeper, you can view the original performance trace in Chrome, which breaks down how the browser parses your application at a highly-granular level. Lighthouse is great for detecting issues that we may want to optimize. But if we're facing an issue with the size of our JavaScript bundle, we need to dive deeper into our own code to fully understand how to address it. Let's jump in to our editor and take a look at our code base. What we have here is a fairly simple Angular application. The Angular router is used to manage two different pages-- the home page and the feature page, which contains the majority of the app's complexity. The main implementation details are not relevant, but over the next few minutes, we'll look at issues related to dependencies and code motion that can affect the size of the JavaScript bundle in ways that are not totally obvious. In addition, if we open the package.json you'll notice several popular libraries like angular material, firebase, lodash, and moment.js. Now at this point the app works perfectly in all our test pass, but we're getting complaints from customers that the app is slow, especially when used on a poor connection. After running a Lighthouse audit we get a low performance score due to render blocking assets like our JavaScript bundle and CSS bundle. But our app is relatively simple, so the question becomes, why are these files so large? A tool that can help us answer that question is Source Map Explorer. It allows us to take the source maps generated by Angular and analyze them visually. The tree map visual allows us to drill down into our own code to see which modules might be causing bloat and it also allows us to compare dependencies to determine if we have any packages that are unexpectedly large. To put this tool to use, we'll first need to install it with npm. And we can use the save-dev flag to put it in the development environment. From there we need to run a production build of our code, which we'd normally do with the ng build command. However, if we go into the dist folder, you'll notice by default, it doesn't generate source maps on the production build. We can easily address that by adding the source map flag to the ng build command. Then you'll notice in the dist folder, every JavaScript file has a corresponding source map file as well. A source map file is basically just a way to take your minified production code and map it back to its original uncombined state used in development. They make it much easier to debug runtime issues in your code and as we'll see, analyze performance based on the JavaScript bundle size. It's also worth noting at this point that you can generate source maps in the Angular json configuration. The build configuration contains an option for source map, which is currently set to false. However, we can set that to true or we can pass an object to control which source maps are generated. In this demo we'll generate maps for all of our own scripts in addition to CSS stylesheets and vendor packages, which are the dependencies we install via npm. And now with this configuration set, we can run the regular ng build command and it will generate the source maps. Now in order to run the Source Map Explorer, we'll go back to our packaged json and add a script that runs the Source Map Explorer command. We need to point the command to the files we want to analyze which live in the dist directory, and then we'll use a glob pattern to capture all of the files in that directory. Now from the command line, we can run npm run explore and that should bring up the tree map visualization in the browser. At first glance, this visual likely looks very overwhelming, but the thing to look for here is the size of the items relative to each other. The bigger the item, the more space it takes up in the JavaScript bundle and you'll also notice that each item has a percentage next to it telling you how much space that module takes up relative to the entire file or application. In this demo, you'll notice our main source code all the way down here at the bottom, taking up only 1.7% of the total bundle size. Meanwhile we have firebase taking up more than half the bundle size along with additional big libraries like lodash and moment. When you click on an item, it allows you to drill deeper into its makeup. That's especially useful for your own source code to see which components, directives, and services are contributing most to the bundle size. Now that we know how to use Source Map Explorer, I want to point out another common tool that you may come across called Webpack Bundle Analyzer. It's a great tool and very similar to Source Map Explorer, but because the Angular CLI extends Webpack, it's generally not the best option for analyzing an Angular application. If you happen to be using that tool, just keep in mind that you may not be getting perfectly accurate results when compared to Source Map Explorer. In any case, it's obvious that our current application has a major issue with the way it consumes dependencies. Let's take a look at a few common pitfalls that may be causing an unnecessarily large JavaScript bundle here. One of the most important concepts to understand is tree shaking, or a dead code elimination, which is the process of eliminating unnecessary modules from your JavaScript bundle. Notice how in this component we're importing two popular JavaScript libraries-- moment and lodash. These are both utility libraries where in most cases the developer only needs one or two functions or classes with everything else being unused. But when we import it like this we're basically telling the Angular CLI and Webpack to include the entire library in our JavaScript bundle. First, let's take a look at moment. This happens to be a library that is not very tree-shakeable, meaning if we want to use it then we have to bring along its 250 kilobytes with us. With that being said, the first thing you should ask yourself is, do I really need this dependency? If you're only using a small handful of functions from moment it may be beneficial to simply reimplement that logic natively in JavaScript. Eliminating a dependency entirely is great, but in some cases the extra complexity is just not worth it. Another option is to look for alternative packages that prioritize performance. In the case of moment, a good alternative is date-fns, which uses functional programming principles to make the code more tree-shakeable. Now let's shift our attention over to lodash. It happens to be a tree-tree-shakeable library, we're just not using it properly. Instead of the default lodash package we can install lodash-es, which exports each function as an ECMAScript module. Now instead of the entire package, we can import just the functions we need and eliminate all the unnecessary code from the bundle. Another major dependency in this project is firebase, and currently we're importing the entire firebase SDK that includes a bunch of services that we're not actually using in the app. In this particular dependency, exports different packages from namespaces. The only package that's actually required is firebase app, then we can selectively import anything else we need beyond that like firebase/auth, for example. All of the other packages will then automatically be left out of the production bundle. Now one other thing you might run into as an Angular developer is an accidental import from rxjs. rx is one of the fundamental dependencies of Angular, but sometimes when you import an operator or class from rxjs, your IDE might accidentally structure that import from one of its internal modules. It's an easy mistake to make, and might add a few additional kilobytes to your bundle. So just be aware of that possibility and audit your imports from rxjs. It's also important to understand that unnecessary imports don't just affect JavaScript, but also CSS. One thing to watch out for is the import of CSS styles into individual components. For example, we might have a global button style but have one particular component with a custom button. In order to customize the button in that component, we might re-import our button styles there, but because each component has style encapsulation we'd end up with two copies of the button CSS code in our bundle. When importing styles into a component, it's important to consider whether or not you want those styles encapsulated or not. In general, styles that can be used by multiple components should be taken to a more global level rather than be encapsulated and duplicated across multiple components. Now that our dependencies are under control, we can turn our attention to our own code and implement a technique known as lazy loading or code splitting. As your app grows in complexity, you likely have multiple features and a user won't be expected to use all these features at any given moment. Therefore, it's often a good idea to leave out unnecessary features from the main app bundle, then load them asynchronously in the background or when they become necessary after the user clicks on a link to that feature. We can implement lazy loading by first making our code modular. Currently, our app is organized into a single app module but earlier in the video, I mentioned that our feature module contains most of the complexity. We can organize this complexity into a module by generating a new module with the CLI. Once generated, we can go into our app module and remove any components, directives, or services that are not directly needed by the app module and move them over to the feature module. That isolates our code into an ng module, and now we can shift our attention to the Angular routing configuration. Currently, you'll notice we're routing to a path a feature that loads the feature component, which is a setup that requires the feature component to be included in the main app bundle. However, now that that component lives in its own module, we can use the load children property to perform a dynamic import that loads it asynchronously. A dynamic import is a relatively new ECMAScript standard that makes it possible to import a module at runtime, where it's resolved as a promise. And in this case, the promise resolves with the feature module that we want to load. That will defer any additional routing to the feature module So. If we go into the feature module itself, we can add a route for the feature component there, and then include the router module for a child in the imports array. The end result here is that when the user lands on the home component, the browser doesn't need to load the JavaScript that goes along with the main app feature. Instead, it can be loaded in the background or after the user clicks on a link to that feature. At this point, we can run the ng build command again, and you'll notice a new JavaScript file in the dist directory. This new file or chunk contains the code for the lazy loaded module. We can see how this affects performance by opening the app in the browser with the Network tab open in Chrome DevTools. When we navigate to the home page, the main js file is loaded, but the JavaScript required for the feature module is delayed until the user actually clicks the link to that feature. You can perform lazy loading for multiple modules and you can even lazy load individual components. In the bottom line, though, is that it's a highly effective technique for managing the size of your application as it grows in complexity. Now that we have our JavaScript bundle down to an acceptable size, there's a tool in Angular that we can use to prevent performance regressions in the future. When you run the ng build command, you may notice output in the console warning you that your bundle size has exceeded a certain limit. The limit is based on a budget that you can configure in the Angular json file. You can set up multiple budgets where each budget has an object that has a type, then a threshold for producing a warning in the console, and another error threshold at which point the build will fail if it exceeds that amount. That's especially useful for a continuous integration where your build will fail on the CI server rather than be pushed out to production with a massive bundle size. Now let's go ahead and recap what we've covered in this video. Tools like Lighthouse and Source Map Explorer can help you find and understand performance bottlenecks. If you run into issues related to dependencies, then verify that your imports are structured properly, and attempt to use tree-shakeable libraries. But if the bundle size is related to your own code, look into lazy loading or code splitting to remove your own JavaScript from the browser's critical path. And once the app has been optimized, use CLI budgets to prevent regressions in the future. For additional examples and details, refer to the official Angular documentation.
Info
Channel: Angular
Views: 18,713
Rating: undefined out of 5
Keywords: Angular, Web Performance, TypeScript, JavaScript
Id: 19T3O7XWJkA
Channel Id: undefined
Length: 13min 22sec (802 seconds)
Published: Tue Jun 08 2021
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.