In Vue.js can a component detect when the slot content changes - javascript

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?

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>

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>

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

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

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

Related

How to watch for external slot changes?

I want to react to external slot changes. In actual HTMLSlotElements I have slotchange events for that but it seems like that's not how that works in Vue 3. How can I watch my slot and react to any new elements being slotted?
This is what I tried:
<script setup lang="ts">
import { useSlots } from 'vue';
const slots = useSlots();
function handleSlotChange() {
console.log('The slotted content has changed to ', slots.foo?.()[0]);
}
</script>
<template>
<div>
<slot name="foo" #slotchange="handleSlotChange"></slot>
</div>
</template>
The slot element doesn't accept events, but you could use #vnodeUpdated event in the element that wraps the slot to watch the changes :
<template>
<div #vnodeUpdated="handleSlotChange">
<slot name="foo"></slot>
</div>
</template>
the vnodeUpdated event handler has the current element as parameter which has dynamicChildren as property which refers to the elements passed as slots.
Or as #matthew-e-brown said in comments try to use the watch with slots :
import { useSlots,watch } from 'vue';
const slots = useSlots();
watch(()=>slots.foo(),(v)=>{
console.log('slots changed')
},{
deep:true
})
Since Vue's slots are not real slots we can't use Vue-internal tools to react to slot changes. Vue's compatibility layer prevents that. Instead, we can use a MutationObserver:
let observer: MutationObserver;
onMounted(() => {
const el = document.querySelector('el-to-observe');
const getSlotContent = () => el.querySelector('[slot]')?.tagName || 'Nothing';
const callback = () => console.log(getSlotContent() + ' was slotted');
observer = new MutationObserver(callback);
observer.observe(el, {
childList: true,
subtree: true,
});
});
onUnmounted(() => observer?.disconnect());

Vue Watcher not working on component created with Vue.extend

I have a Parent component with a select input which is bound through v-model to a variable in data.
Besides, I create child components dynamically using Vue.extend, which i pass the propsData which also includes the value of the select.
This components have a watcher for the prop that is related to the select input.
When i create the component it receives the props succesfully, The problem comes when I update the value of the select input that doesn't trigger the watcher on the child component.
I've been looking for similar situations but have not found something that helps me solve this problem, i don't know why it doesn't trigger the watcher on the child component when the select input changes.
Any help would be very preciated.
Here i create the component dynamically:
let PresupuestoFormularioVue = Vue.extend(PresupuestoFormulario)
let instance = new PresupuestoFormularioVue({
propsData: {
//The prop related to select input
seguro: this.seguro,
}
})
instance.$mount()
this.$refs.formularioContenedor.appendChild(instance.$el)
And this is the watcher in the component which isn't working:
watch:{
seguro:{
handler: function( newVal ){
console.log(newVal)
},
},
},
It's not the watch that doesn't work. It's the bindings. You're assigning the current value of this.seguro, not the reactive object itself. However, a new Vue() can add this binding for you.
As a sidenote, whether PresupuestoFormulario is a Vue.extend() doesn't matter. It can be any valid VueConstructor: a Vue.extend(), Vue.component() or a valid SFC (with name and template): export default {...}.
Here's how to do it:
methods: {
addPresupuestoFormulario() {
const div = document.createElement('div');
this.$el.appendChild(div);
new Vue({
components: { PresupuestoFormulario },
render: h => h("presupuesto-formulario", {
props: {
seguro: this.seguro
}
})
}).$mount(div)
}
}
The <div> initially appended to the parent will get replaced upon mounting with the actual template of PresupuestoFormulario and the bindings will be set, exactly as if you had <presupuesto-formulario :seguro="seguro" /> in the parent template from the start.
The really cool part about it is that the parent component doesn't need to have PresupuestoFormulario declared in its components.
Here's a working example:
const Test = Vue.component('test', {
template: `<div>message: {{message}}</div>`,
props: ['message'],
watch: {
message: console.log
}
})
new Vue({
el: '#app',
data: () => ({
msg: "¯\\_(ツ)_/¯"
}),
methods: {
addComponent() {
const div = document.createElement("div");
this.$el.appendChild(div);
new Vue({
components: {
Test
},
render: h => h("test", {
props: {
message: this.msg
}
})
}).$mount(div);
}
}
})
<script src="https://cdn.jsdelivr.net/npm/vue#2"></script>
<div id="app">
<input v-model="msg">
<button #click="addComponent">Add dynamic child</button>
</div>
A separate note, about using this.$el.appendChild(). While this works when you're using a root Vue instance (a so-called Vue app), it will likely fail when using a normal Vue component, as Vue2 components are limited to having only 1 root element.
It's probably a good idea to have an empty container (e.g: <div ref="container" />) in the parent, and use this.$refs.container.appendChild() instead.
All of props that you want check in watcher, should be a function. If you want read more about this go to vue document codegrepper.
watch: {
// whenever seguro changes, this function will run
seguro: function (newValue, oldValue) {
console.log(newValue,oldValue)
}
}

