DMack
DMack

Reputation: 949

Dynamic Vue components with sync and events

I'm using <component v-for="..."> tags in Vue.js 2.3 to dynamically render a list of components.

The template looks like this:

<some-component v-for="{name, props}, index in modules" :key="index">
    <component :is="name" v-bind="props"></component>
</some-component>

The modules array is in my component data() here:

modules: [
    {
        name: 'some-thing',
        props: {
            color: '#0f0',
            text: 'some text',
        },
    },
    {
        name: 'some-thing',
        props: {
            color: '#f3f',
            text: 'some other text',
        },
    },
],

I'm using the v-bind={...} object syntax to dynamically bind props and this works perfectly. I also want to bind event listeners with v-on (and use .sync'd props) with this approach, but I don't know if it's possible without creating custom directives.

I tried adding to my props objects like this, but it didn't work:

props: {
    color: '#f3f',
    text: 'some other text',
    'v-on:loaded': 'handleLoaded', // no luck
    'volume.sync': 'someValue', // no luck
},

My goal is to let users re-order widgets in a sidebar with vuedraggable, and persist their layout preference to a database, but some of the widgets have @events and .synced props. Is this possible? I welcome any suggestions!

Upvotes: 3

Views: 3730

Answers (1)

Bert
Bert

Reputation: 82499

I don't know of a way you could accomplish this using a dynamic component. You could, however, do it with a render function.

Consider this data structure, which is a modification of yours.

modules: [
  {
    name: 'some-thing',
    props: {
      color: '#0f0',
      text: 'some text',
    },
    sync:{
      "volume": "volume"
    },
    on:{
      loaded: "handleLoaded"
    }
  },
  {
    name: 'other-thing',
    on:{
      clicked: "onClicked"
    }
  },
],

Here I am defining two other properties: sync and on. The sync property is an object that contains a list of all the properties you would want to sync. For example, above the sync property for one of the components contains volume: "volume". That represents a property you would want to typically add as :volume.sync="volume". There's no way (that I know of) that you can add that to your dynamic component dynamically, but in a render function, you could break it down into it's de-sugared parts and add a property and a handler for updated:volume.

Similarly with the on property, in a render function we can add a handler for an event identified by the key that calls a method identified in the value. Here is a possible implementation for that render function.

render(h){
  let components = []
  let modules = Object.assign({}, this.modules)
  for (let template of this.modules) {
    let def = {on:{}, props:{}}
    // add props
    if (template.props){
      def.props = template.props
    } 
    // add sync props
    if (template.sync){
      for (let sync of Object.keys(template.sync)){
        // sync properties are just sugar for a prop and a handler
        // for `updated:prop`. So here we add the prop and the handler.
        def.on[`update:${sync}`] = val => this[sync] = val
        def.props[sync] = this[template.sync[sync]]
      }
    }
    // add handers
    if (template.on){
      // for current purposes, the handler is a string containing the 
      // name of the method to call
      for (let handler of Object.keys(template.on)){
        def.on[handler] = this[template.on[handler]]
      }
    }
    components.push(h(template.name, def))
  }
  return h('div', components)
}

Basically, the render method looks through all the properties in your template in modules to decide how to render the component. In the case of properties, it just passes them along. For sync properties it breaks it down into the property and event handler, and for on handlers it adds the appropriate event handler.

Here is an example of this working.

console.clear()

Vue.component("some-thing", {
  props: ["volume","text","color"],
  template: `
    <div>
     <span :style="{color}">{{text}}</span>
      <input :value="volume" @input="$emit('update:volume', $event.target.value)" />
      <button @click="$emit('loaded')">Click me</button>
    </div>
  `
})

Vue.component("other-thing", {
  template: `
    <div>
      <button @click="$emit('clicked')">Click me</button>
    </div>
  `
})

new Vue({
  el: "#app",
  data: {
    modules: [{
        name: 'some-thing',
        props: {
          color: '#0f0',
          text: 'some text',
        },
        sync: {
          "volume": "volume"
        },
        on: {
          loaded: "handleLoaded"
        }
      },
      {
        name: 'other-thing',
        on: {
          clicked: "onClicked"
        }
      },
    ],
    volume: "stuff"
  },
  methods: {
    handleLoaded() {
      alert('loaded')
    },
    onClicked() {
      alert("clicked")
    }
  },
  render(h) {
    let components = []
    let modules = Object.assign({}, this.modules)
    for (let template of this.modules) {
      let def = {
        on: {},
        props: {}
      }
      // add props
      if (template.props) {
        def.props = template.props
      }
      // add sync props
      if (template.sync) {
        for (let sync of Object.keys(template.sync)) {
          // sync properties are just sugar for a prop and a handler
          // for `updated:prop`. So here we add the prop and the handler.
          def.on[`update:${sync}`] = val => this[sync] = val
          def.props[sync] = this[template.sync[sync]]
        }
      }
      // add handers
      if (template.on) {
        // for current purposes, the handler is a string containing the 
        // name of the method to call
        for (let handler of Object.keys(template.on)) {
          def.on[handler] = this[template.on[handler]]
        }
      }
      components.push(h(template.name, def))
    }
    return h('div', components)
  },
})
<script src="https://unpkg.com/[email protected]/dist/vue.js"></script>
<div id="app"></div>

Upvotes: 3

Related Questions