Obeta

深入理解JavaScript对象

JavaScript中对象非常容易创建和使用,给大多数开发者造成一种错觉:它非常容易,一点都不复杂

你知道 Vuejs 中如何实现 mvc 的吗?知道什么是访问者属性吗?知道如何禁止对象属性被删除吗?知道如何防止对象属性被for-inObject.keys循环到吗?知道如何防止对象被更改吗?

相信看完下面的介绍你就能回答了.

属性类型

Object 中的属性有两种类型,分别是以下两种:

  1. Data Properties(数据属性)
  2. Accessor Properties(访问器属性)

工作中我们经常这样生成一个对象然后进行各种添加删除更新属性的操作:

const obj = {
	name: 'obeta',
	age: 25,
};
obj.age = 26;
console.log(obj.age);
// 26

上面obj中的name,age都是Data Properties数据属性,这是我们平时用的非常多的一种方式.

而访问器属性非常像面向对象语言中的getter,setter函数(如 Python).在 JS 中则是这两个属性函数get,set:

const accessObj = {
	get name() {
		return 'obeta';
	},
};
console.log(accessObj.name);
// obeta

name属性设置了get访问器,则获取name属性的适合其实内部是执行的get函数,但是不能说get是一个函数就能把name当做函数调用,因为 JS 在内部就已经帮你自动调用了name的访问器函数.上面例子并没有给name设置set访问器,因此name是无法设置值的.下面给它设置一个:

const accessObj = {
	_name: '',
	get name() {
		return this._name;
	},
	set name(val) {
		this._name = val;
	},
};

accessObj.name = 'obeta';
console.log(accessObj.name);
// obeta

可以发现我们对外隐藏了真实存储数据的_name属性(类似 private 属性),只提供对应的访问器属性给外部用户使用.不过要知道 JS 无法真正隐藏属性(无法实现 private 属性,类似 Python),但是可以减少了使用者的心智负担(不需要考虑内部如何处理).

说个题外话,私有属性可以通过多种 hack 方式实现,比如使用 Symbol,WeakMap,闭包等等方式,具体可以看看那这篇文章Private member in Javascript Class,作者谈到 Symbol 可以通过getOwnPropertySymbols获取的时候还配图了"笑哭"的表情包...另外 ES10 提供了#符号来表示私有属性,至于为何使用这个,可以看看这个讨论

访问器属性赋予我们细化控制属性的能力,我们可以在设置值的时候进行验证和处理,在获取值的时候返回各种值的计算以及处理的结果(比如 Vuejs 中的计算属性),可以看看这篇讨论why-use-getters-and-setters-accessors

那么问题来了,JS 是如何知道哪个是数据属性哪个是访问器属性?

对象属性描述符

对象属性描述符(Object Property Descriptors)是一个对对象里属性进行描述的对象,规定了各个属性的值属性,访问器属性,可写属性,可枚举性,可配置性.

const obj = {
	name: 'obeta',
	age: 25,
};

我们一直有种错觉,认为对象里面就这些我们定义的数据,但其实并不是的,我们所看到的并不是全部,在对象里每一个属性都有专属于自己的属性描述,定义了这个属性的相关特征.

简而意之就是属性描述符定义和解释这个属性的状态.你可以认为这些看得见的属性其实也是一个对象,这个对象里还有其它的特殊属性:

object model

上图是数据描述符,对象描述符有以下六种:

  • [[Value]]: any
  • [[Writable]]: boolean
  • [[Enumerable]]: boolean
  • [[Configurable]]: boolean
  • [[Get]]: function
  • [[Set]]: function

我们可以通过以下方式或者对象的描述符:

const obj = {
	x: 1,
	y: 1,
};

Object.getOwnPropertyDescriptor(obj, 'x');
// {
// 	configurable: true,
// 	enumerable: true,
// 	value: 1,
// 	writable: true,
// }

Object.getOwnPropertyDescriptors(obj);
// {
// 	x: {
// 		configurable: true,
// 		enumerable: true,
// 		value: 1,
// 		writable: true,
// 	},
// 	y: {
// 		configurable: true,
// 		enumerable: true,
// 		value: 1,
// 		writable: true,
// 	}
// }

这是各个属性描述符代表的含义:

  • [[Value]]: 存储当前属性的值,可以使用.[]来进行访问这个值.
  • [[Writable]]: 布尔值,设置属性值是否可以被改写,如果为 false 则这个值无法更改.
  • [[Enumerable]]: 布尔值,设置属性值是否可以被for-in枚举.
  • [[Configurable]]: 布尔值,设置值是否可以被配置,当为 false 的时候不可删除此属性值,不可再次配置属性描述符,也就是说enumerable,configurable,get,set不可更改.
  • [[Get]]: 访问器属性中的get函数,不接受参数,每次访问都会执行此函数.
  • [[Set]]: 访问器属性中的set函数,接受一个值参数,每次赋值都会调用.

