Vue Composition API

my new best friend

What advantages does Vue* have compared to React*?

* I'm talking about the Vue Options/Class APIs compared to React's Options/Class APIs

What disadvantages does Vue have compared to React?

Vue Composition API fixes all this.

A simple example

<template>
  <button @click="increment">
    Count is: {{ state.count }}, double is: {{ state.double }}
  </button>
</template>

<script>
import { reactive, computed } from 'vue'

export default {
  setup() {
    const state = reactive({
      count: 0,
      double: computed(() => state.count * 2)
    })

    function increment() {
      state.count++
    }

    return {
      state,
      increment
    }
  }
}
</script>

Look Ma!

No 'this'!

Why'd they change?

Mixins

Why'd they change?

Less Confusing Nomenclature

Why'd they change?

Seperation of Concerns

And now: an INCREDIBLY COMPLEX example

This is actually not using Vue 3.0, but Vue 2.0. While I recommend that you do give 3.0 a try, it might be a bit much to convert legacy 2.0 projects.

That shouldn't stop you however, as you can just add the Vue Composition API as a plugin - like Vuex or Vue Router - and use it in Vue 2.0 codebases. (That's right: Try before you buy!)

# BASH
$ yarn add @vue/composition-api

/* ------ */
/* in main.ts/main.js */

import VueComposition from '@vue/composition-api';
Vue.use(VueComposition)

old component

