• 微信公众号:美女很有趣。 工作之余,放松一下,关注即送10G+美女照片!

我看es6之数据变化的玄妙

互联网 diligentman 2周前 (04-07) 12次浏览

本文要说的就是跟「数据改变」有关的两个API —— 其中最先出场的当然是 Object.defineProperty ,它有着“悠久的历史”;而自es6后,另一个API proxy 越来越火,他们之间也算各有优势,只是 从使用上来看,前者更突出对单个数据的处理,而后者更重视对数据集的处理:


Object.defineProperty?

其实这个API主要是用来设置对象属性的可访问性!现在常用的“添加/删除属性”就是基于“对象属性设置了可写可操作”的前提下。
以前看过一道面试题,大意就是:“你能模拟let和const吗?他们有什么不同之处?” 它们有何不同?那无疑就是 变量是否可更改值,也就是常说的 writable

Object.defineProperty方法用于在对象上定义一个新属性,或者修改对象现有属性,并返回此对象。注意,请通过Object构造器调用此方法,而不是对象实例。 —— MDN

基本语法如下:

Object.defineProperty(obj, prop, descriptor)

OK,结合基本用法与概念,我们来试试添加属性与修改属性。

// 添加属性
let obj = {};
Object.defineProperty(obj, 'name', {value:'yxm'});
obj.name;// 'yxm'

// 修改现有属性
obj.age = 20;
// 重返18岁
Object.defineProperty(obj, 'age', {value:18});
obj.age;// 18

通过上面的例子演示我们可知,语法中的obj是我们要添加/修改属性的对象,prop是我们希望添加/修改的属性名,而descriptor是我们添加/修改属性的具体描述,descriptor 包含属性较多,我们展开说 ——

Object.defineProperty方法中的descriptor属性繁多,所以它也非常强大,我们之前说的数据劫持,数据是否可写,是否可删除,是否可枚举都在这个descriptor中定义。在介绍每个属性前,我们还得引入一个新概念,即:
对象里目前存在的属性描述符有两种主要形式:数据描述符和存取描述符。数据描述符是一个具有值的属性,该值可以是可写的,也可以是不可写的。存取描述符是由 getter 函数和 setter 函数所描述的属性。一个描述符只能是这两者其中之一,不能同时是两者。

descriptor中属性包含6个,笔者将其分为了3类,数据描述符类(value,writable),存取描述符类(get,set),以及能与数据描述符或者存取描述符共存的共有属性(configurable,enumerable):

  1. writable是一个布尔值,若不定义默认为false,表示此条属性只可读,不可修改,举个例子:
let o = {};
Object.defineProperty(o, 'name', {
    value: 'yxm',
    writable: false
});
// 尝试修改name属性
o.name = 'mxc';
// 再次读取,结果并未修改成功
console.log(o.name); // yxm

注意,如果在严格模式下,修改writable属性为false的对象属性会报错。但如果将上述代码的writalbe改为false就可以随意更改了。

而在MDN中关于writable属性的描述为:

当该属性的 writable 键值为 true 时,属性的值,也就是上面的 value,才能被赋值运算符改变。

const声明的变量是不是真的一定不可修改?准确来说可以改,分两种情况,一定要区分清楚

// 值为基本类型
const a = 1;
a = 2;// 报错

// 值为复杂类型
const b = [1];
b = [1,2];// 报错

const c = [1];
c[0] = 0;
c;// [0]

如果我们const声明变量赋值是基本类型,只要修改值一定报错;如果值是引用类型,比如值是一个数组,当我们直接使用赋值运算符整个替换数组还是会报错,但如果我们不是整个替换数组而是修改数组中某个元素可以发现并不会报错。

这是因为对于引用数据类型而言,变量保存的是数据存放的引用地址,比如b的例子,原本指向是[1]的地址,后面直接要把地址改成数组[1,2]的地址,这很明显是不允许的,所以报错了。但在c的例子中,我们只是把c地址指向的数组中的一位元素改变了,并未修改地址,这对于const是允许的。