ECMA 规范规定这些带有[[]]双括号的属性是内部值,不允许外部直接访问,只能通过语言提供的方法来查看和修改

一个属性是数据属性,那么在writable为 false 时候不可以更改他的值(不可写),在writeable为 true 的时候可以修改[[Value]]属性(也就是修改值).

因此我们可以通过以下方法区别数据属性和访问器属性:

  • 数据属性: 有value,writable,enumerable,configurable.
  • 访问器属性: 有get,set,enumerable,configurable.

定制属性

一般使用Object.defineProperty来设置对象描述符,它接受三个值:对象,对象属性名,描述符对象:

const obj = {
	name: 'obeta',
};
Object.defineProperty(obj, 'name', {
	value: 'somebody',
	writable: false,
	enumerable: true,
	configurable: true,
});

console.log(obj.name); // somebody

obj.name = 'badman';
console.log(obj.name); // somebody

Object.defineProperty(obj, 'age', {
	value: 25,
	writable: false, // 不可修改, 冻结年龄...
	// 一般来说未指定的参数都默认为false
});
delete obj.age; // 无法删除
console.log(Object.keys(obj)); // ['name']

Object.defineProperties(obj, {
	level: {
		value: 1,
		writable: true,
	},
	class: {
		value: 2,
		writable: false,
	},
});
console.log(obj.level); // 1
obj.level = 2;
console.log(obj.level); // 2

上面的Object.defineProperties可以同时定制多个属性.

保护对象属性

JS 提供了多个原生方法来保护对象:

Object.preventExtensions

禁止给对象添加属性(但是可以删除).简而言之就是不允许添加属性.

const obj = {
	name: 'obeta',
};
Object.preventExtensions(obj);
obj.age = 18;

console.log(obj); // {name: 'obeta'}
delete obj.name;
console.log(obj); // {}

Object.seal

这个比上面Object.preventExtensions多了一层保护.简而言之就是不允许添加和删除属性.

  1. 不允许添加属性
  2. 不允许配置,也就是说configurable为 false.
const obj = {
	id: 42,
};

Object.seal(obj);

delete obj.id; // 没用

obj.name = 'obeta'; // 添加元素也没用

console.log(obj); // { id: 42 }

Object.isExtensible(obj); // false

Object.isSealed(obj); // true

Object.freeze

最大程度保护对象,禁止添加,删除,修改对象中的属性,也不允许配置属性.

// 可以通过此方法判断是否被freeze
Object.isFrozen(obj);

有一点非常重要,以上的三个方法只针对于第一层属性,深层处的对象并没有阻止,因此如果需要自己递归调用这些方法,或者使用deep-freeze

const zzz = { people: { age: 12, name: 'obeta' } };
Object.preventExtensions(zzz);
zzz.people.friend = 'zyx';
console.log(zzz); // { people: { age: 12, name: 'obeta', friend: 'zyx' } }

const xxx = { people: { age: 12, name: 'obeta' } };
Object.seal(xxx);
Reflect.deleteProperty(xxx.people, 'name');
console.log(xxx); // { people: { age: 12 } }

const yyy = { people: { age: 12, name: 'obeta' } };
Object.freeze(yyy);
Reflect.deleteProperty(yyy.people, 'name');
console.log(yyy); // { people: { age: 12 } }

总结

添加更新删除
Object.preventExtensionsx
Object.sealxx
Object.freezexxx

看完之后我们可以推断出来 Vuejs 中我们通过修改某个变量使 view 层更新的机制是在set访问器属性中实现的.

有个好消息据说 Vuejs3+以后会使用Proxy,因为使用Object.defineProperty会隐藏和忽略一些错误信息,对于调试来说是特别麻烦的,比如我们打错了这个属性的名字:

const obj = {
	firstName: 'abc',
	lastName: 'def',
	get fullName() {
		return this.firstName + this.lastName;
	},
	set fullName(val) {
		const [firstName, lastName] = val.split(' ');
		this.firstName = firstName;
		this.lastName = lastName;
	},
};

obj.fulName = 'a c'; // 打错了属性名不会报错

你会发现 JS 没有报任何异常,我们都知道直接给一个对象赋值是很正常的操作,在 JS 中对象几乎都是可以任意扩展的,因此 JS 直接忽略了这个在我们看来是"错误"的操作,我们也就无法及时排查这个 bug.这个错误在 ES6 中的class也存在,除非我们用上了TypeScript或者flow(推荐TypeScript,更像是一门静态语言).当然还有另外一个办法,就是使用ProxyAPI 来尽量避免,我会在后面再写一篇Proxy相关的博文介绍一下这个新 API.

引用

个人随笔记录,内容不保证完全正确,若需要转载,请注明作者和出处.