new component

  import { Component, Prop, Watch, Vue } from 'vue-property-decorator'
  import { Action } from 'vuex-class'
  import { IRoom, IEvent } from '../types'

  @Component
  export default class RoomClock extends Vue {
    @Prop({ required: true }) roomEvents: Array<IEvent>
    @Action('getEventsByRoom') getEventsByRoom: Function

    ctx: CanvasRenderingContext2D | null

    height: number = 0

    width: number = 0

    interval: number = 0

    private CLOCK = {
      PADDING: 0,
      BORDER: 14,
      DEFAULT: 'rgba(255,255,255, 0.1)',
      HOUR_HAND: 'white',
      BUSY: ['#FB4352', '#22CDAD', '#FEE44D', '#1EA1FC'],
      RADIUS_DIVIDER: 2.2
    }

    $refs: {
      container: Element
      clockCanvas: HTMLCanvasElement
    }

    get x() {
      return this.width / 2
    }

    get y() {
      return this.height / 2
    }

    get radius() {
      return (this.x * 94) / 100
    }

    randomColor() {
      return this.CLOCK.BUSY[Math.floor(Math.random() * this.CLOCK.BUSY.length)]
    }

    @Watch('roomEvents')
    draw() {
      this.setCanvas()
      this.drawOuterCircle()
      this.drawOccupiedHours()
      this.drawCurrentHourIndicator()
    }

    setCanvas() {
      // check is container & canvas is avaliable
      if (!this.$refs.container) return null

      this.ctx = this.$refs.clockCanvas.getContext('2d')
      // once both of them are avaliable
      if (this.$refs.container.parentElement) {
        const { width, height } = this.$refs.container.parentElement.getBoundingClientRect()
        this.width = width + this.CLOCK.PADDING
        this.height = height + this.CLOCK.PADDING
      }

      if (this.ctx) {
        // clear everything
        this.ctx.clearRect(0, 0, this.width, this.height)
        return true
      }
    }

    drawOuterCircle() {
      if (this.ctx) {
        this.ctx.beginPath()
        this.ctx.arc(this.x, this.y, this.radius, 0, 2 * Math.PI, false)
        this.ctx.lineWidth = this.CLOCK.BORDER
        this.ctx.strokeStyle = this.CLOCK.DEFAULT
        this.ctx.stroke()
      }
    }

    async drawOccupiedHours() {
      if (this.ctx) {
        // get the data
        this.ctx.lineWidth = this.CLOCK.BORDER
        // draw for all current and future occupied events
        this.roomEvents.forEach(item => {
          if (new Date() < new Date(item.End) && this.ctx) {
            const startAngle = this.getAngle(item.Start)
            const endAngle = this.getAngle(item.End)
            // draw arc
            this.ctx.beginPath()
            this.ctx.strokeStyle = this.randomColor()
            this.ctx.arc(this.x, this.y, this.radius, startAngle, endAngle)
            this.ctx.stroke()
          }
        })
        return true
      }
    }

    drawCurrentHourIndicator() {
      const now = new Date()
      const nowPlusMinute = new Date()
      nowPlusMinute.setMinutes(nowPlusMinute.getMinutes() + 2)

      const startAngle = this.getAngle(now.toJSON())
      const endAngle = this.getAngle(nowPlusMinute.toJSON())

      if (this.ctx) {
        // draw arc
        this.ctx.beginPath()
        this.ctx.strokeStyle = this.CLOCK.HOUR_HAND
        this.ctx.lineWidth = this.CLOCK.BORDER

        this.ctx.arc(this.x, this.y, this.radius, startAngle, endAngle)
        this.ctx.stroke()
      }
    }

    getAngle(timeStr: any) {
      const time = new Date(timeStr)

      const h = time.getHours()
      const m = time.getMinutes()
      const s = time.getSeconds()

      // convert hours, minutes and seconds to angle
      const angle = ((h % 12) * Math.PI) / 6 + (m * Math.PI) / (6 * 60) + (s * Math.PI) / (360 * 60)

      // remove 90 deg from the angle to start at 12 O'clock
      return angle - 0.5 * Math.PI
    }
  }
  import {
    defineComponent,
    reactive,
    ref,
    watch,
    toRefs,
    onMounted,
  } from '@vue/composition-api'
  import { IEvent } from '../../types'
  import { RoomClockProps, RoomClockState } from './types'
  import useCanvas from './hooks/useCanvas'

  const RoomClock = defineComponent({
    name: 'room-clock',
    setup(props: RoomClockProps, vm: unknown) {
      const container = ref<null | HTMLElement>(null)
      const clockCanvas = ref<null | HTMLCanvasElement>(null)
      const roomEvents: IEvent[] = props.roomEvents

      const state = reactive<RoomClockState>({
        ctx: null,
        height: 0,
        width: 0,
        interval: 0
      })
      const draw = useCanvas({ container, clockCanvas, state, roomEvents })
      onMounted(() => {
        draw()
      })
      watch(
        () => roomEvents,
        () => draw()
      )

      const { width, height } = toRefs(state)

      return { width, height, container, clockCanvas }
    }
  })