而这个特性对于writable也是适用的,比如下面这个例子:

let o = {};
Object.defineProperty(o, 'age', {
    value: [20],
    writable: false
});
// 尝试修改name属性
o.age[0] = 18;
// 再次读取,修改成功
console.log(o.age); // 18

OK,我们介绍了descriptor中的数据描述符相关的vaule与writbale,接着聊聊有趣的存取描述符,也就是在vue中也出现过getter、setter方法。

我们知道,JavaScript中对象赋值与取值非常方便,有如下两种方式:

let o = {};
// 通过.赋值取值
o.name = 'yxm';
//通过[]赋值取值,这种常用于key为变量情况
o['age'] = 20;

一个很直观的感受就是,对象赋值就是种瓜得瓜种豆得豆,我们给对象赋予了什么,获取的就是什么。那大家有没有想过这种情况,赋值时我提供1,但取值我希望是2?巧了,这种情况我们就可以使用Object.defineProperty()中的存取描述符来解决这个需求。说直白点,存取描述符给了我们赋值/取值时数据劫持的机会,也就是在赋值与取值时能自定义做一些操作。

  1. getter函数在获取属性值时触发,注意,是你为某个属性添加了getter在获取这个属性才会触发,如果未定义则为undefined,该函数的返回值将作为你访问的属性值。

  2. setter函数在设置属性时触发,同理你得为这个属性提前定义这个方法才行,设置的值将作为参数传入到setter函数中,在这里我们可以加工数据,若未定义此方法默认也是undefined。

OK,让我们用getter与setter模拟最常见的对象赋值与取值,看个例子:

let o = {};
o.name = 'yxm';
console.log(o.name); // 'yxm'

//使用get set模拟赋值取值操作
let age;
Object.defineProperty(o, 'age', {
    get() {
        // 直接返回age
        return age;
    },
    set(val) {
        // 赋值时触发,将值赋予变量age
        age = val;
    }
});
o.age = 18;
console.log(o.age); // 18

在上面例子模拟中,只要为o赋值setter就会触发,并将值赋予给age,那么在读取值getter直接返回变量age即可。

最后,让我们了解剩余两个属性configurable与enumerable。

  1. enumerable值类型为Boolean,表示该属性是否可被枚举,啥意思?我们知道对象中有个方法Object.keys()用于获取对象可枚举属性,比如:
let o = {
    name: 'yxm',
    age: 20
};
Object.keys(o); // ['name','age']

通俗点来说,上面例子中的两个属性还是可以遍历访问的,但如果我们设置enumerable为false,就会变成这样:

let o = {
    name: 'yxm'
};
Object.defineProperty(o, 'age', {
    value: 20,
    enumerable: false
});
// 无法获取keys
Object.keys(o); // ['name']
// 无法遍历访问
for (let i in o) {
    console.log(i); // 'name'
};
  1. configurable的值也是Boolean,默认是false,configurable 特性表示对象的属性是否可以被删除,以及除 value 和 writable 特性外的其他特性是否可以被修改。
let o = {
    name: 'yxm'
};
Object.defineProperty(o, 'age', {
    value: 20,
    configurable: false
});

delete o.name; //true
delete o.age; //false

o.name; //undefined
o.age; //20


var o = {};
Object.defineProperty(o, 'name', {
    get() {
        return 'mxc';
    },
    configurable: false
});
// 报错,尝试通过再配置修改name的configurable失败,因为已经定义过了configurable
Object.defineProperty(o, 'name', {
    configurable: true
});

//报错,尝试修改name的enumerable为true,失败,因为未定义默认为false
Object.defineProperty(o, 'name', {
    enumerable: true
});

//报错,尝试新增set函数,失败,一开始没定义set默认为undefined
Object.defineProperty(o, 'name', {
    set() {}
});

