Home > other >  Vue binding to external object with SFC
Vue binding to external object with SFC

Time:09-02

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?

CodePudding user response:

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');

CodePudding user response:

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