Wrapping a JS library in a Vue Component

I need to know which is best practice for wrapping a JS DOM library in Vue. As an example I will use EditorJS.
<template>
<div :id="holder"></div>
</template>
import Editor from '#editorjs/editorjs'
export default {
name: "editorjs-wrapper",
props: {
holder: {
type: String,
default: () => {return "vue-editor-js"},
required: true
}
},
data(){
return {
editor: null
};
},
methods: {
init_editor(){
this.editor = new Editor({
holder: this.holder,
tools: {
}
});
}
},
mounted(){
this.init_editor();
},
//... Destroy on destroy
}
First:
Supose I have multiple instances of <editorjs-wrapper> in the same View without a :hook then all intances would have the same id.
<div id="app">
<editorjs-wrapper></editorjs-wrapper>
<editorjs-wrapper></editorjs-wrapper>
</div>
Things get little weird since they both try to process the DOM #vue-editor-js. Would it be better if the component generated a random id as default?
Second:
EditorJS provides a save() method for retrieving its content. Which is better for the parent to be able to call save() method from the EditorJS inside the child?
I have though of two ways:
$emit and watch (Events)
// Parent
<div id="#app">
<editorjs-wrapper #save="save_method" :save="save">
</div>
// Child
...
watch: {
save: {
immediate: true,
handler(new_val, old_val){
if(new_val) this.editor.save().then(save=>this.$emit('save', save)) // By the way I have not tested it it might be that `this` scope is incorrect...
}
}
}
...
That is, the parent triggers save in the child an thus the child emits the save event after calling the method from the EditorJS.
this.$refs.childREF
This way would introduce tight coupling between parent and child components.
Third:
If I want to update the content of the child as parent I don't know how to do it, in other projects I have tried without success with v-modal for two way binding:
export default{
name: example,
props:{
content: String
},
watch:{
content: {
handler(new_val, old_val){
this.update_content(new_val);
}
}
},
data(){
return {
some_js_framework: // Initialized at mount
}
},
methods: {
update_content: function(new_val){
this.some_js_framework.update(new_val)
},
update_parent: function(new_val){
this.$emit('input', this.some_js_framework.get_content());
}
},
mounted(){
this.some_js_framework = new Some_js_framework();
this.onchange(this.update_parent);
}
}
The problem is:
Child content updated
Child emit input event
Parent updates the two-way bidding of v-model
Since the parent updated the value the child watch updates and thus onchange handler is triggered an thus 1. again.

Vue Calling function of the child component when rendering the child component in v-if

