Vue binding to external object with SFC - vue.js

I asked a very similar question a few weeks ago, trying to bind a UI control to a web audio AudioParam object in a reactive manner. The only thing I could make work reliably was using a getter/setter on my model object. That wasn't too bad when not using the composition API. Here's that in action:
class MyApp {
constructor() {
// core model which I'd prefer to bind to
this.audio = new AudioContext();
this.audioNode = this.audio.createGain();
this.audioNode.gain.value = .8; // want to bind a control to this
// attempts to add reactivity
this.reactiveWrapper = Vue.reactive(this.audioNode.gain);
this.refWrapper = Vue.ref(this.audioNode.gain.value);
}
get gainValue() { return this.audioNode.gain.value; }
set gainValue(value) { this.audioNode.gain.value = value; }
}
let appModel = new MyApp();
let app = Vue.createApp({
template: '#AppView',
data() { return { model: appModel } }
});
app.mount('#mount');
<script type='text/x-template' id='AppView'>
<div>
model.audioNode.gain.value: {{model.audioNode.gain.value}}
</div>
<hr>
<div>
<div>Binding to getter/setter (works)</div>
<input type='range' min='0' max='1' step='.1' v-model='model.gainValue'>
</div>
<div>
<div>Binding directly to <code>model.audioNode.gain.value</code> (doesn't work)</div>
<input type='range' min='0' max='1' step='.1' v-model='model.audioNode.gain.value'>
</div>
<div>
<div>Binding to <code>reactive(model.audioNode.gain)</code> (doesn't work)</div>
<input type='range' min='0' max='1' step='.1' v-model='model.reactiveWrapper.value'>
</div>
<div>
<div>Binding to <code>ref(model.audioNode.gain.value)</code> (doesn't work)</div>
<input type='range' min='0' max='1' step='.1' v-model='model.refWrapper.value'>
</div>
</script>
<script src="https://unpkg.com/vue#3/dist/vue.global.prod.js"></script>
<div id='mount'></div>
Now I'm trying to use the composition API in a SFC (single file component). I'm having the same issue, but this time the solution requires even more boilerplate. Unfortunately this can't be a runnable snippet, so you'll have to take my word that, exactly as in the example above, trying to use reactive on the AudioParam doesn't work:
<script setup>
import { reactive, computed } from "vue";
let audio = new AudioContext();
let volume = audio.createGain(); // I want to bind to this
// this doesn't work
let reactiveGain = reactive(volume.gain);
// this also doesn't work
const gainComputed = computed({
get: () => volume.gain.value,
set: val => volume.gain.value = val,
})
// This does.
// Is it possible to achieve this effect without all this boilerplate?
// Also, such that I can still use `v-model` in my template?
class Model {
set gain(value) { volume.gain.value = value; }
get gain() { return volume.gain.value; }
}
let model = reactive(new Model());
</script>
<template>
<div>
Volume: {{ model.gain.toFixed(2) }}
</div>
<div>
This works
<input type="range" min="0" max="1" step=".01" v-model="model.gain">
</div>
<div>
This doesn't
<input type="range" min="0" max="1" step=".01" v-model="reactiveGain.value">
</div>
<div>
This doesn't
<input type="range" min="0" max="1" step=".01" v-model="gainComputed">
</div>
</template>
Is there a better way to reactively bind to objects outside of Vue?

The problem with this kind of binding is that it works in one direction, it's possible to update original volume.gain.value on Vue model changes, but it's impossible to update Vue model when volume.gain class instance changes value internally. Given that volume.gain is an instance of AudioParam, value can change based on defined constraints, e.g. minValue. This would require to replace these native classes with extended reactivity-aware classes, this depends on specific classes whether it's possible and how this needs to be done. volume.gain is read-only, in the case of AudioParam replacing gain with fully reactive instance of MyAudioParam would require to extend the whole hierarchy of classes, starting from AudioContext.
Vue 3 provides limited support for the reactivity of classes. reactive transforms own enumerable properties into reactive ones. The rest of members cannot be expected to be affected automatically. reactive can have destructive effect on classes that weren't specifically written with it in mind.
Third-party classes may be implemented in arbitrary ways as long as it fits their normal use, while their use with reactive cannot be considered one, and their ability to provide reactivity needs to be determined by trial and error.
It's evident that native behaviour of volume.gain.value already relies on get/set to make AudioParam react to value changes, so value is not own enumerable property but a descriptor on AudioParam.prototype. The original implementation didn't work because volume.gain.value is not reactive in neither of these cases and its changes cannot cause a view to be updated. reactive shouldn't be used on it because it won't help to make value reactive but can have negative effects on class instance based on its implementation.
A class isn't needed for reactive(new Model()). Vue reactivity was primarily designed to be used with plain objects.
Considering that the binding is one way (model value changes update native value behind it, but not vice versa), it's correct to explicitly define local state and update native value on its changes. If this is a common case, it can be a helper to reduce boilerplate:
let createPropModel = (obj, key) => {
let state = ref(obj[key]);
watch(state, v => { obj[key] = v }, { immediate: true });
return state;
};
let gainRef = createPropModel(volume.gain, 'value');
Alternatively, this can be done with writable computed, but it should involve local state too, and the implementation is less straightforward:
let createPropModel = (obj, key) => {
let state = ref(obj[key]);
return computed({
get: () => state.value,
set: (v) => { state.value = v; obj[key] = v },
});
};
let gainRef = createPropModel(volume.gain, 'value');

OK, the best I've been able to come up with is this. I'll post it as a potential answer to my question, but wait to see if someone has something better before I accept it.
I created a class for wrapping an AudioParam:
import { reactive } from "vue";
export function wrapReactive(obj, field) {
return reactive({
set value(value) { obj[field] = value; },
get value() { return obj[field]; }
});
}
<script setup>
import { wrapReactive } from "./wrapReactive.mjs";
let audio = new AudioContext();
let volume = audio.createGain();
let gain = wrapReactive(volume.gain, 'value');
</script>
<template>
<div>
Volume: {{ gain.value.toFixed(2) }}
</div>
<div>
<input type="range" min="0" max="1" step=".01" v-model="gain.value">
</div>
</template>

Related

Adding Props to found components throw the mounted wrapper

I have a form that contains a selector reusable component like this
<template>
<div class="channelDetail" data-test="channelDetail">
<div class="row">
<BaseTypography class="label">{{ t('channel.detail.service') }}</BaseTypography>
<BaseSelector
v-model="serviceId"
data-test="serviceInput"
class="content"
:option="servicePicker.data?.data"
:class="serviceIdErrorMessage && 'input-error'"
/>
</div>
<div class="row">
<BaseTypography class="label">{{ t('channel.detail.title') }}</BaseTypography>
<BaseInput v-model="title" data-test="titleInput" class="content" :class="titleErrorMessage && 'input-error'" />
</div>
</div>
</template>
I'm going to test this form by using vue-test-utils and vitest.
I need to set option props from the script to the selector.
In my thought, this should be worked but not
it('test', async () => {
const wrapper=mount(MyForm,{})
wrapper.findComponent(BaseSelector).setProps({option:[...some options]})
---or
wrapper.find('[data-test="serviceInput"]').setProps({option:[...some options]})
---or ???
});
Could anyone help me to set the props into components in the mounted wrapper component?
The answer is that you should not do that. Because BaseSelector should have it's own tests in which you should test behavior changes through the setProps.
But if you can't do this for some reason, here what you can do:
Check the props passed to BaseSelector. They always depend on some reactive data (props, data, or computed)
Change those data in MyForm instead.
For example
// MyForm.vue
data() {
return {
servicePicker: {data: null}
}
}
// test.js
wrapper = mount(MyForm)
wrapper.setData({servicePicker: {data: [...some data]})
expect(wrapper.findComponent(BaseSelector)).toDoSomething()
But I suggest you to cover the behavior of BaseSelector in separate test by changing it's props or data. And then in the MyForm's test you should just check the passed props to BaseSelector
expect(wrapper.findComponent(BaseSelector).props('options')).toEqual(expected)

Vue.js 2: How to bind to a component method?

I have a VueJS (v2) component with a private array of objects this.private.messagesReceived which I want displayed in a textarea. The array should be converted to a string by a method/function and Vue is blocking all my attempts to bind. Every attempt results in my serialization function (converting the array to a string) only being called once and never again when the data changes.
I feel there must be a way to do this without Vue.set() or some forceUpdate shenanigans.
https://jsfiddle.net/hdme34ca/
Attempt 1: Computed Methods
Here we have the problem that Vue only calls my computed method messagesReceived1 once and never again.
<script>
{
computed: {
messagesReceived1() {
console.log("This is called once and never again even when new messages arrive");
return this.private.messagesReceived.join("\n");
},
...
methods: {
addMessage(m) {
console.log("This is called multiple times, adding messages successfully");
this.private.messagesReceived.push(m);
}
}
<script>
<template>
<textarea rows="10" cols="40" v-model="messagesReceived1"></textarea>
</template
Attempt 2: Binding Methods
Here Vue decides it doesn't like moustaches inside a textarea {{ messagesReceived2() }} and balks. It also doesn't allow messagesReceived2() or messagesReceived2 in v-model.
<script>
{
methods: {
messagesReceived2() {
return this.private.messagesReceived.join("\n");
},
addMessage(m) {
console.log("This is called multiple times, adding messages successfully");
this.private.messagesReceived.push(m);
}
}
</script>
<template>
<textarea rows="10" cols="40">{{ messagesReceived2() }}</textarea><!--Nope-->
<textarea rows="10" cols="40" v-model="messagesReceived2()"></textarea><!--Nope-->
<textarea rows="10" cols="40" v-model="messagesReceived2"></textarea><!--Nope-->
</template
You can define a data variable and set its value in the function. Then bind variable with textarea, not directly with the function.

Vue.js this.$refs empty due to v-if

I have a simple Vue component that displays an address, but converts into a form to edit the address if the user clicks a button. The address field is an autocomplete using Google Maps API. Because the field is hidden (actually nonexistent) half the time, I have to re-instantiate the autocomplete each time the field is shown.
<template>
<div>
<div v-if="editing">
<div><input ref="autocomplete" v-model="address"></div>
<button #click="save">Save</button>
</div>
<div v-else>
<p>{{ address }}</p>
<button #click="edit">Edit</button>
</div>
</div>
</template>
<script>
export default {
data() {
editing: false,
address: ""
},
methods: {
edit() {
this.editing = true;
this.initAutocomplete();
},
save() {
this.editing = false;
}
initAutocomplete() {
this.autocomplete = new google.maps.places.Autocomplete(this.$refs.autocomplete, {});
}
},
mounted() {
this.initAutocomplete();
}
}
I was getting errors that the autocomplete reference was not a valid HTMLInputElement, and when I did console.log(this.$refs) it only produced {} even though the input field was clearly present on screen. I then realized it was trying to reference a nonexistent field, so I then tried to confine the autocomplete init to only when the input field should be visible via v-if. Even with this, initAutocomplete() is still giving errors trying to reference a nonexistent field.
How can I ensure that the reference exists first?
Maybe a solution would be to use $nextTick which will wait for your DOM to rerender.
So your code would look like :
edit() {
this.editing = true;
this.$nextTick(() => { this.initAutocomplete(); });
},
Moreover if you try to use your this.initAutocomplete(); during mounting it cannot work since the $refs.autocomplete is not existing yet but I'm not sure you need it since your v-model is already empty.
I think it's because your "refs" is plural
<input refs="autocomplete" v-model="address">
It should be:
<input ref="autocomplete" v-model="address">

What's the difference between this.example and this.$data.example to access "data" in vue.js

In vue.js, what's the difference between accessing data via this.example and this.$data.example?
What are the pros and cons for each approach, if any?
Here's an example using both.
JS:
new Vue({
el: '#app',
data() {
return {
demo1: 'Test1',
demo2: 'Test2',
}
},
computed: {
comp1() {
return `value: ${this.demo1}`
},
comp2() {
return `value: ${this.$data.demo2}`
}
},
});
<div id="app">
<fieldset>
<input type="text" v-model="demo1">
<p>Result = <span v-html="demo1"></p>
<p>Computed = <span v-html="comp1"></p>
</fieldset>
<fieldset>
<input type="text" v-model="$data.demo2">
<p>Result = <span v-html="$data.demo2"></span></p>
<p>Computed = <span v-html="comp2"></p>
</fieldset>
</div>
The vue instance api has many properties that start with $ that can be used in certain circumstances. For the $data property it might be useful to loop all data that exists on a particular component or maybe to send all of a components data to another component or api (think of a form component where each field is bound to a data property). For most use cases though it's more common to access data properties directly on the vue instance itself using this.myDataProperty. If you need to access a single property there is no benefit to using this.$data to access it though I'm not aware of any downside of doing so. Here's some additional reading about the vue instance and data properties in general from the Vue docs.
https://v2.vuejs.org/v2/guide/instance.html#Data-and-Methods
https://v2.vuejs.org/v2/api/#vm-data

Why is Vue changing the parent value from a child without emitting an event

I am fairly new to Vue but doesn't this behavior completely contradict the design of props down, events up?
I have managed to stop it by using Object.assign({}, this.test_object ); when initializing the value in child-component but shouldn't that be the default behaviour?
Here is some background.
I am trying to have a dirty state in a much larger application (Eg a value has changed so a user must save the data back to the database before continuing on their way)
I had an event being emitted, and caught by the parent but the code I had to test the value and init the dirty state was not running as the value had already been changed in the parent component.
Vue.component( 'parent-component', {
template: '#parent-component',
data: function() {
return {
testObject: {
val: 'Test Value'
}
}
}
});
Vue.component( 'child-component', {
template: '#child-component',
props: {
test_object: Object
},
data: function() {
return {
child_object: this.test_object
}
}
});
Vue.config.productionTip = false;
new Vue({
el: '#app',
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<script type="text/x-template" id="parent-component">
<div>
<child-component :test_object="testObject"></child-component>
<p>This is in the parent component</p>
<p><code>testObject.val = {{testObject.val}}</code></p>
</div>
</script>
<script type="text/x-template" id="child-component">
<div>
<label for="html_input">HTML Input</label>
<input style="border:1px solid #CCC; display:block;" type="text" name="html_input" v-model="child_object.val" />
</div>
</script>
<div id="app">
<parent-component></parent-component>
</div>
Use of v-model is a very deceptive thing. If you are not careful, you might end-up mutating data that doesn't belong to your component. In your case, you are accidentally passing read-only props directly to the v-model. It doesn't know if it is a prop or a local component state.
What you are doing is the right solution but considering one-way/unidirectional data flow in mind, we can rewrite this example in more explicit and elegant fashion:
Your component definition would be:
Vue.component( 'parent-component', {
template: '#parent-component',
data: function() {
return {
testObject: {
val: 'Test Value'
}
}
},
methods: {
// Added this method to listen for input event changes
onChange(newValue) {
this.testObject.val = newValue;
// Or if you favor immutability
// this.testObject = {
// ...this.testObject,
// val: newValue
// };
}
}
});
Your templates should be:
<script type="text/x-template" id="parent-component">
<div>
<child-component :test_object="testObject"
#inputChange="onChange"></child-component>
<p>This is in the parent component</p>
<p><code>testObject.val = {{testObject.val}}</code></p>
</div>
</script>
<!-- Instead of v-model, you can use :value and #input binding. -->
<script type="text/x-template" id="child-component">
<div>
<label for="html_input">HTML Input</label>
<input type="text" name="html_input"
:value="test_object.val"
#input="$emit('inputChange', $event.target.value)" />
</div>
</script>
Key things to note:
When using v-model, ensure that you are strictly working on a local value/data of the component. By no means, it should be referenced copy of external prop.
A custom form-like component can be readily converted into the one that can work with v-model provided you accept current value as :value prop and event as #input. v-model will just work out of the box.
Any modification to the value should happen in the same component.