Tutorial

Vue.js Custom Component Renderers

Published on March 9, 2017
author

Joshua Bemenderfer

Vue.js Custom Component Renderers

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';

export default {
  data() {
    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()
    }
  },
  // Allows descendants to inject everything.
  provide() {
    return {
      PIXIWrapper: this.PIXIWrapper,
      EventBus: this.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() {
    return {
      // 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('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() {
    return {
      sprite: null
    }
  },

  render(h) { return h() },

  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!)

Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.

Learn more about our products

About the authors
Default avatar
Joshua Bemenderfer

author

While we believe that this content benefits our community, we have not yet thoroughly reviewed it. If you have any suggestions for improvements, please let us know by clicking the “report an issue“ button at the bottom of the tutorial.

Still looking for an answer?

Ask a questionSearch for more help

Was this helpful?
 
1 Comments


This textbox defaults to using Markdown to format your answer.

You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!

Almost 4 years old article but very interesting. I would like to deepen your work and continue what you have started. Can you tell me why the tickers are slow? Do you have any idea how to improve the performance to be as close as that of PIXI? Cheers!

Try DigitalOcean for free

Click below to sign up and get $200 of credit to try our products over 60 days!

Sign up

Join the Tech Talk
Success! Thank you! Please check your email for further details.

Please complete your information!

Become a contributor for community

Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.

DigitalOcean Documentation

Full documentation for every DigitalOcean product.

Resources for startups and SMBs

The Wave has everything you need to know about building a business, from raising funding to marketing your product.

Get our newsletter

Stay up to date by signing up for DigitalOcean’s Infrastructure as a Newsletter.

New accounts only. By submitting your email you agree to our Privacy Policy

The developer cloud

Scale up as you grow — whether you're running one virtual machine or ten thousand.

Get started for free

Sign up and get $200 in credit for your first 60 days with DigitalOcean.*

*This promotional offer applies to new accounts only.