old component

  import { Component, Prop, Watch, Vue } from 'vue-property-decorator'
  import { Action } from 'vuex-class'
  import { IRoom, IEvent } from '../types'

  @Component
  export default class RoomClock extends Vue {
    @Prop({ required: true }) roomEvents: Array<IEvent>
    @Action('getEventsByRoom') getEventsByRoom: Function

    ctx: CanvasRenderingContext2D | null

    height: number = 0

    width: number = 0

    interval: number = 0

    private CLOCK = {
      PADDING: 0,
      BORDER: 14,
      DEFAULT: 'rgba(255,255,255, 0.1)',
      HOUR_HAND: 'white',
      BUSY: ['#FB4352', '#22CDAD', '#FEE44D', '#1EA1FC'],
      RADIUS_DIVIDER: 2.2
    }

    $refs: {
      container: Element
      clockCanvas: HTMLCanvasElement
    }

    get x() {
      return this.width / 2
    }

    get y() {
      return this.height / 2
    }

    get radius() {
      return (this.x * 94) / 100
    }

    randomColor() {
      return this.CLOCK.BUSY[Math.floor(Math.random() * this.CLOCK.BUSY.length)]
    }

    @Watch('roomEvents')
    draw() {
      this.setCanvas()
      this.drawOuterCircle()
      this.drawOccupiedHours()
      this.drawCurrentHourIndicator()
    }

    setCanvas() {
      // check is container & canvas is avaliable
      if (!this.$refs.container) return null

      this.ctx = this.$refs.clockCanvas.getContext('2d')
      // once both of them are avaliable
      if (this.$refs.container.parentElement) {
        const { width, height } = this.$refs.container.parentElement.getBoundingClientRect()
        this.width = width + this.CLOCK.PADDING
        this.height = height + this.CLOCK.PADDING
      }

      if (this.ctx) {
        // clear everything
        this.ctx.clearRect(0, 0, this.width, this.height)
        return true
      }
    }

    drawOuterCircle() {
      if (this.ctx) {
        this.ctx.beginPath()
        this.ctx.arc(this.x, this.y, this.radius, 0, 2 * Math.PI, false)
        this.ctx.lineWidth = this.CLOCK.BORDER
        this.ctx.strokeStyle = this.CLOCK.DEFAULT
        this.ctx.stroke()
      }
    }

    async drawOccupiedHours() {
      if (this.ctx) {
        // get the data
        this.ctx.lineWidth = this.CLOCK.BORDER
        // draw for all current and future occupied events
        this.roomEvents.forEach(item => {
          if (new Date() < new Date(item.End) && this.ctx) {
            const startAngle = this.getAngle(item.Start)
            const endAngle = this.getAngle(item.End)
            // draw arc
            this.ctx.beginPath()
            this.ctx.strokeStyle = this.randomColor()
            this.ctx.arc(this.x, this.y, this.radius, startAngle, endAngle)
            this.ctx.stroke()
          }
        })
        return true
      }
    }

    drawCurrentHourIndicator() {
      const now = new Date()
      const nowPlusMinute = new Date()
      nowPlusMinute.setMinutes(nowPlusMinute.getMinutes() + 2)

      const startAngle = this.getAngle(now.toJSON())
      const endAngle = this.getAngle(nowPlusMinute.toJSON())

      if (this.ctx) {
        // draw arc
        this.ctx.beginPath()
        this.ctx.strokeStyle = this.CLOCK.HOUR_HAND
        this.ctx.lineWidth = this.CLOCK.BORDER

        this.ctx.arc(this.x, this.y, this.radius, startAngle, endAngle)
        this.ctx.stroke()
      }
    }

    getAngle(timeStr: any) {
      const time = new Date(timeStr)

      const h = time.getHours()
      const m = time.getMinutes()
      const s = time.getSeconds()

      // convert hours, minutes and seconds to angle
      const angle = ((h % 12) * Math.PI) / 6 + (m * Math.PI) / (6 * 60) + (s * Math.PI) / (360 * 60)

      // remove 90 deg from the angle to start at 12 O'clock
      return angle - 0.5 * Math.PI
    }
  }

All of these concern static data and derived

data for the clock, so they were changed from observables to plain functions and seperated into ./utils/clock.ts

All of these concern drawing on the canvas, so they were moved to useCanvas()

...and changed from managed observable to plain function

which in fact, didn't need to be imported into the main component at all.

Hey Present Brian. This is Past Brian telling you to switch over to the code now.

Key takeaways

  1. We seperate concerns, such as seperating the "Clock" logic from the "Canvas" logic
  2. It's extremely clear what's a "prop", what's "state", what's a method, and what's a derived value, instead of having it all on 'this'
  3. In fact, we never have to keep track of what context 'this' is, because we don't use 'this'
  4. Everything is properly encapsulated - the template doesn't have access to any properties or methods that it doesn't need.
  5. Everything is just a function - so you can test it independently like any other function, without worrying about all the interdependencies on it.
  6. A lot of stuff that was observable, turns out, doesn't have to be, so we can simplify the logic and thereby simplify future maintainance.

Questions?

Vue Composition API: my new best friend

By brianboyko

Vue Composition API: my new best friend

  • 786