I want to call the method of the child component.
I think that the developer uses $nextTick function to process the data after all child component rendered.
But how could I call the method of the child component when rendering by v-if directive.
Here is an example.
var comp = Vue.component('child', {
data:function(){
return {
}
},
template:`
<div class="child">
I'm a child
</div>
`,
methods:{
callFunction:function(){
console.log("I'm called");
}
}
});
var vm = new Vue({
el:'#app',
data:{
if_child:false
},
methods:{
showChild(){
this.if_child = !this.if_child;
//Calling child's function
this.$refs.child.callFunction();
}
}
})
.child{
display:inline-block;
padding:10px;
background:#eaeaea;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<div id="app">
<div v-if="if_child">
<child ref="child" class="child"></child>
</div>
<button type="button" #click="showChild">
Toggle Child
</button>
</div>
When I trying to call the method callFunction() of the child component in showChild() , it throws an error.
Uncaught TypeError: Cannot read property 'callFunction' of undefined
I think that the reason is because it calls the function of the child component before rendering the child component.
How could I solve this issue?
Thanks.
As stated in the question, $nextTick is the solution here.
Vue batches together rendering. When you change reactive data, such as if_child, it won't immediately cause any rendering to happen. Instead the component is added to a list of components that need rendering. Once you've finished making all your data changes Vue will render all the components in the list.
There are two reasons for this. Firstly, rendering is quite expensive. Secondly, if you're in the middle of updating your data then it might be in an inconsistent state that can't be rendered correctly.
The name 'rendering' is a little misleading. It makes it sound a bit like drawing something. However, it also includes things like creating and destroying child components.
The $refs are updated just after a component renders. This all happens at the start of the next tick. To wait for that we use $nextTick.
Vue.component('child', {
template: `
<div class="child">
I'm a child
</div>
`,
methods: {
callFunction () {
console.log("I'm called");
}
}
});
var vm = new Vue({
el: '#app',
data: {
if_child: false
},
methods: {
showChild () {
this.if_child = !this.if_child;
this.$nextTick(() => {
const child = this.$refs.child;
if (child) {
child.callFunction();
}
});
}
}
});
.child {
display: inline-block;
padding: 10px;
background: #eaeaea;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<div id="app">
<div v-if="if_child">
<child ref="child" class="child"></child>
</div>
<button type="button" #click="showChild">
Toggle Child
</button>
</div>
Here's the key section:
showChild () {
this.if_child = !this.if_child;
this.$nextTick(() => {
const child = this.$refs.child;
if (child) {
child.callFunction();
}
});
}
You might wonder why it needs the if (child) {. That's because the button toggles the value of if_child. The method name, showChild, is actually misleading. The first time you click the button the child is created but the next time you click it will be destroyed.
If you don't pass a callback to $nextTick it will return a promise instead. This allows it to be used with async/await if you prefer:
async showChild () {
this.if_child = !this.if_child;
await this.$nextTick();
const child = this.$refs.child;
if (child) {
child.callFunction();
}
}
Why it doesn't work ?
because when your v-if is on the false state your child component doesn't exist. Vue hasn’t created it yet so your ref child is still undefined which means the callFunction won't be executed (undefined)
how about using Vue.nextTick API ?
i tried implementing it on the code (i tried both synchronous and asynchronous syntax) , but it works only on the first attempt then the child ref became undefined again ... it's because the component got destroyed (after if_child turned to false) so ´ref´ will be ´undefined´.
How can i fix this ?
I found two ways that can solve your problem :
1 - by using v-show on your child instead of v-if ... this will make your child always available(always rendered so your ref will be always defined) with a display : none on the false state;
2 - however if you insist on using v-if you can add another variable that will be mutated when the DOM finish rendering (using nextTick API) ... and your child component will watch that variable and execute the function upon that ... here is how you can do it :
Vue.component('child', {
props: ['exe'],
watch: {
exe() {
this.callFunction()
}
},
template: `
<div class="child">
I'm a child
</div>
`,
methods: {
callFunction: function() {
console.log("I'm called");
}
}
});
var vm = new Vue({
el: '#app',
data: {
if_child: false,
executeTheChildFunction: false,
},
methods: {
showChild() {
this.if_child = !this.if_child;
//Calling child's function
this.$nextTick(function() {
this.executeTheChildFunction = !this.executeTheChildFunction;
})
}
}
})
.child {
display: inline-block;
padding: 10px;
background: #eaeaea;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<div id="app">
<div v-if="if_child">
<child id="child" class="child" :exe="executeTheChildFunction"></child>
</div>
<button type="button" #click="showChild">
Toggle Child
</button>
</div>

Vuejs single file component (.vue) model update inside the <script> tag

I'm new to vuejs and I'm trying to build a simple single file component for testing purpose.
This component simply displays a bool and a button that change the bool value.
It also listen for a "customEvent" that also changes the bool value
<template>
{{ mybool }}
<button v-on:click="test">test</button>
</template>
<script>
ipcRenderer.on('customEvent', () => {
console.log('event received');
this.mybool = !this.mybool;
});
export default {
data() {
return {
mybool: true,
};
},
methods: {
test: () => {
console.log(mybool);
mybool = !mybool;
},
},
};
</script>
The button works fine. when I click on it the value changes.
but when I receive my event, the 'event received' is displayed in the console but my bool doesn't change.
Is there a way to access the components data from my code?
Thanks and regards,
Eric
You can move ipcRenderer.on(...) into vuejs's lifecycle hooks like created.
See: https://v2.vuejs.org/v2/guide/instance.html#Instance-Lifecycle-Hooks
You are setting up the event listener outside of the component's options which you export by using
export default{ //... options }
Set up the event listener inside the vue options so the vue instance has control over it, in your case modifying dara property
As choasia suggested move the event listener to `created() life cycle hook:
<template>
{{ mybool }}
<button v-on:click="test">test</button>
</template>
<script>
export default {
data() {
return {
mybool: true,
};
},
methods: {
test: () => {
console.log(mybool);
mybool = !mybool;
},
},
created(){
ipcRenderer.on('customEvent', () => {
console.log('event received');
this.mybool = !this.mybool;
});
}
};
</script>
Now you component will starting listening for that particular even when the component is created

Categories

Resources