25

We have a component in Vue which is a frame, scaled to the window size, which contains (in a <slot>) an element (typically <img> or <canvas>) which it scales to fit the frame and enables pan and zoom on that element.

The component needs to react when the element changes. The only way we can see to do it is for the parent to prod the component when that happens, however it would be much nicer if the component could automatically detect when the <slot> element changes and react accordingly. Is there a way to do this?

Euan Smith
  • 2,102
  • 1
  • 16
  • 26

5 Answers5

28

To my knowledge, Vue does not provide a way to do this. However here are two approaches worth considering.

Watching the Slot's DOM for Changes

Use a MutationObserver to detect when the DOM in the <slot> changes. This requires no communication between components. Simply set up the observer during the mounted callback of your component.

Here's a snippet showing this approach in action:

Vue.component('container', {
  template: '#container',
  
  data: function() {
    return { number: 0, observer: null }
  },
  
  mounted: function() {
    // Create the observer (and what to do on changes...)
    this.observer = new MutationObserver(function(mutations) {
      this.number++;
    }.bind(this));
    
    // Setup the observer
    this.observer.observe(
      $(this.$el).find('.content')[0],
      { attributes: true, childList: true, characterData: true, subtree: true }
    );
  },
  
  beforeDestroy: function() {
    // Clean up
    this.observer.disconnect();
  }
});

