How I optimized Minesweeper using Angular 2 and Immutable.js to make it insanely fast
In my previous article I showed you how to build Minesweeper using Angular 2 and Immutable.js. You can try out the game right here.
One thing that bugged me is that it turned out to be slow on mobile. After all we are using Angular 2, which is supposed to be really fast. And we use Immutable.js, which should help in making dirty checking blazing fast. So why is it slow on mobile?
In this article I will show you what I did to figure out what was wrong and how I optimized it. Turns out we can make it faster, a whole lot faster, as in: "insanely fast"!
Quick hint: it was not Angular 2's fault and it was not Immutable.js's fault!
So let's get started!
The problem
After adding some log statements I quickly discovered what was causing the performance issues. Whenever a tile was clicked, the entire board was re-rendered:
So with every click, all 256 tiles were re-rendered. Imagine a board with 10.000 tiles!
Surely this shouldn't be happening. After all I was using Immutable.js. Maybe I was missing something? What did I do wrong?
Effort 1: Angular 2 and immutability
Luckily, Victor Savkin of the Angular team at Google had just posted a really informative article on Angular 2 and immutability.
The article describes that if you use immutable data, you have to let your angular Angular component know about it, so it knows how to optimize the rendering process.
So I added this line to the tile
component:
@Component({
selector: 'tile',
template: `...`,
changeDetection: ChangeDetectionStrategy.OnPush // ⇐⇐⇐
});
I refreshed the browser and boom! No difference in number of tiles rendered :-(
If this didn't make a difference, it had to be higher up. So I looked at the row
component and added the same line:
@Component({
selector: 'row',
template: `...`,
changeDetection: ChangeDetectionStrategy.OnPush // ⇐⇐⇐
});
Refreshed again and still no difference in number of tiles rendered. So I looked higher up again at the minesweeper
component:
import {Component, Input, CORE_DIRECTIVES} from 'angular2/angular2';
import {partition} from './util';
import {revealTile, isGameOver} from './game';
import {RowComponent} from './row.component';
@Component({
selector: 'minesweeper',
template: `
<div class="board">
<row *ng-for="#row of rows" [row]="row" (tile-click)="handleTileClick($event)"></row>
</div>
`,
directives: [CORE_DIRECTIVES, RowComponent]
})
export class MinesweeperComponent {
@Input() game: any;
rows;
history = Immutable.List();
onChanges(changes){
// Only update game when game has actually changed
if(changes.hasOwnProperty('game')){
this.updateGame()
}
}
updateGame(updateHistory = true){
this.rows = partition(this.game.get('cols'), this.game.get('tiles'));
if(updateHistory){
this.history = this.history.push(this.game);
}
}
handleTileClick(tile){
if(!tile){
return;
}
if (isGameOver(this.game)) {
return;
}
const newGame = revealTile(this.game, tile.get('id'));
if (newGame !== this.game) {
this.game = newGame;
this.updateGame();
}
if (isGameOver(this.game)) {
window.alert('GAME OVER!');
}
}
undo(){
if (this.canUndo()) {
this.history = this.history.pop();
this.game = this.history.last();
// Don't update the history so we don't end up with
// the same game twice in the end of the list
this.updateGame(false);
}
}
canUndo(){
return this.history.size > 1;
}
}
I added the same line again:
@Component({
selector: 'minesweeper',
template: `...`,
changeDetection: ChangeDetectionStrategy.OnPush // ⇐⇐⇐
});
Refreshed and again no difference in number of tiles rendered. What's even more is that this time the Undo button stopped working.
Why did the Undo button stop working? How could this be affected by adding ChangeDetectionStrategy.OnPush
?
It turns out ChangeDetectionStrategy.OnPush
tells Angular that it should not re-render the component unless the input property has changed. This allows Angular to optimize the rendering process for the component.
In case of the minesweeper
component, we have an input property for game
:
@Input() game: any;
This property allows us to pass the game
to the component as a property:
<minesweeper [game]="game"></minesweeper>
So whenever the input property game
changes, the view will be re-rendered. This works fine, even with ChangeDetectionStrategy.OnPush
.
But we also change the game
programmatically in the undo()
method:
undo(){
if (this.canUndo()) {
this.history = this.history.pop();
this.game = this.history.last(); // ⇐⇐⇐
// Don't update the history so we don't end up with
// the same game twice in the end of the list
this.updateGame(false);
}
}
Adding ChangeDetectionStrategy.OnPush
told Angular only to re-render when the input property changes, so it does no longer re-render when we change the property programmatically from within the controller.
Even though adding ChangeDetectionStrategy.OnPush
didn't do any good for the minesweeper
component, it definitely learned us something about how it works and what it does.
Since we change the game
programmatically in the undo()
method, I removed the ChangeDetectionStrategy.OnPush
again from the minesweeper
component.
Having to remove ChangeDetectionStrategy.OnPush
in a component that uses an immutable value as input property is probably a sign that the component is not optimally structured. I will get into this in my next article. If you want to receive a quick note when this article is available, leave your email address at the bottom of this article or follow me on Twitter.
Although we had now optimized the rendering process for row
and tile
, the changes had still not solved the fact the every tile got re-rendered on every click.
Effort 2: remove row partitioning
If every tile was re-rendered upon every tile click, it also implied that every row was re-rendered upon every click. The view part in the minesweeper
component that renders the rows looks like this:
<div class="board">
<row *ng-for="#row of rows" [row]="row" (tile-click)="handleTileClick($event)"></row>
</div>
and the updateGame()
method in the minesweeper
component is responsible for updating the rows:
updateGame(updateHistory = true){
this.rows = partition(this.game.get('cols'), this.game.get('tiles'));
if(updateHistory){
this.history = this.history.push(this.game);
}
}
where partition()
is a function supplied by the original Minesweeper code of the React.js version:
function partition(size, coll) {
var res = [];
for (var i = 0, l = coll.size || coll.length; i < l; i += size) {
res.push(coll.slice(i, i + size));
}
return fromJS(res);
}
The official Angular 2 ng-for
directive documentation says:
Angular uses object identity to track insertions and deletions within the iterator and reproduce those changes in the DOM.
Wait! So the partition
function generates new row objects every time the game data changes and ng-for
uses strict equality (===
) to determine which rows to update.
So even though only one tile
is updated in our game
data, the partition()
function breaks up the game in 16 new row
objects where each row object is different than its previous version (!==
).
This will cause all rows to be updated every single time. Which is exactly what we are seeing!
So how do we fix this?
Easy! We remove the row partitioning and using the game data directly to display our tiles.
So instead of:
<div class="board">
<row *ng-for="#row of rows" [row]="row" (tile-click)="handleTileClick($event)"></row>
</div>
we now use:
<div class="board">
<tile *ng-for="#tile of getTiles()" [tile]="tile" (tile-click)="handleTileClick($event)"></tile>
</div>
Instead of using row
components to display rows, we simple display all tiles and use CSS width
of the board to make sure the tiles are shown correctly.
We could also use the template syntax<template ng-for #item [ng-for-of]="items" #i="index">...</template>
to construct more complicated markup and generate row divs if needed, but here a simpleng-for
of tiles is sufficient.
Let's save the changes and refresh the browser. Boom!
Now only the affected tiles are updated whenever a tile is clicked!
So instead of 256 tiles being re-rendered when 1 tile had changed, only 1 tile is re-rendered!
How is that for a performance gain!!
You can try out the optimized version right here.
Here is a visualization of the change we made:
Summary
By updating the way our data is passed into the components, we were able to optimize the performance exponentially!
The problem was not caused by Angular. The problem was not caused by Immutable.js.
The problem was caused by partitioning the data into rows and creating new immutable row objects every time the game data changed.
We also added changeDetection: ChangeDetectionStrategy.OnPush
to the tile
component to improve the Angular rendering process even more, as explained in Angular 2 and immutability by Victor Savkin.
Conclusion
When working with immutable data it is a good idea to align your datastructure with the logical hierarchy of your components and to avoid operations that may cause strict object equality to be false, especially when using ng-for
.
If you perform operations on your immutable data causing new objects to be created, you may end up with a serious performance hit that may not be immediately visible.
You can add logging to the onChanges()
method of your components to see if they render as expected.
If you have a component that only expects immutable data to be passed in via a property, you can further optimize rendering speed by adding changeDetection: ChangeDetectionStrategy.OnPush
to the component.
All code from this article can be found here:
- original Minesweeper from previous article.
- original Minesweeper with logging to uncover the performance issue.
- optimized Minesweeper from this article.
In the next article I will restructure Minesweeper to make use of Redux.
If you are interested in receiving a note when the article is ready, feel free to leave your email address below or follow me on Twitter.
Have a great one!!