我正在尝试将 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:在探索执行此操作的方法时,我发现(如前所述)如果我绑定到异物的嵌套部分(GainNode
Web 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的样板。我要么:
- 创建我自己的
ref
版本(没有线索Vue.ref
不起作用)或 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
是一个反应式对象(由reactive
、ref
或computed
创建),Vue 将创建一个 getter,将链上的每个对象转换为响应式对象。因此,以下这些对象将使用Vue.reactive
函数转换为响应形式:model.audioNode
、model.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
}
}
如我们所见,除Object
、Array
、Map
、Set
、WeakMap
和WeakSet
以外的类型将是无效的。要知道你的对象类型,你可以调用yourObject.toString()
(Vue 实际使用什么)。任何不修改toString
方法的自定义类都将是Object
类型,并且可以成为反应式类。在您的示例代码中,model
object
类型,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
的值。