var app = new Vue({
  el: '#app',

  data: { number: 0 },
  
  mounted: function() {
    //Update the element in the slot every second
    setInterval(function(){ this.number++; }.bind(this), 1000);
  }
});
.content, .container {
  margin: 5px;
  border: 1px solid black;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.13/vue.js"></script>

<template id="container">
  <div class="container">
    I am the container, and I have detected {{ number }} updates.
    <div class="content"><slot></slot></div>
  </div>
</template>

<div id="app">
  
  <container>
    I am the content, and I have been updated {{ number }} times.
  </container>
  
</div>

Using Emit

If a Vue component is responsible for changing the slot, then it is best to emit an event when that change occurs. This allows any other component to respond to the emitted event if needed.

To do this, use an empty Vue instance as a global event bus. Any component can emit/listen to events on the event bus. In your case, the parent component could emit an "updated-content" event, and the child component could react to it.

Here is a simple example:

// Use an empty Vue instance as an event bus
var bus = new Vue()

Vue.component('container', {
  template: '#container',

  data: function() {
    return { number: 0 }
  },

  methods: {
    increment: function() { this.number++; }
  },
  
  created: function() {
    // listen for the 'updated-content' event and react accordingly
    bus.$on('updated-content', this.increment);
  },
  
  beforeDestroy: function() {
    // Clean up
    bus.$off('updated-content', this.increment);
  }
});

var app = new Vue({
  el: '#app',

  data: { number: 0 },

  mounted: function() {
    //Update the element in the slot every second, 
    //  and emit an "updated-content" event
    setInterval(function(){ 
      this.number++;
      bus.$emit('updated-content');
    }.bind(this), 1000);
  }
});
.content, .container {
  margin: 5px;
  border: 1px solid black;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.13/vue.js"></script>

<template id="container">
  <div class="container">
    I am the container, and I have detected {{ number }} updates.
    <div class="content">
      <slot></slot>
    </div>
  </div>
</template>

<div id="app">

  <container>
    I am the content, and I have been updated {{ number }} times.
  </container>
  
</div>
tony19
  • 125,647
  • 18
  • 229
  • 307
asemahle
  • 20,235
  • 4
  • 40
  • 38
  • For the first one isn't DOMSubtreeModified [deprecated](https://www.w3.org/TR/DOM-Level-3-Events/#event-type-DOMSubtreeModified)? – Euan Smith Jul 04 '16 at 09:58
  • For the second, kind-of similarly there is also in Vue 2.0 (which we are planning to transition to once it is stable) a plan to deprecate the broadcast events [see here](https://github.com/vuejs/vue/issues/2873), although that does not prevent an event being directly dispatched on a component. – Euan Smith Jul 04 '16 at 09:59
  • Good catch. I've updated the answer to use `MutationObservers` and `$emit`. – asemahle Jul 04 '16 at 13:58
  • @pqvst You are correct. The old answer was written for Vue 1.0, which used `ready` rather than `mounted`. I have edited the answer to use `mounted` instead. – asemahle Jan 08 '18 at 20:23
  • Does VueJS still not provide the ability to bind a watcher to `$slots.default`? Is this complex Observer cruft still required? – stevendesu Apr 08 '18 at 16:39
17

As far as I understand Vue 2+, a component should be re-rendered when the slot content changes. In my case I had an error-message component that should hide until it has some slot content to show. At first I had this method attached to v-if on my component's root element (a computed property won't work, Vue doesn't appear to have reactivity on this.$slots).

checkForSlotContent() {
    let checkForContent = (hasContent, node) => {
        return hasContent || node.tag || (node.text && node.text.trim());
    }
    return this.$slots.default && this.$slots.default.reduce(checkForContent, false);
},

This works well whenever 99% of changes happen in the slot, including any addition or removal of DOM elements. The only edge case was usage like this:

<error-message> {{someErrorStringVariable}} </error-message>

Only a text node is being updated here, and for reasons still unclear to me, my method wouldn't fire. I fixed this case by hooking into beforeUpdate() and created(), leaving me with this for a full solution:

<script>
    export default {
        data() {
            return {
                hasSlotContent: false,
            }
        },
        methods: {
            checkForSlotContent() {
                let checkForContent = (hasContent, node) => {
                    return hasContent || node.tag || (node.text && node.text.trim());
                }
                return this.$slots.default && this.$slots.default.reduce(checkForContent, false);
            },
        },
        beforeUpdate() {
            this.hasSlotContent = this.checkForSlotContent();
        },
        created() {
            this.hasSlotContent = this.checkForSlotContent();
        }
    };
</script>
Mathew Sonke
  • 171
  • 1
  • 4
1

There is another way to react on slot changes. I find it much cleaner to be honest in case it fits. Neither emit+event-bus nor mutation observing seems correct to me.

Take following scenario:

<some-component>{{someVariable}}</some-component>

In this case when someVariable changes some-component should react. What I'd do here is defining a :key on the component, which forces it to rerender whenever someVariable changes.

<some-component :key="someVariable">Some text {{someVariable}}</some-component>

Kind regard Rozbeh Chiryai Sharahi

  • Nice and simple, so long as you are also in control of where and how the component is used, and if this is possible. Because this is not a regular use pattern it could be unexpected to a consumer of the component. We have something more like ``, so the pattern you use would not work. – Euan Smith Feb 10 '21 at 13:00
  • I understand. In your given case the `` would be a rendered component like `MyView.vue`. If somehow possible, one should prefer emitting an event in `MyView.vue` and react on parent. Example: ``. Though, I totally understand, that in some complex cases, like yours, there is no ways around hammers like EventBus or MutationObservers, Just putting this here, for those who have easier cases. Kind regards – Rozbeh Sharahi Feb 20 '21 at 19:58
  • TBH this is probably the first answer for most people to consider. I would also note that you probably want the key to be unique, for example attach a timestamp to it: `:key="'myKey'+sometimestamp"` – Kalnode May 18 '22 at 20:55
0

I would suggest you to consider this trick: https://codesandbox.io/s/1yn7nn72rl, that I used to watch changes and do anything with slot content.

The idea, inspired by how works VIcon component of vuetify, is to use a functional component in which we implement logic in its render function. A context object is passed as the second argument of the render function. In particular, the context object has a data property (in which you can find attributes, attrs), and a children property, corresponding to the slot (you could event call the context.slot() function with the same result).

Best regards

ekqnp
  • 284
  • 3
  • 13
  • It looks to me like you are making something dependent on the content - in this case showing a 1 or 2 if the slot content is either 'foo' or 'bar'. What we were needing was slightly different - reacting to a changed graphical element, an `img` tag or a `canvas` element. We needed to do something when that changes, rather than do something dependent on the content. To do that you would need, essentially, to create a content watch mechanism on the html. For us I think the above options are probably simpler. However thanks for the idea and taking the effort to share the code. – Euan Smith Feb 12 '19 at 14:57
0

In Vue 3 with script setup syntax, I used the MutationObserver to great success:

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const container = ref();
const mutationObserver = ref(null);
const mockData = ref([]);

const desiredFunc = () => {
    console.log('children changed');
};

const connectMutationObserver = () => {
    mutationObserver.value = new MutationObserver(desiredFunc);

    mutationObserver.value.observe(container.value, {
        attributes: true,
        childList: true,
        characterData: true,
        subtree: true,
    });
};

const disconnectMutationObserver = () => {
    mutationObserver.value.disconnect();
};

onMounted(async () => {
    connectMutationObserver();

    setTimeout(() => { mockData.value = [1, 2, 3]; }, 5000);
});

onUnmounted(() => {
    disconnectMutationObserver();
});
</script>

<template>
    <div ref="container">
        <div v-for="child in mockData" :key="child">
            {{ child }}
        </div>
    </div>
</template>

My example code works better if the v-for is inside a slot that isn't visible to the component. If you are watching the list for changes, you can instead simply put a watcher on the list, such as:

watch(() => mockData.value, desiredFunc);

or if that doesn't work, you can use a deep watcher:

watch(() => mockData.value, desiredFunc, { deep: true });

My main goal is to highlight how to use the MutationObserver.

Read more here: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver

agm1984
  • 15,500
  • 6
  • 89
  • 113
  • thanks @agm1984, nice example for the mutationObserver approach in Vue3! I do find it surprising that this is still getting new answers even thought the question was on Vue v1 (and I think at the time we still had a vue v0.x application) – Euan Smith Sep 28 '22 at 13:54