loading...

Using vue.js as an alternative to stimulus.js

bdtomlin profile image Bryan Tomlin ・6 min read

I've been using stimulus.js for a while, but I wanted a little more power. I've tried a few different tactics, and feel like I've found a nice middle ground loading Vue components using mutation observers. I'm working on a new Lucky Framework application and I've had a few questions about this approach.

The examples here will be for the lucky framework, where html is compiled from crystal code.

I'm really excited the see how javascript is handled in the future in the Lucky Framework, but in the meantime I need a pragmatic way to get work done and this is the approach I'm using.

I may eventually write a similar post with some examples in rails.

Table of contents for this article

An overview of the setup.

  • Add a data-vue="my-component" to an html tag to detect vue single file components that need to be initialized.
  • Add a data-vue="my-component-inline" to an html tag to detect inline components that use the actual markup as the template.
  • Use "--" to determine folder structure similar to stimulus.js eg. my-dir--some-component
  • Add data-json="...somejson" to pass any data needed into the vue instance.
  • Use a mutation observer to detect when components are added to or removed from the document in order to initialize and destroy them.

Tradeoffs/Disadvantages

  • You cannot nest inline components within other inline components
  • You have to use the larger vue distribution that has contains the compiler
  • inline components can't utilize vuejs scoped css

I'm sure there are more which I will add as they come up.

