相信大家对于这两个名词并不陌生,最知名的变化就是vue从2到3底层数据响应使用的变化。今天得幸有时间,让我们重新温故一下,这方面的知识可能还需要大家对于JS的原型链有一定的认知。
Object.defineProperty()
首先我们来学习一下Object.defineProperty()方法。大家想要更加深入可以直接阅读MDN官网。
构造器上的方法
从图片可以看出,该方法是在Object直接调用,是直接在Object构造器上的,而不是在原型上。所以我们使用应当直接在构造器对象上调用此方法,而不是在任意一个Object类型的实例上调用。
语法
Object.defineProperty(obj, prop, descriptor)
// 参数
// obj 要定义属性的目标对象
// prop 要定义或修改的属性的名称或Symbol,就像对象的键值
// descriptor 要定义或修改的属性的描述符,用来描述该属性是否可被枚举,可被赋值等等,下面会讲解
返回值:obj对象,被传递给方法经过处理的目标对象。
在ES6中,由于 Symbol类型的特殊性,用Symbol类型的值来做对象的key与常规的定义或修改不同,而Object.defineProperty 是定义key为Symbol的属性的方法之一。
描述符
描述符主要分为三类:公共描述符(官网并没有这个称呼,这个是我自己取的,便于理解),数据描述符,存取描述符。
数据描述符键值:
- value
描述:该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。
默认值:undefined - writable
描述:是否可以被赋值运算符改变
默认值:false
存取运算符键值(这里不懂的朋友可以理解成属性存值取值的过程,类似钩子)
- get
描述:属性的 getter 函数,如果没有 getter,则为 undefined。当访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入 this 对象(由于继承关系,这里的this并不一定是定义该属性的对象)。该函数的返回值会被用作属性的值。
默认值:undefined - set
描述:属性的 setter 函数,如果没有 setter,则为 undefined。当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的 this 对象。
默认值:undefined
公共描述符键值(该名词并不官方)
从数据描述符可以看出,我们拥有了直接给属性赋值的权利,还能控制属性是否可以改写。
从存取描述符可以看出,属性取值和赋值的操作完全掌握在我们自己手中。
所以不免有朋友好奇,这两种描述符是否有些重叠,你的疑问是对的。数据描述符和存取描述符确实不能同时使用,它两水火不容,同时使用会产生异常。但是我们下面要讲的描述符确实可以和它们在一起配合使用,所以我给它们取名公共描述符。
- configurable
描述:该属性的描述符是否能被修改,属性是否能被删除。
默认值:false
如果属性已经存在,Object.defineProperty()
将尝试根据描述符中的值以及对象当前的配置来修改这个属性。如果旧描述符将其configurable
属性设置为false
,则该属性被认为是“不可配置的”,并且没有属性可以被改变(除了单向改变 writable 为 false)。当属性不可配置时,不能在数据和访问器属性类型之间切换。
当试图改变不可配置属性(除了 value
和 writable
属性之外)的值时,会抛出TypeError
,除非当前值和新值相同。
- enumerable
描述:enumerable
定义了对象的属性是否可以在for...in
循环和Object.keys()
中被枚举。
默认值:false
var o = {};
Object.defineProperty(o, "a", { value : 1, enumerable: true });
Object.defineProperty(o, "b", { value : 2, enumerable: false });
Object.defineProperty(o, "c", { value : 3 }); // enumerable 默认为 false
o.d = 4; // 如果使用直接赋值的方式创建对象的属性,则 enumerable 为 true
Object.defineProperty(o, Symbol.for('e'), {
value: 5,
enumerable: true
});
Object.defineProperty(o, Symbol.for('f'), {
value: 6,
enumerable: false
});
for (var i in o) {
console.log(i);
}
// logs 'a' and 'd' (in undefined order)
Object.keys(o); // ['a', 'd']
o.propertyIsEnumerable('a'); // true
o.propertyIsEnumerable('b'); // false
o.propertyIsEnumerable('c'); // false
o.propertyIsEnumerable('d'); // true
o.propertyIsEnumerable(Symbol.for('e')); // true
o.propertyIsEnumerable(Symbol.for('f')); // false
var p = { ...o }
p.a // 1
p.b // undefined
p.c // undefined
p.d // 4
p[Symbol.for('e')] // 5
p[Symbol.for('f')] // undefined
记住,这些选项不一定是自身属性,也要考虑继承来的属性。为了确认保留这些默认值,在设置之前,可能要冻结
Object.prototype
(en-US),明确指定所有的选项,或者通过Object.create(null)
将__proto__
(en-US) 属性指向null
。
// 使用 __proto__
var obj = {};
var descriptor = Object.create(null); // 没有继承的属性
// 默认没有 enumerable,没有 configurable,没有 writable
descriptor.value = 'static';
Object.defineProperty(obj, 'key', descriptor);
// 显式
Object.defineProperty(obj, "key", {
enumerable: false,
configurable: false,
writable: false,
value: "static"
});
继承
如果访问者的属性是被继承的,它的 get 和 set 方法会在子对象的属性被访问或者修改时被调用。如果这些方法用一个变量存值,该值会被所有对象共享。
function myclass() {
}
var value;
Object.defineProperty(myclass.prototype, "x", {
get() {
return value;
},
set(x) {
value = x;
}
});
var a = new myclass();
var b = new myclass();
a.x = 1;
console.log(b.x); // 1
这可以通过将值存储在另一个属性中解决。在 get 和 set 方法中,this 指向某个被访问和修改属性的对象。
function myclass() {
}
Object.defineProperty(myclass.prototype, "x", {
get() {
return this.stored_x;
},
set(x) {
this.stored_x = x;
}
});
var a = new myclass();
var b = new myclass();
a.x = 1;
console.log(b.x); // undefined
不像访问者属性,值属性始终在对象自身上设置,而不是一个原型。然而,如果一个不可写的属性被继承,它仍然可以防止修改对象的属性。
function myclass() {
}
myclass.prototype.x = 1;
Object.defineProperty(myclass.prototype, "y", {
writable: false,
value: 1
});
var a = new myclass();
a.x = 2;
console.log(a.x); // 2
console.log(myclass.prototype.x); // 1
a.y = 2; // Ignored, throws in strict mode
console.log(a.y); // 1
console.log(myclass.prototype.y); // 1
Proxy
接下来我们来学习一下Proxy。同样,大家想要更加深入可以直接阅读MDN官网。
从图片我们可以看出Proxy是JS的标准内置对象,和Object平级。
Proxy对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。我理解这边就像是给对象包了一层外衣,让我们可以按照规定自己定义在不同捕捉的时机执行的hooks。
术语
1.handler(en-US)
包含捕捉器(trap)的占位符对象,可以称为处理器对象。
2.traps
提供属性访问的方法。这里指的其实就是根据时机我们自己定义要执行的方法群。
3.target
被Proxy代理虚拟化的对象。这里其实就是目标对象。
语法
const p = new Proxy(target, handler)
// target 要使用Proxy的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另外一个代理)。
// handler 一个通常以函数作为属性的对象,按照捕获时机定义的方法。
捕获时机都有哪些?
handler对象是一个容纳一批特定属性的占位符对象。它包含有 Proxy 的各个捕获器(trap)。
所有的捕捉器是可选的。如果没有定义某个捕捉器,那么就会保留源对象的默认行为。我们可以根据自己的需要去定义这个目标对象的操作方法,如果同学还想深入了解不同捕获器请查看官网。
重点来啦
我们从一道面试题来对比两者:实现双向绑定Proxy与defineProperty的优劣。
双向绑定体系
双向绑定其实已经是一个老掉牙的问题,只要涉及到MVVM框架就不得不涉及的知识点,大家熟知的vue三要素之一。
Vue三要素:
- 响应式:监听数据变化,实现方法就是响应式
- 模板引擎:如何解析模板
- 渲染:Vue如何将监听到的数据变化和解析后的HTML进行渲染。
可以实现双向绑定的方法有很多:
1.KnockoutJs基于观察者模式的双向绑定
2.Ember基于数据模型的双向绑定
3.Angular基于脏检查的双向绑定
4.面试中最常见的基于数据劫持的双向绑定
最常见的基于数据劫持的双向绑定有两种实现:
- Vue2 Object.defineProperty 实现
- Vue3 Proxy 实现(严格来说Proxy被称为代理,并非劫持,不过作用有很多相似之处)
基于数据劫持的Object.observe方法,已被废弃。
基于数据劫持实现的双向绑定的特点
什么是数据劫持?
通常我们利用Object.defineProperty()劫持对象的访问器,在属性变化时我们可以获取变化,从而进行下一步操作。使用过Vue2的小伙伴们都知道,Vue初始化实例时会把data中声明的对象属性进行getter/setter转化,也就是使用了Object.defineProperty。
数据劫持的优势
目前主流框架可以分为两个流派:一个是以React为首的单向数据绑定,另一个是以Angular、Vue为主的双向数据绑定。
其实三大框架都是既可以双向绑定也可以单项绑定,比如React可以手动绑定onChange和value实现双向绑定,也可以调用可以双向绑定库。Vue也加入了props这种单向流的api,不过都并非主流卖点。
这里我们不讨论单向或者双向,我们来对比其他双向绑定的实现方法,数据劫持的优势所在:
1.无需显式调用:例如Vue运用数据劫持+发布订阅,直接可以通知变化并驱动视图。比如Angular则需要显式调用markForCheck(这里可以使用zone.js库避免显示调用),react则需要显式调用setState。
2.可精准得知变化数据:我们劫持了属性的setter,当属性变化,我们可以精确获知变化的内容,因此这部分不需要额外的diff操作,否则我们只知道数据发生了变化而不知道具体哪些数据变化了,这个时候就需要大量的diff来找出变化值,这是额外的性能损耗。
基于数据劫持双向绑定的实现思路
数据劫持是双向绑定各种方案中比较流行的一种,最著名的实现就是Vue。
基于数据劫持的双向绑定离不开Proxy与Object.defineProperty等方法对对象/对象属性的“劫持”,我们要实现一个完整的双向绑定需要以下几个要点。
1.利用Proxy或Object.defineProperty生成的Observer针对对象/对象的属性进行“劫持”,在属性发生变化后通知订阅者。(①)
2.解析器Compile解析模板中的Directive(指令),收集指令所依赖的方法和数据,等待数据变化进行渲染。(②)
3.Watcher属于Observer和Compile桥梁,它将接收到的Observer产生的数据变化,并根据Compile提供的指令进行试图渲染,是的数据变化促使视图图变化。(③)
这里有人就好奇了,为什么我们不一个模块直接在Dep数据变化的时候直接去更新视图,还要用发布订阅模式。我这里总结了两个原因:
- 大家如果了解开放封闭原则,就会知道这样操作明显违反了开放封闭原则。
- 代码耦合严重,我们的数据,方法和DOM都是耦合在一起的,这就是传说的面条代码。
到这里,相信大家对于如何实现双向绑定都有了相应的认知。接下来我们通过分析Vue2和Vue3响应式数据原理来对比Object.defineProperty到Proxy都有哪些的不同。
Vue2响应式数据原理
Vue2的数据响应使用了ES5中的Object.defineProperty。
Object.defineProperty只能劫持对象的属性,从而需要对每个对象,每个属性进行遍历。如果,属性值是对象,还需要深度遍历。
- 优点:
兼容性好,IE9 。 - 缺点:
1.只能劫持对象的属性,因此需要对每个对象的每个属性进行遍历,性能消耗大。
2.Vue的文档提到了Vue是可以检测到数组变化的,但是只有以下七种方法。其实Vue2里面是通过重写数据的操作方法(通过原型链进行拦截)来对数组进行监听的。但是对于数组长度变化和下标值修改内容是无法监听的,Vue提供了Vue.set()进行响应式。
3.不能监听对象属性的新增和删除。
4..不能对es6新产生的Map,Set这些数据结构做出监听。
因为Object.defineProperty会一开始就会遍历data、methods、props、computed、watch、mixins… 里的一系列变量全都绑定在this上,当嵌套层次比较深时会影响性能和占内存比较大。
push()
pop()
shift()
unshift()
splice()
sort()
reverse()
模拟Vue2中用Object.defineProperty实现数据响应
let obj = {
key:'cue'
flags: {
name: ['AAA', 'VVV', 'FFF']
}
}
function observer(obj) {
if (typeof obj == 'object') {
for (let key in obj) {
defineReactive(obj, key, obj[key])
}
}
}
function defineReactive(obj, key, value) {
Object.defineProperty(obj, key, {
get() {
console.log('获取:' + key)
return value
},
set(val) {
observer(val)
console.log(key + "-数据改变了")
value = val
}
})
}
observer(obj)
Vue3响应式数据原理
先来看下MND描述:
Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。
Proxy可以劫持整个对象,并返回一个新的对象。
- 优点:
1.可以直接监听对象而非属性
2.可以直接监听数组的变化
3.有多种拦截方式,不限于apply、ownKeys、has等是defineProperty不具备的
4.返回的是一个新对象,我们可以只操作新的对象达到目的,而defineProperty只能遍历对象属性直接修改
模拟Vue3种用Proxy实现数据响应
let obj = {
key:'cue'
flags: {
name: ['AAA', 'VVV', 'FFF']
}
}
function observerProxy(obj) {
const handler = {
get(target, key, receiver) {
console.log("获取:" + key);
if (typeof target[key] === "object" && target[key] !== null) {
// 如果是对象,就添加 proxy 拦截
return new Proxy(target[key], handler);
}
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
console.log("设置:" + key);
return Reflect.set(target, key, value, receiver);
},
};
return new Proxy(obj, handler);
}
let newObj = observerProxy(obj);
vue3在开发中的一些具体使用
<script lang="ts">
import { defineComponent, setup } from'vue'
export default defineComponent({
setup(props, context) {
//props父组件传的值
//在setup()里我们不能用this
//vue2.0里的 this.$emit, this.$psrent, this.$refs在这里都不能用了。
//context就是对这些参数的集合
//context.attrs
//context.slots
//context.parent 相当于2.0里 this.$psrent
//context.root 相当于2.0里 this
//context.emit 相当于2.0里 this.$emit
//context.refs 相当于2.0里 this.$refs
let data = reactive({ message: "hello world" }); //响应式对象
let message = ref("hello world"); //响应式字符串
let arr = ref(['hello','world']); //响应式数组字符串
let username = computed(() => user.firstname + " " + user.lastname); //计算属性
const copy = readonly(original); //只读代理
...
}
})
</script>
总结
首先,我们先认识到了Object.defineProperty和Proxy的概念和使用。再围绕着实现双向绑定Proxy与defineProperty的优劣面试题,了解双向绑定体系,什么是数据劫持,数据劫持实现双向绑定的思路。最后通过分析Vue2和Vue3响应式数据原理实现(Observer)的区别,更加深入了解了从Object.defineProperty到Proxy的一大步进步。有兴趣的同学,可以按照基于数据劫持的双向绑死实现思路图实现完整双向绑定。