Vue 绑定到外部对象



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

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

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

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

更新:我正在使用解决方法#2(如下),它似乎工作得很好,直到我尝试v-model将两个组件绑定到相同的音频参数。然后它只是无法正常工作,以我无法调试的完全神秘的方式。我最终放弃了,正在使用getters/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:在探索执行此操作的方法时,我发现(如前所述)如果我绑定到异物的嵌套部分(GainNodeWeb Audio 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对象并在调用资源库时调用$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:

另一种解决方法是修补我想监视的访问器并在其中调用$forceUpdate。这具有最少的样板。我只是调用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 音频 API 对象的属性时,反应不起作用(实际上任何内置对象都是相同的)
  • 当在二传器中改变相同的属性时,反应式确实有效

解释

首先,我们需要知道当 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.audioNode是类型GainNode。所以它不能被转换为反应式对象,改变它的属性不会触发 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,
}
}
});
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"行将在更新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的值。

最新更新