Advantages

  • Most html can be generated on the server (great with Lucky framework's type safety)
  • Access to the great power of vuejs
  • Ability to use full on vuejs single file components when you need to do something super interactive
  • No need to manually initialize vue components, it all happens automatically
  • Single file components can be embedded in inline components or other single file components

A contrived but easy to follow example

This example could be used anywhere in your app. The part to focus on is what is inside of the content method.

class App::IndexPage < MainLayout
  def content
    div data_vue: "example-inline", data_json: ["one", "two"].to_json do
      para "inputData Item: {{item}}", "v-for": "item in inputData"
    end
  end
end

And here's the javascript side of the vue component.

// src/js/components/example-inline.js
import Vue from 'vue'
export default Vue.extend(
  {
    props: {
      myProp: String
    },
    data() {
      return {
        name: "another"
      }
    },
    methods: {
      changeName(event) {
        event.preventDefault()
        this.name = "some other"
      }
    },
    destroyed() {
      console.log("destroyed inline component")
    },
    mounted() {
      console.log("mounted")
    }
  }
)

And this is the vue rendered html you end up with...

<div data-vue="example-inline" data-json="[&quot;one&quot;,&quot;two&quot;]" data-vueified="">
  <p>inputData Item: one</p>
  <p>inputData Item: two</p>
</div>

A simple but realistic example with Lucky flash messages

This is a simple, but realistic use case where I want flash messages to automatically countdown and remove themselves.

This is a summary of functionality:

  • Animated progress bar
  • Messages autoremove themselves after countdown expires
  • When you mouse over the message the timer is reset and paused
  • When you mouse out the timer restarts
  • There is a close button to manually remove the message

In this particular example I'm using the bulma css framework. I will include only the additional css that is specific to this component.

The Vue component gets setup on the div in the notification_div method. There are also some events wired up on the main notification div and the close button as well as a class binding on the inner progress bar for animation.

# src/components/shared/flash_messages.cr
class Shared::FlashMessages < BaseComponent
  needs flash : Lucky::FlashStore

  FLASH_CSS_CLASSES = {
    "primary": "is-primary",
    "info":    "is-info",
    "link":    "is-link",
    "success": "is-success",
    "warning": "is-warning",
    "failure": "is-danger",
  }

  def render
    if @flash.any?
      div class: "flash-messages" do
        @flash.each do |flash_type, flash_message|
          notification_div(flash_type) do
            button class: "delete", "v-on:click": "close"
            text flash_message
            div class: "flash-progress-bar" do
              div "", class: "flash-progress-bar-inner", "v-bind:class": "{counting: isCounting}"
            end
          end
        end
      end
    end
  end

  private def class_for_flash(flash_type)
    FLASH_CSS_CLASSES[flash_type]
  end

  private def notification_div(flash_type)
    div class: "notification #{class_for_flash(flash_type)}",
      flow_id: "flash",
      data_vue: "shared--flash-message-inline",
      "v-on:mouseenter": "onMouseEnter",
      "v-on:mouseleave": "onMouseLeave" do
      yield
    end
  end
end


// src/js/components/shared/flash-message-inline.js
import Vue from 'vue'
export default Vue.extend(
  {
    data() {
      return {
        isCounting: false
      }
    },
    mounted() {
      setTimeout(this.startTimer.bind(this), 25)
    },
    destroyed() {
      clearTimeout(this.timer)
    },
    methods: {
      close(event) {
        event.preventDefault()
        this.removeSelf()
      },
      removeSelf() {
        this.$el.remove()
      },
      startTimer() {
        this.isCounting = true
        this.timer = setTimeout(this.removeSelf, 5000)

      },
      onMouseEnter() {
        this.isCounting = false
        clearTimeout(this.timer)
      },
      onMouseLeave() {
        this.startTimer()
      },
    }
  }
)
// src/css/components/shared/flash_messages.scss
.flash-messages {
  position: absolute;
  top: 4rem;
  z-index: 25;
  overflow: visible;
  width: 100%;
  pointer-events: none;
  .notification {
    pointer-events: all;
    box-shadow: 2px 2px 5px hsla(267, 0, 0, 0.5);
    margin: 0 auto 0.75rem auto;
    width: 40%;
  }
}

.flash-progress-bar {
  position: absolute;
  left: 2px;
  width: calc(100% - 4px);
  height: 4px;
  bottom: 3px;
  overflow: hidden;
}

.flash-progress-bar-inner {
  width: 100%;
  border-radius: 8px 0 0 8px;
  height: 4px;
  background: hsla(267, 0, 0, 0.2);
  transition: transform 5s linear;
  position: absolute;
  top: 0;
  left: 0;
  &.counting {
    transform: translate(-100%);
  }
}

.notification:hover {
  .flash-progress-bar-inner {
    background: hsla(267, 0, 0, 0.2);
    transition: none;
    transform: translate(0);
  }
}

The set and forget javascript that makes it all happen

This js definitely has room for improvement but it works well. This file doesn't ever really change, so once it's in place it just gets ignored and you can go about your business of writing html and vue and it all gets loaded and destroyed properly.

It's about 110 lines including empty lines and it handles the following:

  • Load inline and single file vue components from component directory and subdirectories
  • Use mutation observer to watch for data-vue attributes and initialize the appropriate component
  • Mark processed components before they get initialized to ensure they only get initialized once
  • Use mutation observer to watch for removal of vue instances destroy method
  • Pass any necessary props from the server html along to the vue instance
  • Parse any data in the data-json element and mix it in to the vue component
// src/js/load-vue.js
import Vue from 'vue'

let files = require.context('./components/', true, /\.vue$/i)
files.keys().map(key => {
  const component = key.replace(/^\.\//, "").replace('/', '--').split('.')[0]
  Vue.component(component, files(key).default)
})
files = require.context('./components/', true, /\.js$/i)
let inlineComponents = {}
files.keys().map(key => {
  const component = key.replace(/^\.\//, "").replace(/\//g, '--').split('.')[0]
  inlineComponents[component] = files(key).default
})

const ATTRIBUTE_NAME = 'data-vue'
const QUERY_SELECTOR = '[' + ATTRIBUTE_NAME + ']'
const ACTIVE_ATTRIBUTE = 'data-vueified'
const DATA_INPUT_ATTRIBUTE = 'data-json'
const SKIP_ATTRIBUTES = [ATTRIBUTE_NAME, ACTIVE_ATTRIBUTE, DATA_INPUT_ATTRIBUTE]

export default () => {
  const observer = new MutationObserver(callback)
  observer.observe(document.documentElement, { childList: true, subtree: true })
}

function callback(mutationList, _observer) {
  for (let mutation of mutationList) {
    // order matters! remove those old nodes before adding the new!
    processRemovedNodes(mutation.removedNodes)
    processAddedNodes(mutation.addedNodes)
  }
}

function processRemovedNodes(nodes) {
  for (let node of nodes) {
    if (node.nodeType !== Node.ELEMENT_NODE) continue

    if (node.matches(QUERY_SELECTOR)) {
      destroyVueComponent(node)
    }
    for (let el of node.querySelectorAll(QUERY_SELECTOR)) {
      destroyVueComponent(el)
    }
  }
}

function processAddedNodes(nodes) {
  for (let node of nodes) {
    if (node.nodeType !== Node.ELEMENT_NODE) continue

    if (node.matches(QUERY_SELECTOR)) {
      createVueComponent(node)
    }
    for (let el of node.querySelectorAll(QUERY_SELECTOR)) {
      createVueComponent(el)
    }
  }
}

function destroyVueComponent(node) {
  if (node.__vue__) node.__vue__.$destroy()
}

function createVueComponent(node) {
  if (node.hasAttribute(ACTIVE_ATTRIBUTE)) return
  node.setAttribute(ACTIVE_ATTRIBUTE, "")

  let componentName = node.getAttribute(ATTRIBUTE_NAME)
  let dataMixin = {
    data() {
      return { inputData: jsonInput(node) }
    }
  }

  if (componentName.endsWith("-inline")) {
    new inlineComponents[componentName]({ mixins: [dataMixin], propsData: propsData(node) }).$mount(node)
  } else {

    new Vue({
      el: node,
      mixins: [dataMixin],
      template: `<${componentName} ${propsString(node)} :inputData="inputData"/>`,
      components: { componentName }
    })
  }
}

function jsonInput(node) {
  if (!node.hasAttribute(DATA_INPUT_ATTRIBUTE)) return
  return JSON.parse(node.getAttribute(DATA_INPUT_ATTRIBUTE));
}

function propsData(node) {
  return Object.fromEntries(propsArray(node).map(attr => [snakeToCamel(attr[0]), attr[1]]))
}

function propsString(node) {
  return propsArray(node).reduce((acc, cur) => acc + `${cur[0]}='${cur[1]}' `, "")
}

function propsArray(node) {
  return (Object.values(node.attributes).filter(attr => SKIP_ATTRIBUTES.indexOf(attr.name) === -1).map(attr => [attr.name, attr.value]))
}

function snakeToCamel(snake) {
  return snake.split("-").reduce((acc, cur, idx) => {
    if (idx === 0) return cur
    return acc + cur.charAt(0).toUpperCase() + cur.slice(1)
  })
}

Discussion

pic
Editor guide