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:
- Create my own version of
ref(no clueVue.refdoesn't work) or Proxythe object and call$forceUpdatewhen 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>