I would like to bind the properties of a Vue instance that is manually mounted to several DOM elements (mapbox markers, in this case):
const makerContent = Vue.extend(Marker)
features.forEach(function(feature, index) {
var parent = document.createElement('div')
parent.classList.add("mapboxgl-marker")
var child = document.createElement('div')
child.classList.add("marker")
parent.appendChild(child)
const marker = new mapboxgl.Marker(parent)
.setLngLat(feature.geometry.coordinates)
.addTo(self.map)
new makerContent({
store: store,
propsData: {
feature: feature,
}
}).$mount(child);
})
Changes to the store (eg: feature.geometry.coordinates) do not trigger changes on the Marker component without a reload.
How can I bind the features property to each marker, so the markers react to data changes? Probably not the right use of propsData. Perhaps a shared Vuex store module? I think I need to track the changes of ES6 map state, which Vue does not currently support
See my Mappy.vue component on GitHub
Related
I have a reactive around a map that's initially empty: const map = reactive({});, and a computed that tells if the map has a key "key": const mapContainsKeyComputed = computed(() => map.hasOwnProperty("key")). The computed doesn't get updated when I change the map.
I stuck with this issue for a day and managed to come up with a minimum example that demonstrates the issue:
<script setup>
import {computed, reactive, ref, watch} from "vue";
const map = reactive({});
const key = "key";
const mapContainsKeyComputed = computed(() => map.hasOwnProperty(key))
const mapContainsKeyWatched = ref(map.hasOwnProperty(key));
watch(map, () => mapContainsKeyWatched.value = map.hasOwnProperty(key))
</script>
<template>
Map: {{map}}
<br/>
Computed: does map contain "key"? {{mapContainsKeyComputed}}
<br/>
Watch: does map contain key? {{mapContainsKeyWatched}}
<br/>
<button #click="map[key] = 'value'">add key-value</button>
</template>
I've read a bunch of stackoverflow answers and the Vue docs, but I still can't figure it out.
why mapContainsKeyComputed doesn't get updated?
if the reactive doesn't "track" adding or removing keys to the map, why the Map: {{map}} (line 14) updates perfectly fine?
when I replace the map{} with an array[] and "hasOwnProperty" with "includes()", it works fine. How's that different?
how do I overcome this issue without the ugly "watch" solution where the "map.hasOwnProperty(key)" has to be duplicated?
EDIT: as mentioned by #estus-flask, this was a VueJS bug fixed in 3.2.46.
Vue reactivity needs to explicitly support reactive object methods. hasOwnProperty is rather low-level so it hasn't been supported for some time. Without the support, map.hasOwnProperty(key) tries to access key on non-reactive raw object and doesn't trigger the reactivity, so the first computed call doesn't set a listener that could be triggered with the next map change.
One way this could be fixed is to either define key initially (as suggested in another answer), this is the legacy way to make reactivity work in both Vue 2 and 3:
const map = reactive({ key: undefined })
Another way is to access missing key property on reactive object:
const mapContainsKeyComputed = computed(() => map[key] !== undefined)
Yet another way is to use in operator. Since Vue 3 uses Proxy for reactivity, that a property is accessed can be detected by has trap:
const mapContainsKeyComputed = computed(() => key in map)
The support for hasOwnProperty has been recently added in 3.2.46, so the code from the question is supposed to be workable with the latest Vue version.
map is not really a map. This would be different in any Vue 3 version if Map were used, it's supported by Vue and it's expected that map.has(key) would trigger reactivity.
In the documents, the root element is created as follows:
const RootComponent = {
/* options */
}
const app = Vue.createApp(RootComponent)
const vm = app.mount('#app')
However, soon I realized createApp() can be left blank:
const app = Vue.createApp({})
app.component('component-a', {
/* ... */
})
app.mount('#app')
In the second example, what is the root element? A default one?
A side question, I understand it is better to use props and $emit to pass data around, but is there any way to directly access a component through its $data?
Root Component is always app instance.
RootComponent variabile contains global options (property) for app Component. (Component Instance Properties)
About the side question, You should consider if the component handle application data (shared between different component inside your page) or local data between the component and it's parent.
In the first case is better to use a State Management Library as Vuex, otherwise you should use $emit to change parent data passed via props to the child component.
I created the child using:
const ComponentClass = Vue.extend(someComponent);
const instance = new ComponentClass({
propsData: { prop: this.value }
})
instance.$mount();
this.$refs.container.appendChild(instance.$el);
When this.value is updated in the parent, its value doesn't change in the child. I've tried to watch it but it didn't work.
Update:
There's an easier way to achieve this:
create a <div>
append it to your $refs.container
create a new Vue instance and .$mount() it in the div
set the div instance's data to whatever you want to bind dynamically, getting values from the parent
provide the props to the mounted component from the div's data, through render function
methods: {
addComponent() {
const div = document.createElement("div");
this.$refs.container.appendChild(div);
new Vue({
components: { Test },
data: this.$data,
render: h => h("test", {
props: {
message: this.msg
}
})
}).$mount(div);
}
}
Important note: this in this.$data refers the parent (the component which has the addComponent method), while this inside render refers new Vue()'s instance. So, the chain of reactivity is: parent.$data > new Vue().$data > new Vue().render => Test.props. I had numerous attempts at bypassing the new Vue() step and passing a Test component directly, but haven't found a way yet. I'm pretty sure it's possible, though, although the solution above achieves it in practice, because the <div> in which new Vue() renders gets replaced by its template, which is the Test component. So, in practice, Test is a direct ancestor of $refs.container. But in reality, it passes through an extra instance of Vue, used for binding.
Obviously, if you don't want to add a new child component to the container each time the method is called, you can ditch the div placeholder and simply .$mount(this.$refs.container), but by doing so you will replace the existing child each subsequent time you call the method.
See it working here: https://codesandbox.io/s/nifty-dhawan-9ed2l?file=/src/components/HelloWorld.vue
However, unlike the method below, you can't override data props of the child with values from parent dynamically. But, if you think about it, that's the way data should work, so just use props for whatever you want bound.
Initial answer:
Here's a function I've used over multiple projects, mostly for creating programmatic components for mapbox popups and markers, but also useful for creating components without actually adding them to DOM, for various purposes.
import Vue from "vue";
// import store from "./store";
export function addProgrammaticComponent(parent, component, dataFn, componentOptions) {
const ComponentClass = Vue.extend(component);
const initData = dataFn() || {};
const data = {};
const propsData = {};
const propKeys = Object.keys(ComponentClass.options.props || {});
Object.keys(initData).forEach(key => {
if (propKeys.includes(key)) {
propsData[key] = initData[key];
} else {
data[key] = initData[key];
}
});
const instance = new ComponentClass({
// store,
data,
propsData,
...componentOptions
});
instance.$mount(document.createElement("div"));
const dataSetter = data => {
Object.keys(data).forEach(key => {
instance[key] = data[key];
});
};
const unwatch = parent.$watch(dataFn || {}, dataSetter);
return {
instance,
update: () => dataSetter(dataFn ? dataFn() : {}),
dispose: () => {
unwatch();
instance.$destroy();
}
};
}
componentOptions is to provide any custom (one-off) functionality to the new instance (i.e.: mounted(), watchers, computed, store, you name it...).
I've set up a demo here: https://codesandbox.io/s/gifted-mestorf-297xx?file=/src/components/HelloWorld.vue
Notice I'm not doing the appendChild in the function purposefully, as in some cases I want to use the instance without adding it to DOM. The regular usage is:
const component = addProgrammaticComponent(this, SomeComponent, dataFn);
this.$el.appendChild(component.instance.$el);
Depending on what your dynamic component does, you might want to call .dispose() on it in parent's beforeDestroy(). If you don't, beforeDestroy() on child never gets called.
Probably the coolest part about it all is you don't actually need to append the child to the parent's DOM (it can be placed anywhere in DOM and the child will still respond to any changes of the parent, like it would if it was an actual descendant). Their "link" is programmatic, through dataFn.
Obviously, this opens the door to a bunch of potential problems, especially around destroying the parent without destroying the child. So you need be very careful and thorough about this type of cleanup. You either register each dynamic component into a property of the parent and .dispose() all of them in the parent's beforeDestroy() or give them a particular selector and sweep the entire DOM clean before destroying the parent.
Another interesting note is that in Vue 3 all of the above will no longer be necessary, as most of the core Vue functionality (reactivity, computed, hooks, listeners) is now exposed and reusable as is, so you won't have to $mount a component in order to have access to its "magic".
How to store the state value of a variable?
For example, I have three components: home.vue and map.vue, as well as a common sidebar.vue component, which is used inside home.vue and map.vue.
I pass some data from sidebar.vue (a variable storing sidebar width state). And this data should change dynamically in both components. That is, when switching from home.vue to map.vue, the state of the variable for changing the width of sidebar.vue should not change.
You can store the width of the sidebar in Vuex store.
Vuex is a state management pattern + library for Vue.js applications. It serves as a centralized store for all the components in an application, with rules ensuring that the state can only be mutated in a predictable fashion.
Create the store:
const store = new Vuex.Store({
state: {
width: 50
},
mutations: {
setWidth (state, value) {
state.width = value
}
}
})
"Inject" the store into all child components from the root component with the store option (enabled by Vue.use(Vuex)):
const app = new Vue({
el: '#app',
// provide the store using the "store" option.
// this will inject the store instance to all child components.
store,
...
})
By providing the store option to the root instance, the store will be injected into all child components of the root and will be available on them as this.$store. When the width is changed update it by calling mutation method in the methods of your components:
this.$store.commit('setWidth', value)
To read the value of the width from the store in any method of your vue-components use this:
this.$store.state.width
When extending a Vue component to a modal, a new Vue instance is generated. This leads to losing all pre-imported helpers/ Vuex-stores etc.
Is there a way how to copy all data to the new extended Vue component?
To make it clear, have a look at this jsfiddle.
It confronts you with an alert. When you click on the button, the modal is opened. But if you look at my code, another alert should be fired. But $hi() is not available in the other Vue instance.
I create a component like this:
const createComponent = (props) => {
const wrapper = document.createElement('div');
const Component = Vue.extend(MyComponent);
return new Component({
propsData: {
props,
},
}).$mount(wrapper);
};
I'm using SweetAlert as a modal.
So I add this like:
swal({
content: createComponent(props).$el,
})
If you move the line
Vue.prototype.$hi = () => alert('hi');
above
let component = new TestComponent().$mount(wrapper)
it will work. Your first component references $hi() before it's defined.