//尝试再定义get,报错,已经定义过了
Object.defineProperty(o, 'name', {
    get() {
        return 1;
    }
});

// 尝试添加数据描述符中的vaule,报错,数据描述符无法与存取描述符共存
Object.defineProperty(o, 'name', {
    value: 12
});

由于前面我们说了,未定义的属性虽然没用代码写出来,但它们其实都有了默认值,当configurable为false时,这些属性都无法被重新定义以及修改。

那么到这里,我们把descriptor中所有属性都介绍完了,在使用中有几点需要强调,这里再扩展一下:
前面概念已经提出对象属性描述符要么是数据描述符(value,writable),要么是存取描述符(get,set),不应该同时存在两者描述符。

var o = {};
Object.defineProperty(o, 'name', {
    value: 'hhh',
    get() {
        return 'mxc';
    }
});

这个例子就会报错,其实不难理解,存取方法就是用来定义属性值的,value也是用来定义值的,同时定义程序也不知道该以哪个为准了,所以用了value/writable其一,就不能用get/set了;不过configurableenumerable这两个属性可以与上面两种属性任意搭配。

我们在前面已经说了各个属性是有默认值的,所以在用Object.defineProperty()时某个属性没定义不是代表没用这条属性,而是会用这条属性的默认值。

let o = {};

Object.defineProperty(o, 'name', {
    value: 'hhh'
});
//等同于
Object.defineProperty(o, 'name', {
    value: 'hhh',
    writable: false,
    enumerable: false,
    configurable: false
});

var o = {};
o.name = 'yxm';
//等同于
Object.defineProperty(o, 'name', {
    value: 'yxm',
    writable: true,
    enumerable: true,
    configurable: true
});
//等同于
let name = 'yxm';
Object.defineProperty(o, 'name', {
    get() {
        return name;
    },
    set(val) {
        name = val;
    },
    enumerable: true,
    configurable: true
});

Proxy的天下

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。

const p = new Proxy(target, handler)
  1. target: 使用 Proxy 包装的目标对象(可以是任何类型的 JavaScript 对象,包括原生数组,函数,甚至另一个代理)。
  2. handler: 一个通常以函数作为属性的对象,用来定制拦截行为。

在支持 Proxy 的浏览器环境中,Proxy 是一个全局对象,可以直接使用。Proxy(target, handler)是一个构造函数,target是被代理的对象,最终返回一个代理对象。

proxy针对的也是“对象整体”,它在监听时不管你要用的是哪个具体属性,先挂上再说。

