Vue绑定外部对象

4

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

下面是一个玩具应用程序,说明了我的问题。我有一个来自Web Audio API的GainNode对象,我想将它的value绑定到滑块上。

在Angular中这很容易。双向绑定适用于任何对象,无论是不是Angular组件的一部分。在Vue中是否有类似的方法呢?

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

更新:我使用了下面的第二种解决方案,并且它看起来工作得很好,直到我尝试将v-model绑定到同一音频参数的两个组件上。然后它就以一种完全神秘的方式不起作用,我无法调试。最终我放弃了并使用了getter/setter,虽然有更多的样板代码,但是有一个优点,那就是能够正常工作。

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: 在探索做到这一点的方法时,我发现(如前所述),如果我绑定到外部对象 (GainNode Web音频API),它不是响应式的,但是如果我自己构建一个类似的外部对象,绑定到嵌套参数就是响应式的。以下是示例代码:

// 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代理对象,并在setter被调用时调用$forceUpdate

这两种方法都可行,但缺点是我必须将代理暴露为成员并绑定到而不是原始对象。但这比同时暴露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:

另一个解决方法是修补我想要监视的访问器(accessor),并在其中调用$forceUpdate。这个方法有最少的模板代码(boilerplate)。只需调用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>

1个回答

2

您的问题很有趣,所以我决定花费几个小时来找到答案。

简短回答

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

让我们总结一下问题:

  • 当更改 Web Audio API 对象的属性(实际上任何内置对象都是如此)时,响应式不起作用。
  • 当在 setter 中更改相同的属性时,响应式会起作用。

解释

首先,我们需要知道Vue 在模板中呈现值时会发生什么? 让我们考虑这个模板:

{{ model.audioNode.gain.value }}

如果model是一个响应式对象(由reactiverefcomputed创建...),Vue将创建一个getter,将链上的每个对象转换为响应式。因此,以下对象将使用Vue.reactive函数转换为响应式形式:model.audioNodemodel.audioNode.gain
但仅有一些类型可以被转换为响应式对象。这里是来自Vue响应式包的代码: 链接
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
  }
}

我们可以看到,除了 ObjectArrayMapSetWeakMapWeakSet 之外的其他类型都将无效。要知道您的对象的类型,您可以调用 yourObject.toString()Vue 实际使用的内容)。任何未修改 toString 方法的自定义类都将是 Object 类型,并且可以被制作成响应式对象。在您的示例代码中,modelobject 类型,model.audioNodeGainNode 类型。因此,它不能转换为响应式对象,而且更改其属性不会触发 Vue 重新渲染。

那么为什么 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>

上面代码段中的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 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>

上面代码片段中的setter确实起作用。看一下这行代码:<input type='range' min='0' max='1' step='.1' :value="model.gainValue" @input="model.gainValue=$event.target.value"> 实际上,它就是在使用v-model =“model.gainValue”时发生的情况。它能够工作的原因是,当更新model.gainValue时,该行:value=“model.gainValue”会触发Vue重新渲染。但是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}} 也会被更新。

解决方案

此解决方案仅适用于在模板中使用属性的情况。如果您想使用类属性中的 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的值即可。

第一句:非常感谢你所付出的努力!我自己也对这个问题着迷。第二句:当你说“实际上任何内置对象”时,什么是“内置”的意思?原生的?由浏览器构建的?我刚刚更新了问题,并提供了第二个示例,在该示例中,Web API 对象未被跟踪,但我自己构建的类似对象却被跟踪。重新阅读你的答案,我想答案是“任何不在 Vue 的 targetTypeMap 中的东西”。这正确吗? - Mud
我看到你的代码正在更新触发,但这需要视图中的自定义更新函数。现在它很小,但如果我想绑定到许多不同的对象,并且只想使用 v-model 绑定到目标属性,那么它将无法工作。我在我的帖子中更新了一些解决方法。解决方法2似乎比你写的少了一些样板代码(留下了可以被每个类重用的 hookSetter 函数)。你看到任何问题吗? - Mud
@Mud 让我们来看一下 Vue 如何定义对象类型。它调用了 yourObject.toString().slice(8, -1)。在我的术语中,内置对象是从浏览器 API 创建的对象。你猜对了,任何不在 Vue 的 targetTypeMap 中的东西都是无效的。注意:你自定义的类是“对象”类型,并且可以被响应化。 - Duannx
问题是...我有一些类属性,我只想要绑定它们。但是我被要求为每个属性创建两个额外的属性,没有其他原因,只是因为Vue。因此,我的类上有60个成员,而不是20个,因为我无法告诉Vue如何观察一个东西。在Angular中,这将是20个成员。我的watch方法将其降回到20个。因此,现在对我来说,这是最有吸引力的选项。我只希望我能找到一种更本地的方法来告诉Vue观察这些属性。 - Mud
@Mud $forceUpdate 方法的缺点是它无法更新依赖于类属性的计算属性。因此,如果您希望在类中使用 computed,则必须使用 setter/getter 方法。 - Duannx
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接