Vue.js Custom Component Renderers

Joshua Bemenderfer

While in most web apps you’ll likely be rendering to the DOM, there are a few special cases where you might want to use Vue for something else. Say you’re developing an app with WebGL and you’d like to describe it with Vue as a tree of components. While Vue doesn’t explicitly support this at the moment, it’s entirely possible to implement yourself, as you’ll discover below.

We’ll be using pixi.js for this guide, so be sure to install that via npm. This is an advanced article, and as such, it is assumed that you already have a skeleton app prepared with webpack and vue 2.2+. Additionally, explanation will largely happen through code comments due to the complex nature of the components.

The goal will be to produce a set of three Vue components, the renderer, container, and a sprite component in order to draw textures in a 2D WebGL canvas with pixi.js.

The end result should look something like this:

Vue.js logo rendered in PIXI.js

Note: This is not a guide on implementing a complete PIXI renderer in Vue, just the basics. You'll have to handle anything further yourself if you intend to do something more serious.

The Renderer Component

This is the component that initializes our PIXI stage and provides the PIXI objects to all of its descendants. (via. Vue 2.2+’s provide / inject system.)

PIXIRenderer.vue

<template>
  <div class="pixi-renderer">
    <canvas ref="renderCanvas"></canvas>
    <!-- All child <template> elements get added in here -->
    <slot></slot>
  </div>
</template>

<script>
import Vue from 'vue';
import * as PIXI from 'pixi.js';

const EventBus = new Vue();

function getProviders() {
  return {
    // These need to be contained in an object because providers are not reactive.
    PIXIWrapper: {
      // Expose PIXI and the created app to all descendants.
      PIXI,
      PIXIApp: null
    },
    // Expose the event bus to all descendants so they can listen for the app-ready event.
    EventBus: new Vue()
  }
}

export default {
  // Allows descendants to inject everything.
  provide: getProviders,
  // We have to inject into this component as well, so we can instantiate the objects.
  inject: ['PIXIWrapper', 'EventBus'],

  mounted() {
    // Determine the width and height of the renderer wrapper element.
    const renderCanvas = this.$refs.renderCanvas;
    const w = renderCanvas.offsetWidth;
    const h = renderCanvas.offsetHeight;

    // Create a new PIXI app.
    this.PIXIWrapper.PIXIApp = new PIXI.Application(w, h, {
      view: renderCanvas,
      backgroundColor: 0x1099bb
    });

    this.EventBus.$emit('ready');
  }
}
</script>

<style scoped>
canvas {
  width: 100%;
  height: 100%;
}
</style>

This component primarily does two things.

  1. When the renderer is added to the DOM, create a new PIXI app on the canvas and emit the ready event.
  2. Provides the PIXI app and event bus to all descendant components.

Container Component

The container component can contain an arbitrary amount of sprites or other containers, allowing for group nesting.

PIXIContainer.vue

<script>
export default {
  // Inject the EventBus and PIXIWrapper objects from the ancestor renderer component.
  inject: ['EventBus', 'PIXIWrapper'],
  // Take properties for the x and y position. (Basic, no validation)
  props: ['x', 'y'],

  data: () => ({
    // Keep a reference to the container so children can be added to it.
    container: null
  }),

  // At the current time, Vue does not allow empty components to be created without a DOM element if they have children.
  // To work around this, we create a tiny render function that renders to <template><!-- children --></template>.
  render: function(h) {
    return h('template', this.$slots.default)
  },

  created() {
    // Create a new PIXI container and set some default values on it.
    this.container = new this.PIXIWrapper.PIXI.Container();

    // You should probably use computed properties to set the position instead.
    this.container.x = this.x || 0;
    this.container.y = this.y || 0;

    // Allow the container to be interacted with.
    this.container.interactive = true;

    // Forward PIXI's pointerdown event through Vue.
    this.container.on('pointerdown', () => this.$emit('pointerdown', this.container));

    // Once the PIXI app in the renderer component is ready, add this container to its parent.
    this.EventBus.$on('app-ready', () => {
      if (this.$parent.container) {
        // If the parent is another container, add to it.
        this.$parent.container.addChild(this.container)
      } else {
        // Otherwise it's a direct descendant of the renderer stage.
        this.PIXIWrapper.PIXIApp.stage.addChild(this.container)
      }

      // Emit a Vue event on every tick with the container and tick delta for an easy way to do frame-by-frame animation.
      // (Not performant)
      this.PIXIWrapper.PIXIApp.ticker.add(delta => this.$emit('tick', this.container, delta))
    })
  }
}
</script>

