Last week, I wrote an article that showed how to use Vue.js and Chrome devtools to measure performance for a game of Snake by analyzing its component render times, frames per second, and flame graphs. I ended that post with the two realizations that Vue validation could be expensive for components with many props and that I should re-evaluate my underlying architecture before pointing fingers at Vue for slow render times. So now, after some additional introspection and experimentation, I'm back with answers!

First, an overview of the new Snake metrics after making the code changes that will be described in this post:

  • Time-to-move-snake decreased from ~47ms to ~11ms.
  • Time-to-render-component decreased from ~50ms to <3ms.
  • Render rate increased from ~36fps to ~56fps.

And the accompanying graphs:

Moving the snake now only takes ~11ms (meaning a frame rate of 90fps is potentially possible!).
Moving the snake now only takes ~11ms (meaning a frame rate of 90fps is potentially possible!).
Components now only need a few milliseconds to render.
Components now only need a few milliseconds to render.
Snake frame rate is now consistently >50fps.
Snake frame rate is now consistently >50fps.

So how'd I do it?

Understanding When to Vue

When I'd first done my performance analysis, I'd traversed down the flame graph and focused on the single function where time was most prominently spent, which was validateProp(). However, on reviewing the flame graphs, it became clear that the costly part of the update was actually its ancestor -- patch() -- being called on each and every BaseHexagon component on a HexagonGrid made up hundreds of them.

The validateProp function often takes >1ms to finish, but this is only one piece of the patch function.
The validateProp() function often takes >1ms to finish, but this is only one piece of the patch() function.

Some context on the patch() function:

Vue works by building a virtual DOM composed of Javascript objects where it keeps track of component changes before applying them to the real DOM of HTML elements. These changes are discovered during the patch() call, where each component's reactive properties are evaluated and the component's virtual DOM node is updated if changes are found. This approach is performant and enables declarative DOM manipulation.

But this only makes sense when the goal is to eventually modify the real DOM. In my case, the game is drawn on a single <canvas> element shared by all the BaseHexagon components. When any BaseHexagons change, I don't need to update the DOM; I only need to use the canvas API to redraw the changed BaseHexagons. Since I needed a way to track BaseHexagon changes and automatically re-render them, I'd taken advantage of Vue's virtual DOM to handle that for me, but I completely missed some big flaws with this plan. Most noteworthy, Vue is optimized to track changes for the real DOM, not for an HTML5 canvas.

If I really wanted to track canvas changes, then I needed to use a proper game engine. There are plenty of good ones out there, but since this project was started with the express purpose of learning Javascript to use for future non-gaming projects, I brainstormed solutions that would allow me to continue using Vue instead.

The Change

I knew I'd need to re-implement the BaseHexagon component in another form. I thought about the features of a Vue component that I wanted to keep -- reactive properties, computed properties, a well-defined interface, an internal state, and an automatic re-render on changes. While listing these features, it finally clicked. I wanted the OOP paradigm. I needed a class!

The change from component to class was shockingly easy. I was able to copy-and-paste all my component methods as they were and only had to make a few tweaks for the other pieces to work:

  • the well-defined interface (props) was implemented via the constructor and some getters and setters
  • the internal state (data) was implemented through "private" variables prefixed with an underscore
  • the reactive properties were tracked through a dirty bit that got set inside the setters and unset inside the render function
  • the automatic re-render was triggered by the dirty bit

It ended up looking something like this:

export class Hexagon {
  /**
   * Construct a new hexagon.
   * (Prop replacement.)
   */
  constructor(options = { fillColor: "blue", height: 20, width: 20 }) {
    this._fillColor = fillColor;
    this._height = height;
    this._width = width;
  
    this._dirty = true;
  }

  /**
   * Change the color of the hexagon.
   * (Prop replacement.)
   */
  set fillColor(value) {
    if (this._fillColor != value) this._dirty = true;
    this._fillColor = value;
  }

  /**
   * The hexagon's central HTML5 canvas pixel.
   * (Data/computed property replacement.)
   */
  get _centroid() {
    return ...code here...;
  }

  /**
   * Draw the hexagon on an HTML5 canvas.
   * (Method replacement.)
   */
  _draw() {
      ...code here...
  }

  /**
   * Render the hexagon if any changes have been made.
   * (Reactive re-render.)
   */
  async render() {
    if (!this._dirty) return;
    this._draw();
  }
};
 

And that was it! Of course, this isn't something that makes sense for all components, but it was a perfect fit for my use case of a component-like structure that didn't manipulate the DOM. As always, there may be better ways to do this, so I plan to revist the topic again when I migrate from Vue 2 to Vue 3. Maybe Vue 3's composition API will offer an elegant alternative. When I get there, I'll make sure to write about it.