相关原理
初始化Vue实例时,Vue将递归遍历data
对象,通过Object.defineProperty
将其中已有的属性转化为响应式的属性(getter/setter)。响应式属性的变化才能够被Vue观察到。
这就是为什么,Vue文档建议我们在初始化Vue实例之前,提前初始化data
中所有可能用到的属性。如果想要在Vue实例创建以后添加响应式属性,需要使用Vue.set(object, key, value)
,而不能直接通过赋值来添加新属性(这样添加的新属性不具有响应性)。
在
运行时才能确定数据属性的键,这称为
动态属性。相对地,如果在
编程时就能确定属性的键,这称为
静态属性。
Vue.set的限制
注意,Vue.set的第一个参数不能是Vue实例或者Vue实例的数据对象,可以是数据对象内嵌套的对象,或者props中的对象。也就是说,不能动态添加根级响应式属性。
Vue文档: Vue does not allow dynamically adding new
root-level reactive properties to an
already created instance. However, it’s possible to add reactive properties to a
nested object using the
Vue.set(object, key, value)
method.
let vm = new Vue({
data: {
nestedObj: {}
}
}); // 创建Vue实例
Vue.set(vm, 'a', 2); // not works,不能为Vue实例添加根级响应式属性
Vue.set(vm.$data, 'b', 2); // not works,不能为Vue数据对象添加根级响应式属性
Vue.set(vm.nestedObj, 'c', 2); // works,vm.nestedObj是数据对象内的一个嵌套对象
Vue.set(vm.$data.nestedObj, 'd', 2); // works,vm.$data.nestedObj是数据对象内的一个嵌套对象
Vue.set会做适当的检查并报错:set源码。
Vue.set例子
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id="app">
<test-dynamic></test-dynamic>
</div>
</body>
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>
<script>
const testDynamicComponent = {
template: `
<div>
<button @click="onClick">test</button>
<p v-if="show">{{ nestedObj.dynamic }}</p>
</div>
`,
data() {
return ({
show: false,
nestedObj: {}
})
},
methods: {
onClick() {
// Vue.set(this, 'dynamic', 'wait 2 seconds...'); // this will not works!
// Vue.set(this.$data, 'dynamic', 'wait 2 seconds...'); // this will not works!
Vue.set(this.$data.nestedObj, 'dynamic', 'wait 2 seconds...'); // this works
// Vue.set(this.nestedObj, 'dynamic', 'wait 2 seconds...'); // this also works
this.show = true;
setTimeout(() => {
this.nestedObj.dynamic = 'createReactiveProxy works!';
}, 2000);
}
}
};
var app = new Vue({
el: '#app',
components: {
'test-dynamic': testDynamicComponent
}
})
</script>
</html>
问题背景
实际使用场景中,有时碰到这种情况:在创建Vue实例的时候,你还不确定会用到哪些属性(需要与用户进行交互之后才知道),或者有大量的属性都有可能被用到(而你不想为数据对象初始化那么多的属性)。这时候,提前初始化所有数据对象的属性就不太现实了。
解决方案
一个原始的解决方案:与用户交互的过程中,每当发现需要用到新的属性,就通过Vue.set
添加响应式属性。
牢记上面讲到的
Vue.set的限制。动态添加的属性只能放在data内嵌套的对象中,或者props中的对象。实战中可以在data数据对象中专门用一个属性来存放动态属性,比如
data: { staticProp1: '', staticProp2: '', dynamicProps: {} }
。
在这个方法的基础上,可以扩展出一个一劳永逸的方案:使用ES6 Proxy,为data
创建一个代理,拦截对data
的赋值操作,如果发现这次赋值是属性添加,则使用Vue.set
来动态添加响应式属性。
再进一步,我们还可以:
- 递归为已存在的子属性创建代理。
- 动态添加属性时,如果赋值的属性值是对象,那么也为这个对象创建代理。
实现如下:
import Vue from "vue";
// 已经拥有createReactiveProxy的对象拥有以下特殊属性,方便我们检测、获取reactiveProxy
const REACTIVE_PROXY = Symbol("reactiveProxy拥有的特殊标记,方便识别");
/**
* @description 拦截赋值操作,
* 如果发现这次赋值是属性添加,则使用Vue.set(object, key, value)来添加响应式属性。
*/
export function createReactiveProxy(obj) {
if (typeof obj !== "object" || obj === null) {
throw new Error(
"createReactiveProxy的参数不是object: " + JSON.stringify(obj)
);
}
if (obj[REACTIVE_PROXY]) {
// 如果传入的对象已经拥有reactiveProxy,或者它就是reactiveProxy,则直接返回已有reactiveProxy
return obj[REACTIVE_PROXY];
}
// console.log("creating reactiveProxy", obj);
const proxy = new Proxy(obj, {
set(target, property, value, receiver) {
// 如果receiver === target,表明proxy处于被赋值对象的原型链上
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/set
// 仅仅拦截直接对proxy的赋值操作(reactiveProxy.newProperty=newValue)
if (!target.hasOwnProperty(property) && receiver === proxy) {
if (typeof value === "object" && value !== null) {
// 如果要赋的值也是对象,则也要拦截这个对象的赋值操作
value = createReactiveProxy(value);
}
// console.log("Vue.set ", target, property);
Vue.set(target, property, value);
return true;
} else {
// console.log("Reflect.set ", target, property);
return Reflect.set(...arguments);
}
}
});
// 方便以后检测、找到对象的reactiveProxy
Object.defineProperty(obj, REACTIVE_PROXY, { value: proxy });
Object.defineProperty(proxy, REACTIVE_PROXY, { value: proxy });
// 检测这个对象已有的属性,如果是对象,则也要被拦截
Object.keys(obj).forEach(key => {
if (typeof obj[key] === "object" && obj[key] !== null) {
obj[key] = createReactiveProxy(obj[key]);
}
});
return proxy;
}
createReactiveProxy例子
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id="app">
<test-dynamic></test-dynamic>
</div>
</body>
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>
<script>
// 已经拥有createReactiveProxy的对象拥有以下特殊属性,方便我们检测、获取reactiveProxy
const REACTIVE_PROXY = Symbol("reactiveProxy拥有的特殊标记,方便识别");
/**
* @description 拦截赋值操作,
* 如果发现这次赋值是属性添加,则使用Vue.set(object, key, value)来添加响应式属性。
*/
function createReactiveProxy(obj) {
if (typeof obj !== "object" || obj === null) {
throw new Error(
"createReactiveProxy的参数不是object: " + JSON.stringify(obj)
);
}
if (obj[REACTIVE_PROXY]) {
// 如果传入的对象已经拥有reactiveProxy,或者它就是reactiveProxy,则直接返回已有reactiveProxy
return obj[REACTIVE_PROXY];
}
console.log("creating reactiveProxy", obj);
const proxy = new Proxy(obj, {
set(target, property, value, receiver) {
// 如果receiver === target,表明proxy处于被赋值对象的原型链上
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/set
// 仅仅拦截直接对proxy的赋值操作(reactiveProxy.newProperty=newValue)
if (!target.hasOwnProperty(property) && receiver === proxy) {
if (typeof value === "object" && value !== null) {
// 如果要赋的值也是对象,则也要拦截这个对象的赋值操作
value = createReactiveProxy(value);
}
console.log("Vue.set ", target, property, value);
Vue.set(target, property, value);
return true;
} else {
console.log("Reflect.set ", target, property, value);
return Reflect.set(...arguments);
}
}
});
// 方便以后检测、找到对象的reactiveProxy
Object.defineProperty(obj, REACTIVE_PROXY, { value: proxy });
Object.defineProperty(proxy, REACTIVE_PROXY, { value: proxy });
// 检测这个对象已有的属性,如果是对象,则也要被拦截
Object.keys(obj).forEach(key => {
if (typeof obj[key] === "object" && obj[key] !== null) {
obj[key] = createReactiveProxy(obj[key]);
}
});
return proxy;
}
</script>
<script>
const testDynamicComponent = {
template: `
<div>
<button @click="onClick">test</button>
<p v-if="show">{{ dynamicProps.dynamic }}</p>
</div>
`,
data() {
return createReactiveProxy({
show: false,
dynamicProps: {}
});
},
methods: {
onClick() {
this.dynamicProps.dynamic = 'wait 2 seconds...';
this.show = true;
setTimeout(() => {
this.dynamicProps.dynamic = 'createReactiveProxy works!';
}, 2000);
}
}
};
var app = new Vue({
el: '#app',
components: {
'test-dynamic': testDynamicComponent
}
})
</script>
</html>
关于v-model的补充
- Vue.set添加属性时,是通过defineProperty来添加getter和setter,并不会触发set handler,而是触发defineProperty handler。
- 如果v-model绑定的属性不存在对象上,那么v-model会在第一次@input事件发生时,通过Vue.set添加绑定属性,让绑定的属性拥有响应性。如上一条所说,这个过程不会触发proxy的set handler。
- 在后续的@input事件,v-model才会通过
data.prop=$event
来更新绑定,这时会触发proxy的set handler。
也就是说,v-model不仅仅是data.prop=$event
这样的语法糖,它会自动添加尚不存在、但立即需要的属性(利用Vue.set)。