Reputation: 28991
I'm trying to use Vue as a very thin layer to bind existing model objects to a view.
Below is a toy app illustrating my problem. I have a GainNode object, from the Web Audio API. I want to bind its value
to a slider.
This is trivial in Angular. Two-way binding works with any object, whether part of an Angular component or not. Is there a way to do something similar in Vue?
In the real app, I have big lists of programmatically generated objects. I need to bind them to a component, e.g. <Knob v-for='channel in channels' v-model='channel.gainNode.gain.value'>
.
UPDATE: I was using Workaround #2 (below), and it appeared to be working great, until I tried v-model
-binding two components to the same audio parameter. Then it just didn't work in, in totally mysterious ways that I couldn't debug. I eventually gave up and am using getters/setters, which is more boilerplate but has the advantage of, ya know.. actually working.
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 {
// core model which I'd prefer to bind to
model: appModel,
// attempt to add reactivity
dataAliasAudioNode: appModel.audioNode }
}
});
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 for <code>model.audioNode.gain.value</code> (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 through <code>model.reactiveWrapper</code> (doesn't work)</div>
<input type='range' min='0' max='1' step='.1' v-model='model.reactiveWrapper.value'>
</div>
<div>
<div>Binding through <code>model.refWrapper</code> (doesn't work)</div>
<input type='range' min='0' max='1' step='.1' v-model='model.refWrapper.value'>
</div>
<div>
<div>Binding through <code>dataAliasAudioNode.gain.value</code> (doesn't work)</div>
<input type='range' min='0' max='1' step='.1' v-model='dataAliasAudioNode.gain.value'>
</div>
</script>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<div id='mount'></div>
Question Addendum #1: In exploring ways to do this, I've discovered (as already noted) that if I bind to a nested portion of a foreign object (GainNode
the Web Audio API) it's not reactive, but if I build a similar foreign object myself, binding to a nested parameter is reactive. Here's example code:
// my version Web Audio API's AudioContext, GainNode, and AudioParam
class AudioParamX {
constructor() {
this._value = 0;
}
get value() { return this._value; }
set value(v) { this._value = v; }
}
class ValueParamX extends AudioParamX {
}
class GainNodeX {
constructor() {
this.gain = new ValueParamX();
}
}
class AudioContextX {
createGain() {
return new GainNodeX();
}
}
//==================================================================
class MyApp {
constructor() {
this.audio = new AudioContext();
this.audioNode = this.audio.createGain();
this.xaudio = new AudioContextX();
this.xaudioNode = this.xaudio.createGain();
}
}
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.xaudioNode.gain.value: {{model.xaudioNode.gain.value}}
</div>
<div>
model.audioNode.gain.value: {{model.audioNode.gain.value}}
</div>
<hr>
<div>
<div>Binding to <code>model.xaudioNode.gain.value</code> works.</div>
<input type='range' min='0' max='1' step='.05' v-model='model.xaudioNode.gain.value'>
</div>
<div>
<div>Binding to <code>model.audioNode.gain.value</code> doesn't. Why?</div>
<input type='range' min='0' max='1' step='.05' v-model='model.audioNode.gain.value'>
</div>
</script>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<div id='mount'></div>
Workaround #1:
So after more exploration, I've come up with one workaround which reduces the boilerplate of a getter/setter. I either:
ref
(no clue Vue.ref
doesn't work) orProxy
the object and call $forceUpdate
when the setter is called.Both of these work, with the disadvantage that I then must expose the proxy as a member and bind to it rather than the original object. But it's better than exposing both a getter and setter and it works with v-model
.
class MyApp {
createWrapper(obj, field) {
return {
get [field]() { return obj[field]; },
set [field](v) { obj[field] = v; }
}
}
createProxy(obj) {
let update = () => this.forceUpdate();
return new Proxy(obj, {
get(target, prop) { return target[prop] },
set(target, prop, value) {
update();
return target[prop] = value
}
});
}
watch(obj, prop) {
hookSetter(obj, prop, () => this.forceUpdate());
}
constructor() {
this.audio = new AudioContext();
// core model which I'd prefer to bind to
this.audioNode = this.audio.createGain();
this.audioNode.gain.value = .1; // want to bind a control to this
this.audioNode.connect(this.audio.destination);
// attempts to add reactivity
this.wrapper = this.createWrapper(this.audioNode.gain, 'value');
this.proxy = this.createProxy(this.audioNode.gain);
}
}
let appModel = new MyApp();
let app = Vue.createApp({
template: '<AppView :model="model" />',
data() { return { model: appModel } },
});
app.component('AppView', {
template: '#AppView',
props: ['model'],
mounted() {
this.model.forceUpdate = () => this.$forceUpdate();
}
})
app.mount('#mount');
<style>body { user-select: none; }</style>
<script type='text/x-template' id='AppView'>
<div>
<div>model.audioNode.gain.value: {{model.audioNode.gain.value}}</div>
<div>model.wrapper.value: {{model.wrapper.value}}</div>
<div>model.proxy.value: {{model.wrapper.value}}</div>
</div>
<hr>
<div>
<div>Binding directly to <code>model.audioNode.gain.value</code> (doesn't work)</div>
<input type='range' min='0' max='1' step='.05' v-model='model.audioNode.gain.value'>
</div>
<div>
<div>Binding through <code>model.wrapper.value</code> (works)</div>
<input type='range' min='0' max='1' step='.05' v-model='model.wrapper.value'>
</div>
<div>
<div>Binding through <code>model.proxy.value</code> (works)</div>
<input type='range' min='0' max='1' step='.05' v-model='model.proxy.value'>
</div>
</script>
<div id='mount'></div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
Workaround #2:
Another workaround is to patch the accessor I want to watch and call $forceUpdate
in it. This has the least boilerplate. I just call watch(obj, prop)
and that property becomes reactive.
This is a fairly acceptable workaround, to my taste. However, I'm not sure how well these workaround schemes will work when I start moving things into child components. I'm going to try that next. I also still don't understand why Vue.reference
doesn't do the same thing.
I'd like to do this in the most Vue native way possible, and it seems like it would be a pretty typical use case.
class MyApp {
watch(obj, prop) {
hookObjectSetter(obj, prop, () => this.forceUpdate());
}
constructor() {
this.audio = new AudioContext();
// core model which I'd prefer to bind to
this.audioNode = this.audio.createGain();
this.audioNode.gain.value = .1; // want to bind a control to this
this.watch(this.audioNode.gain, 'value'); // make it reactive
}
}
let appModel = new MyApp();
let app = Vue.createApp({
template: '<AppView :model="model" />',
data() { return { model: appModel } },
});
app.component('AppView', {
template: '#AppView',
props: ['model'],
mounted() {
this.model.forceUpdate = () => this.$forceUpdate();
}
})
app.mount('#mount');
function hookObjectSetter(obj, prop, callback) {
let descriptor = Object.getOwnPropertyDescriptor(obj, prop);
if (!descriptor) {
obj = Object.getPrototypeOf(obj);
descriptor = Object.getOwnPropertyDescriptor(obj, prop);
}
if (descriptor && descriptor.configurable) {
let set = descriptor.set || (v => descriptor.value = v);
let get = descriptor.get || (v => descriptor.value);
Object.defineProperty(obj, prop, {
configurable: false, // prevent double-hooking; sorry anybody else!
get,
set(v) {
callback();
return set.apply(this, arguments);
},
});
}
}
<script type='text/x-template' id='AppView'>
<div>
<div>model.audioNode.gain.value: {{model.audioNode.gain.value}}</div>
</div>
<hr>
<div>
<div>Binding directly to <code>model.audioNode.gain.value</code> with custom `watch` (works)</div>
<input type='range' min='0' max='1' step='.05' v-model='model.audioNode.gain.value'>
</div>
</script>
<div id='mount'></div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
Upvotes: 4
Views: 775
Reputation: 8726
Your question is exciting so I decided to spend hours figuring out the answer.
First, we need to know What happens when Vue renders a value on the template? Let's consider this template:
{{ model.audioNode.gain.value }}
If model
is a reactive object (which is created by reactive
, ref
, or computed
...), Vue will create a getter that converts each object on the chain into reactive. So, these following objects will be converted into the reactive form using the Vue.reactive
function: model.audioNode
, model.audioNode.gain
But just some types that can be converted to a reactive object. Here is the code from Vue reactive package
function targetTypeMap(rawType: string) {
switch (rawType) {
case 'Object':
case 'Array':
return TargetType.COMMON
case 'Map':
case 'Set':
case 'WeakMap':
case 'WeakSet':
return TargetType.COLLECTION
default:
return TargetType.INVALID
}
}
As we can see, types that other than Object
, Array
, Map
, Set
, WeakMap
, and WeakSet
will be invalid. To know what type of your object, you can call yourObject.toString()
(what Vue is actually using). Any custom class that does not modify the toString
method will be Object
type and can be made reactive. In your example code model
is object
type, model.audioNode
is type GainNode
. So it can NOT be converted to a reactive object and mutating its property will not trigger Vue re-render.
So Why the setter method works?
It actually does NOT work. Let's consider this snippet:
class MyApp {
constructor() {
this.audio = new AudioContext();
this.audioNode = this.audio.createGain();
this.audioNode.gain.value = .8;
}
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 for <code>model.gainValue</code> (does NOT work)</div>
<input type='range' min='0' max='1' step='.1' :value="model.audioNode.gain.value" @input="model.gainValue=$event.target.value">
</div>
</script>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<div id='mount'></div>
The setter in the snippet above does NOT work. Let's consider another snippet:
class MyApp {
constructor() {
this.audio = new AudioContext();
this.audioNode = this.audio.createGain();
this.audioNode.gain.value = .8;
}
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 for <code>model.gainValue</code> (does work)</div>
<input type='range' min='0' max='1' step='.1' :value="model.gainValue" @input="model.gainValue=$event.target.value">
</div>
</script>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<div id='mount'></div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<div id='mount'></div>
The setter in the snippet above does work. Take a look at that line <input type='range' min='0' max='1' step='.1' :value="model.gainValue" @input="model.gainValue=$event.target.value">
It is actually what happened when you using v-model="model.gainValue"
. The reason it works is the line :value="model.gainValue"
will trigger Vue re-render anytime model.gainValue
is updated. And Vue is not fully selective re-render. So when the whole template is re-render the block {{ model.audioNode.gain.value }}
will be re-rendered too.
To prove that Vue is not fully selective re-render, let's consider this snippet:
class MyApp {
constructor() {
this.audio = new AudioContext();
this.audioNode = this.audio.createGain();
this.audioNode.gain.value = .8;
}
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,
anIndependentProperty: 1
}
},
methods: {
update(event){
this.model.audioNode.gain.value = event.target.value
this.anIndependentProperty = event.target.value
}
}
});
app.mount('#mount');
<script type='text/x-template' id='AppView'>
<div>
model.audioNode.gain.value: {{model.audioNode.gain.value}}
</div>
<div>
anIndependentProperty: {{anIndependentProperty}}
</div>
<hr>
<div>
<div>anIndependentProperty trigger re-render so the template will be updated</div>
<input type='range' min='0' max='1' step='.1' :value="model.audioNode.gain.value" @input="update">
</div>
</script>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<div id='mount'></div>
In the example above the anIndependentProperty
is reactive and it will trigger Vue re-render anytime it is updated. When Vue re-render the template the block {{model.audioNode.gain.value}}
will be update too.
This solution only works for the case using properties in the template. If you want to use the computed
from your class properties, you have to roll with setter/getter method.
class MyApp {
constructor() {
this.audio = new AudioContext();
this.audioNode = this.audio.createGain();
this.audioNode.gain.value = .8;
}
}
let appModel = new MyApp();
let app = Vue.createApp({
template: '#AppView',
data() {
return {
model: appModel,
reactiveControl: 0
}
},
});
app.mount('#mount');
<script type='text/x-template' id='AppView'>
<input type="hidden" :value="reactiveControl">
<div>
<div>Binding to <code>model.audioNode.gain.value (works):</code> {{model.audioNode.gain.value}} </div>
<input type='range' min='0' max='1' step='.1' :value="model.audioNode.gain.value" @input="model.audioNode.gain.value=$event.target.value; reactiveControl++">
</div>
<div>
<div>Binding to other property <code>model.audioNode.channelCount (works):</code> {{model.audioNode.channelCount}}</div>
<input type='range' min='1' max='32' step='1' :value="model.audioNode.channelCount" @input="model.audioNode.channelCount=$event.target.value; reactiveControl++">
</div>
You can bind to any property now...
</script>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<div id='mount'></div>
Please notice this line:
<input type="hidden" :value="reactiveControl">
Whenever the reactiveControl
variable changes, the template will be updated and other variables will be updated too. So you just need to change the value of reactiveControl
whenever you update your class properties.
Upvotes: 2