Angular 2 Best Practices: Change Detector Performance

Ben Dilts

Reading time: about 11 min

Topics:

  • Architecture
  • Web Development

Introduction: Angular 2 and Lucidchart

Lucidchart launched in 2010 as one of the very first and most impressive graphical applications on the web. Over the course of several years, it has grown organically to include an incredible array of features. However, for many users it has become unwieldy over time, and its complexity can sometimes get in the way of its core value: drawing diagrams more quickly and easily.

Over the last six months, our UX and engineering departments have been hard at work wireframing, prototyping, testing, and finally building a completely redesigned editor for Lucidchart. After some consideration and experimentation, we decided to build our new editor with Angular 2, for several reasons:

  1. We wanted to code our views declaratively in something close to raw HTML and CSS, rather than building them each independently with Javascript code.
  2. We wanted to be able to thoroughly test our view code as part of our continuous integration system.
  3. We wanted to keep our styling consistent within and among our applications by tying CSS very closely to DOM specification for reusable components.
  4. We wanted a framework that would play well with the Google Closure Compiler's advanced optimizations, which we use extensively.
  5. We didn't want to deal with Angular 1's performance bottlenecks or strange magical syntax.

This is the first in an ongoing series of blog posts on practical, real-world lessons we've learned from building a very large, complex application using Angular 2. In each entry, code samples will be in Javascript rather than Typescript, as Lucidchart is written in Javascript.

Change detection in Angular 2

An Angular 2 application consists of a root component, which is comprised of other components and directives. Each of these components and directives can affect the DOM presented to the user based on information they gather from different types of sources:

  • Properties, passed to child components and directives via view templates, like this:
    <lucid-spinner
        [(value)] = "lineWidth"
        (valuePreview) = "lineWidthPreview = $event"
        [lucid-disabled] = "!lineWidthEditable"
        [visualStep] = "1"
        [scalar] = "1"
        [units] = "'px'"
        [min] = "0"
        [max] = "10"
        [precision] = "1"
    ></lucid-spinner>
  • Classes or data injected directly into the constructor of the component or directive (formerly "Services" in Angular 1)
  • Out-of-band data, like the current date and time

In order to guarantee that the DOM always shows the very latest available data, Angular monkey-patches every entry point to running Javascript with a Zone. So any time Javascript finishes executing—meaning an AJAX request completes, a click event handler runs, or a Promise is fulfilled—Angular 2 checks whether any changes have occurred that would affect the DOM.

When the change detector runs on an Angular 2 component or directive, it evaluates every Javascript expression in that component's view template (or the "host" entry on the @Component or @Directive decorator settings). It then compares the output of each expression to its previous output. If the result of an expression has changed, Angular 2 updates the DOM accordingly.