API 概览如下:

  1. get(target, propKey, receiver):拦截对象属性的读取,比如 proxy.foo 和 proxy[‘foo’];
  2. set(target, propKey, value, receiver):拦截对象属性的设置,比如 proxy.foo = v 或 proxy[‘foo’] = v ,返回一个布尔值;
  3. has(target, propKey):拦截 propKey in proxy 的操作,返回一个布尔值;
  4. deleteProperty(target, propKey):拦截 delete proxy[propKey]的操作,返回一个布尔值;
  5. ownKeys(target):拦截 Object.getOwnPropertyNames(proxy) 、 Object.getOwnPropertySymbols(proxy) 、 Object.keys(proxy) 、 for…in 循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而 Object.keys() 的返回结果仅包括目标对象自身的可遍历属性;
  6. getOwnPropertyDescriptor(target, propKey):拦截 Object.getOwnPropertyDescriptor(proxy, propKey) ,返回属性的描述对象;
  7. defineProperty(target, propKey, propDesc):拦截 Object.defineProperty(proxy, propKey, propDesc) 、Object.defineProperties(proxy, propDescs),返回一个布尔值;
  8. preventExtensions(target):拦截 Object.preventExtensions(proxy) ,返回一个布尔值;
  9. getPrototypeOf(target):拦截 Object.getPrototypeOf(proxy),返回一个对象 ;
  10. isExtensible(target):拦截 Object.isExtensible(proxy) ,返回一个布尔值;
  11. setPrototypeOf(target, proto):拦截 Object.setPrototypeOf(proxy, proto) ,返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截;
  12. apply(target, object, args):拦截 Proxy 实例作为函数调用的操作,比如 proxy(…args)、proxy.call(object, …args)、proxy.apply(…)`;
  13. construct(target, args):拦截 Proxy 实例作为构造函数调用的操作,比如 new proxy(…args)

最常用的方法就是get和set了:

const target = {
  name: 'jacky',
  sex: 'man',
}
const handler = {
  get(target, key) {
    console.log('获取名字/性别')
    return Reflect.get(target, key)
  },
}
const proxy = new Proxy(target, handler)
console.log(proxy.name)

运行,打印台输出:

获取名字/性别
jacky

在获取name属性其实是先进入get方法,在get方法里面打印了获取名字/性别,然后通过Reflect.get(target, key)的返回值拿到属性值,相当于target[key]

const target = {
  name: 'jacky',
  sex: 'man',
}
const handler = {
  get(target, key) {
    console.log('获取名字/性别')
    return Reflect.get(target, key)
  },
  set(target, key, value) {
    return Reflect.set(target, key, `强行设置为 ${value}`)
  },
}
const proxy = new Proxy(target, handler)
proxy.name = 'monkey'
console.log(proxy.name)

运行输出:

获取名字/性别
强行设置 monkey

设置proxy.name = 'monkey',这是修改属性的值,则会触发到set方法, 然后我们强行更改设置的值再返回,达到拦截对象属性的设置的目的。

Reflect对象与Proxy对象一样,也是 ES6 为了操作对象而提供的新 API。(不过它在使用上似乎更偏向于Object.defineProperty
Reflect.get(target, name, receiver) :查找并返回target对象的name属性,如果没有该属性,则返回undefined:

Reflect.set(target, name, value, receiver) //设置target对象的name属性等于value。

proxy 会改变 target 中的 this 指向,一旦 Proxy 代理了 target,target 内部的 this 则指向了 Proxy 代理

const target = new Date('2021-01-03')
const handler = {}
const proxy = new Proxy(target, handler)

console.log(proxy.getDate())

运行代码,会发现报错,提示TypeError: this is not a Date object,即 this 不是 Date 对象的实例,这时需要我们手动绑定原始对象即可解决:

const target = new Date('2021-01-03')
const handler = {
  get(target, prop) {
    if (prop === 'getDate') {
      return target.getDate.bind(target) // 绑定
    }
    return Reflect.get(target, prop)
  },
}
const proxy = new Proxy(target, handler)

console.log(proxy.getDate()) // 3

注意:使用时一定注意proxy的set中参数对于“基本数据类型”和“引用数据类型”的不同。


综合:模仿vue双向数据绑定行为

假设页面上有一个input框,有id为“example”,有一对象,名为test,test中有一个属性叫value,它的值是input中实时输入的值。而且当手动改变value值时,input框中的文字也会相应的改变。要求实时打印test.value的值:

Object.defineProperty版:

let test={};
Object.defineProperty(test,'value',{
	set(newVal){
		test._value=newVal;
		document.querySelector('#example').value=newVal;
	},
	get(){
		return test._value;
	}
});

document.querySelector('#example').addEventListener('input',function(e){
	test.value=e.target.value;
	console.log(test.value);
})

proxy版:

let test={
	value:""
}
let text=document.querySelector("#example");
text.addEventListener("input",()=>{
	// 通过输入改变对象中属性值
	test.value=text.value
})
test=new Proxy(test,{
	get(target,key){
		return target[key]
	},
	set(target,key,value){
		console.log(value)
		target[key]=value
		// 在这里顺便改变input中的值
		text.value=value
		return true
	}
})


程序员灯塔
转载请注明原文链接:我看es6之数据变化的玄妙
喜欢 (0)