The container component takes two propertis, x and y, for position and emits two events, pointerdown and tick to handle clicking and frame updates, respectively. It can also have any number of containers or sprites as children.

The Sprite Component

The sprite component is almost the same as the container component, but with some extra tweaks for PIXI’s Sprite API.

PIXISprite.vue

<script>
export default {
  inject: ['EventBus', 'PIXIWrapper'],
  // x, y define the sprite's position in the parent.
  // imagePath is the path to the image on the server to render as the sprite.
  props: ['x', 'y', 'imagePath'],

  data: () => ({
    sprite: null
  }),

  // We can leave the render function empty
  // since a sprite has no children.
  render() {},

  created() {
    this.sprite = this.PIXIWrapper.PIXI.Sprite.fromImage(this.imagePath);
    // Set the initial position.
    this.sprite.x = this.x || 0;
    this.sprite.y = this.y || 0;
    this.sprite.anchor.set(0.5);

    // Opt-in to interactivity.
    this.sprite.interactive = true;

    // Forward the pointerdown event.
    this.sprite.on('pointerdown', () => this.$emit('pointerdown', this.sprite));

    // When the PIXI renderer starts.
    this.EventBus.$on('ready', () => {
      // Add the sprite to the parent container or the root app stage.
      if (this.$parent.container) {
        this.$parent.container.addChild(this.sprite);
      } else {
        this.PIXIWrapper.PIXIApp.stage.addChild(this.sprite);
      }

      // Emit an event for this sprite on every tick.
      // Great way to kill performance.
      this.PIXIWrapper.PIXIApp.ticker.add(delta => this.$emit('tick', this.sprite, delta));
    })
  }
}
</script>

The sprite is pretty much the same as a container, except that it has an imagePath prop which allows you to choose what image to load and display from the server.

Usage

A simple Vue app that uses these three components to render the image at the start of the article:

App.vue

<template>
  <div id="app">
    <pixi-renderer>
      <container
        :x="200" :y="400"
        @tick="tickInfo" @pointerdown="scaleObject"
      >
        <sprite :x="0" :y="0" imagePath="./assets/vue-logo.png"/>
      </container>
    </pixi-renderer>
  </div>
</template>

<script>
import PixiRenderer from './PIXIRenderer.vue'
import Sprite from './PIXISprite.vue'
import Container from './PIXIContainer.vue'

export default {
  components: {
    PixiRenderer,
    Sprite,
    Container
  },

  methods: {
    scaleObject(container) {
      container.scale.x *= 1.25;
      container.scale.y *= 1.25;
    },

    tickInfo(container, delta) {
      console.log(`Tick delta: ${delta}`)
    }
  }
}
</script>

<style>
#app {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
}
</style>

Caveats

  • Unfortunately, there’s still a DOM representation present for any container components due to Vue requiring you to have an element to render if you want to add children to your component. Hopefully this will be resolved by a patch in the near future.
  • You have to proxy any input properties and output events from the app. This is no different from any other library, but can be quite an extensive and difficult to test task if you’re maintaining a binding for a large library.
  • While screen graphs and renderers are a particularly ideal use-cases for this technique, it can be applied to just about anything, even AJAX requests. That said, it’s almost always a terrible idea to mix presentation and logic.

👉 Hopefully your knowledge of what can be done with Vue components has now been significantly expanded and the ideas are flowing like a waterfall. These are practically unexplored grounds, so there’s plenty you could choose to do!

Tread carefully! (or don't!)

✖ Clear

🕵 Search Results

🔎 Searching...