This is incredibly fast (until it's not)

When you start a new Angular 2 application, the change detector seems magically fast. The view compiler for Angular 2 produces Javascript VM-friendly code, and is as smart as it can be about rapidly determining if an expression has changed.

As the application grows, things may start to lag a bit. If you have drag & drop in your interface, you may find that you're no longer getting silky-smooth 60FPS updates as you drag elements around. Eventually you'll run a profiler on your code to see what's slow, and you'll find something that looks like this:

There's no single function call that's taking up all your time. Instead, it's thousands upon thousands of calls inside AbstractChangeDetector. Each is nearly instantaneous, but in aggregate they are crushingly slow.

But almost nothing is changing!

Why should the change detector be chewing up so much CPU when almost nothing is changing from one Javascript execution to the next? Well, you the programmer know that almost nothing has changed, but Angular 2 needs to empirically determine that each and every time, for every single piece of updateable DOM in your application. For a very detailed explanation of what's going on, I recommend this excellent Thoughtram post.

At this point, there are three things you can do, and you should do all of them:

  1. Have less DOM. This is a critically important piece of the puzzle. If DOM elements aren't visible, you should remove them from the DOM by using *ngIf instead of simply hiding elements with CSS. As the saying goes, the fastest code is code that is not run—and the fastest DOM is DOM that doesn't exist.
  2. Make your expressions faster. Move complex calculations into the ngDoCheck lifecycle hook, and refer to the calculated value in your view. Cache results to complex calculations as long as possible.
  3. Use the OnPush change detection strategy to tell Angular 2 there have been no changes. This lets you skip the entire change detection step on most of your application most of the time.

OnPush change detection strategy

By default, the change detection strategy on any component or directive is CheckAlways. There is another strategy, OnPush, which can be much more efficient if you build your application carefully.

OnPush means that the change detector will only run on a component or directive if one of the following occurs:

  • An input property has changed to a new value
  • An event handler fired in the component or directive
  • You manually tell the change detector to look for changes
  • The change detector of a child component or directive runs

If you're able to use OnPush throughout your application, you will ideally only ever run the change detector on components that have actually changed (and their direct ancestors). This reduces the time complexity of the change detector from O(n) to O(log n) in the number of component instances in your application.

There are two ways to prepare a component or directive for OnPush: Using immutable inputs, or using observable inputs.

Method 1: Only use immutable inputs

When you use OnPush, the change detector will run whenever an input property changes. However, if you pass an object or array as an input to a component (which is very common), the change detector will not notice if something in that object or array changes. It only detects when you change to a different object entirely.

The simplest way to make OnPush work perfectly is to use immutable objects throughout a component. If you change an object you are passing to a component, don't change its properties in place; rather, construct a copy with the change applied. That way Angular 2 can see you've changed an input and adjust your DOM accordingly.

Method 2: Only use observable inputs and injectables

If you can reliably listen for when your data changes, then you can manage when the change detector runs yourself. It doesn't matter if you're using something like RxJS's Observables, or if you roll your own change management system, as long as you are notified every time your data changes.

As a simple example, consider a simple indicator of a user's name and portrait. You want to keep the indicator up to date, but changes can come from elsewhere in the application or even from another browser tab. But you've implemented your own onChanges method on your injectable User that calls a callback whenever your data changes. So your component could look like this:

UserIndicator = ng.core
  .Component({
    selector: 'user-indicator',
    template: '<img src="{{user.portrait}}"/> {{user.name}}',
    changeDetection: ng.core.ChangeDetectionStrategy.OnPush
  })
  .Class({
    constructor:[
      User, ng.core.ChangeDetectorRef, ng.core.NgZone,
      function(user, cdr, zone) {
        this.user = user;

        this.onChangesCallback = function() {
          zone.run(function() {
            cdr.markForCheck();
          });
        };

        this.user.onChanges(this.onChangesCallback);
      },
      ngOnDestroy:function() {
        this.user.removeOnChanges(this.onChangesCallback);
      }
    ]
  });

When the component is constructed, we inject our observable user, a change detector, and the zone. Whenever changes happen on our user, we mark the change detector as needing to be checked within the Angular 2 zone. It's important to do this if your change callback can come from outside the Angular 2 zone, which does happen regularly in our application.

Alternatively, if the user is passed into the component as a property, it might look like this:

UserIndicator = ng.core
  .Component({
    selector: 'user-indicator',
    template: '<img src="{{user.portrait}}"/> {{user.name}}',
    properties: ['user'],
    changeDetection: ng.core.ChangeDetectionStrategy.OnPush
  })
  .Class({
    constructor:[
      ng.core.ChangeDetectorRef, ng.core.NgZone,
      function(cdr, zone) {
        this.onChangesCallback = function() {
          zone.run(function() {
            cdr.markForCheck();
          });
        };
      },
      ngOnChanges:function(changes) {
        if('user' in changes') {
          if(changes.user.previousValue) {
            changes.user.previousValue.removeOnChanges(this.onChangesCallback);
          }
          this.user.onChanges(this.onChangesCallback);
        }
      },
      ngOnDestroy:function() {
        this.user.removeOnChanges(this.onChangesCallback);
      }
    ]
  });

Here, we have to watch for changes with the ngOnChanges lifecycle hook, and unbind and rebind changes appropriately.

Now, I don't want to recommend that you roll your own change event or observable system. But these examples hopefully make it clear that you don't have to use any particular library in order to take advantage of Angular 2's OnPush change detection strategy.

Implement OnPush starting at your "leaf" components

If a component is not marked for change detection, none of its descendant components are checked either. So if you use the OnPush strategy on a component which consists of CheckAlways components, those CheckAlways components will never actually be checked for changes.

This can bite you in subtle ways. For example, you may check that each child component each is using OnPush, but forget that you're using a Directive that does not. Getting this right is not complex, but it does require complete thoroughness.

Use your profiler

As you work to make your applications faster, using Angular 2 or otherwise, always use profiling tools to decide where to optimize. If your profiler output is dominated by a function that is rarely called but operates too slowly, optimizing the change detector won't help much.

If your profiler output looks like ours did (in the image earlier in this post), use the "Heavy (Bottom-Up)" view and sort by total time. Then scan down the list until you find the first few methods from your application. Make them faster. And when they're pretty fast, but still taking up too much time, that's when you want to improve your change detector patterns.

About Lucid

Lucid Software is a pioneer and leader in visual collaboration dedicated to helping teams build the future. With its products—Lucidchart, Lucidspark, and Lucidscale—teams are supported from ideation to execution and are empowered to align around a shared vision, clarify complexity, and collaborate visually, no matter where they are. Lucid is proud to serve top businesses around the world, including customers such as Google, GE, and NBC Universal, and 99% of the Fortune 500. Lucid partners with industry leaders, including Google, Atlassian, and Microsoft. Since its founding, Lucid has received numerous awards for its products, business, and workplace culture. For more information, visit lucid.co.

Solutions

  • Digital transformation
  • Cloud migration
  • New product development
  • Efficiency through AI
  • View more

Resources

  • Customers
  • Developers
  • Security
  • Support
  • Learning campus
  • Community
  • Partners
  • Newsletter
PrivacyLegalCookie privacy choicesCookie policy
  • linkedin
  • twitter
  • instagram
  • facebook
  • youtube
  • glassdoor
  • tiktok

© 2024 Lucid Software Inc.