Vue 绑定到外部对象

2024-04-22

我正在尝试使用 Vue 作为一个非常薄的层来将现有模型对象绑定到视图。

下面是一个玩具应用程序,说明了我的问题。我有一个GainNode https://developer.mozilla.org/en-US/docs/Web/API/GainNode对象,来自网络音频 API。我想绑定它value到滑块。

这在 Angular 中是微不足道的。双向绑定适用于任何对象,无论是否是 Angular 组件的一部分。有没有办法在 Vue 中做类似的事情?

在真实的应用程序中,我有大量以编程方式生成的对象列表。我需要将它们绑定到一个组件,例如<Knob v-for='channel in channels' v-model='channel.gainNode.gain.value'>.

更新:我正在使用解决方法#2(如下),它似乎工作得很好,直到我尝试v-model-将两个组件绑定到相同的音频参数。然后它就不起作用了,以完全神秘的方式我无法调试。我最终放弃了并使用 getters/setters,这更像是样板文件,但优点是,你知道......实际工作。

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>

问题附录#1:在探索执行此操作的方法时,我发现(如前所述)如果我绑定到外来对象的嵌套部分(GainNodeWeb Audio API)它不是反应式的,但如果我自己构建一个类似的外部对象,绑定到嵌套参数is反应性的。这是示例代码:

// 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>

解决方法#1:

因此,经过更多的探索,我想出了一种解决方法,可以减少 getter/setter 的样板。我要么:

  1. 创建我自己的版本ref(没有线索Vue.ref不起作用)或
  2. Proxy对象和调用$forceUpdate当 setter 被调用时。

这两种方法都有效,缺点是我必须将代理公开为成员并绑定到it而不是原来的对象。但这比同时暴露 getter 和 setter 更好,并且它可以与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>

解决方法#2:

另一个解决方法是修补我想要观看和调用的访问器$forceUpdate在里面。这有least样板文件。我只是打电话watch(obj, prop)并且该属性变得具有反应性。

根据我的口味,这是一个相当可以接受的解决方法。但是,当我开始将内容移至子组件中时,我不确定这些解决方案的效果如何。接下来我要尝试一下。我也还是不明白为什么Vue.reference不做同样的事情。

我想以最 Vue 原生的方式做到这一点,这似乎是一个非常典型的用例。

    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>

你的问题很令人兴奋,所以我决定花几个小时来找出答案。

TLDR

  • 内置对象(由浏览器 API 创建的对象)无法转换为响应式形式,因此改变其属性不会触发重新渲染
  • Vue 不是完全选择性的重新渲染,因此当它重新渲染模板时,一些甚至不具有反应性的块也会被更新。

让我们总结一下这个问题:

  • 当改变 Web Audio API 对象的属性时,反应式不起作用(实际上任何内置对象都是相同的)
  • 当在 setter 中改变相同的属性时,反应式确实起作用

解释

首先,我们需要知道当 Vue 在模板上渲染值时会发生什么?让我们考虑一下这个模板:

{{ model.audioNode.gain.value }}

If model是一个响应式对象(它是由reactive, ref, or computed...),Vue 将创建一个 getter,将链上的每个对象转换为响应式对象。因此,以下这些对象将使用以下方法转换为反应形式:Vue.reactive功能:model.audioNode, model.audioNode.gain

但只有一些类型可以转换为响应式对象。这是来自 Vue 反应包的代码 https://github.com/vuejs/core/blob/main/packages/reactivity/src/reactive.ts#L43

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
  }
}

正如我们所看到的,除了Object, Array, Map, Set, WeakMap, and WeakSet将无效。要知道您的对象是什么类型,您可以调用yourObject.toString() (Vue 实际使用的是什么 https://github.com/vuejs/core/blob/main/packages/shared/src/index.ts#L67)。任何不修改的自定义类toString方法将是Object类型并可以使其成为反应性的。在你的示例代码中model is object type, model.audioNode是类型GainNode。因此它不能转换为响应式对象,并且改变其属性也不会触发 Vue 重新渲染。

So 为什么 setter 方法有效?

它实际上不起作用。让我们考虑一下这个片段:

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>

上面代码片段中的设置器不起作用。让我们考虑另一个片段:

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>

上面代码片段中的设置器确实有效。看看那条线<input type='range' min='0' max='1' step='.1' :value="model.gainValue" @input="model.gainValue=$event.target.value">这实际上是您使用时发生的情况v-model="model.gainValue"。它起作用的原因是这条线:value="model.gainValue"随时都会触发Vue重新渲染model.gainValue已更新。和Vue 并不是完全选择性重新渲染。所以当整个模板重新渲染块时{{ model.audioNode.gain.value }}也会被重新渲染。

为了证明 Vue 不是完全选择性重新渲染,让我们考虑一下这个片段:

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>

在上面的例子中anIndependentProperty是反应性的,每当更新时都会触发 Vue 重新渲染。当Vue重新渲染模板块时{{model.audioNode.gain.value}}也会更新。

Solution

此解决方案仅适用于使用模板中的属性的情况。如果您想使用computed从你的类属性中,你必须使用 setter/getter 方法。

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>

请注意这一行:

<input type="hidden" :value="reactiveControl">

每当reactiveControl变量更改,模板将更新,其他变量也会更新。所以你只需要改变的值reactiveControl每当您更新类属性时。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

Vue 绑定到外部对象 的相关文章

随机推荐