由浅入深-让你全方位搞懂JS中的对象!

396 阅读25分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

前言

什么是对象?我们在日常中面向对象的应用和处理是否全面?对象在每一步操作时的内部运作是怎样实现的?你是否遇到过面试官对对象的高频提问?

欢迎与我一起通过本篇全方位了解对象,从此做到面向JavaScript的对象得心应手~

1.对象的语法和类型

对象的定义方式有两种,分别是对象的声明形式和对象的构造形式。

//对象的声明形式
var newObj = {
  key: value,
};
//对象的构造形式
var newObj = new Object();
newObj.key = value;

事实上,我们很少在需要自定义属性的操作中去使用对象的构造形式,因为对象的构造形式中需要一个个去添加属性。

我们知道,在JavaScript中一共有6种主要类型,分别是:

string、number、boolean、null、undefined、object、symbol(ES6新加类型)。

上述的6种主要类型,除了object属于对象,其他都不是对象,很多人认为null属于一种“空”对象,毕竟typeof null的返回结果是字符串"object"但实际上null是一个拥有空指针属性的基本数据类型。

之所以typeof null会返回一个"object",是因为不同的对象在底层均表示为二进制,在JS中二进制前三位都为0的话,就会被判断为object类型,而null的二进制表示是全0,故会被判定为"object",这属于JS语言的"BUG"。

我们先着重地声明一个理念:函数属于对象的一个子类型

函数拥有对象的以下属性:

  • 可以像普通函数一样被调用。
  • 可以以携带参数对象的形式一般操作函数。

除此以外,JS中还存在一些内置对象,他们一部分看起来和基本数据类型一样,只是首字母用大写。

1.1 JS内置对象

  • String
  • Number
  • Boolean
  • Array
  • Function
  • Date (只能以构造形式创建,自动生成对应的结果,无声明形式)
  • regExp (此为正则表达式)
  • Error (抛出异常时自动构造,一般不做显示构造)

这些都是JS"与生俱来"的内置对象,他们可以用构造函数的形式来使用,且每一个内置对象里面还包含很多内置的函数方法和属性,用于执行对应的操作。

基本数据类型只是一个字面量,本质上是不能进行任何操作的,而且是不可变的。

但在我们的日常操作时,我们会习惯直接在这些字面量上执行获取长度,截取某个字符的操作,实际上这只是JS引擎自动帮我们把字面量转换为对象的JS内置对象,再执行其操作,最后返回一个操作结果给属性,生成一个新的字面量,但不会对原字面量进行任何改变,如果此时原字面量没有其它引用了,那么该原字面量就会被JS引擎执行垃圾回收。

var strPrimitive = "I'm Haruhi";
console.log(typeof strPrimitive); //"string"
console.log(strPrimitive instanceof String); //false
console.log(strPrimitive.length); //10
var strObj = new String("I'm Haruhi");
console.log(typeof strObj); //object
console.log(strObj instanceof String); //true

在执行strPrimitive.length的时候,JS引擎会自动把strPrimitive转换为new String(strPrimitive),再执行后续的操作。

instanceof操作符用于检查一个对象是否是一个类的实例。在JavaScript中,字符串字面量(如 "I'm Haruhi")和String对象是不同的。

2.对象的内容

对象的内容由存储在特定名称的值组成,而这些值我们称之为对象的属性。

存储在对象容器内部的并非是属性值本身,对象容器内部存储的只是这些属性值的“指针”。

充当“指针”的是这些属性的名称,属性值的名称指向属性值真正的存储位置。

属性访问和键访问

我们需要通过属性访问或者键访问的形式来寻找属性值的位置。

举例来说,newObj.a称之为属性访问,而newObj['a']称之为键访问,键访问支持任何UTF-8/unicode字符串作为属性名,但是键访问的属性名必须是一个字符串形式或者是Symbol。

(特此鸣谢岁月神偷~大佬提示的关键点:键访问的属性名也可以是一个Symbol,代码见下:)

let sy = Symbol(1);
let obj = { [sy]: 1 };
console.log(obj); //{Symbol(1): 1}

与此同时,我们可以通过自定义一个字符串字面量的属性,来执行对应的键访问。

var newObj = {
  a: 5,
};
var param = 'a';
console.log(newObj[param]); //5

如果我们想直接用一个数字来当做键名,键访问会直接将数字转换为字符串。

var newObj = {
  a: 5,
};
var param = 'a';
console.log(newObj[param]);  //5
newObj[2] = 'yes!';
console.log(newObj['2']); //'yes'
console.log(newObj);

newObject的打印内容见下,其属性名为2,但实际上是一个字符串“2”,对应的值为"yes"。

但是要注意!我们是在对象中将数字作为属性名,如果在数组中使用这种方法,数组下标(索引)会直接使用你填入的数字,而非创建一个新的属性名。

2.1 数组

数组使用的是一套更加结构化的值存储机制,不限制值的类型,数组的期望是索引,也就是所谓的数值下标,和对象的属性名对比,数组的索引要求是非负整数。

var newArr = ['yes', 3, 6];
newArr[3] = 'abc';
console.log(newArr.length); /4
console.log(newArr[0]); /'yes'
console.log(newArr[3]); //指向新创建的属性,其索引对应的属性值为'abc'

我们上述代码的代码,咋一看好像是利用数组索引来“新增”了一个属性,但实际上这里执行的操作是修改数组的内容,而非添加一个属性。

可能你会问,如果我们在以类似普通对象的键/值对的形式来在数组中添加属性,会发生什么事情呢?

事实上,这种方式确实可以,但是并不推荐这么做(官方也不推荐)。

var newArr = ['yes', 3, 6];
newArr.params = 'abc';
console.log(newArr.params); //'abc'
console.log(newArr.length); //3

我们可以看到,和上面修改数组索引增加属性的方法不同的是,我们确实在数组里面添加了一个属性,但是数组的长度并没有任何变化,也就是说我们这种操作——并没有修改数组内容。

JS对数组和普通对象是有一定的规范要求的:即普通对象最好利用键/值对来存储属性,数组则使用数值下标/值对来存储属性。

2.2 ES6的可计算属性名

ES6允许我们在声明对象的形式中,以[]包裹表达式来组合属性名。

var customStr = 'Yamato';
var newObj = {
  [customStr + 'Yuki']: 'key',
  [customStr + 'Haruhi']: 'world',
};
console.log(newObj['YamatoYuki']); //"key"
console.log(newObj['YamatoHaruhi']); //"world"

2.3 对象的属性和方法

有意思的是,我们经常会把访问对象内部的函数称之为访问对象内部的“方法”,事实上我们知道,一个对象的函数是不属于这个对象的,对象所拥有的只是函数的引用。

var newObj = {
  a: 2,
  findNum: function() {
    console.log(this.a); 
  },
};
newObj.findNum();//2

我们“似乎”利用了newObj里面的函数findNum()的this指向,使其指向的是newObj。 但实际上this属于运行时调用,它会根据调用位置而执行动态绑定,newObj并没有拥有函数findNum()。

而在这里,findNum()里面的this由于存在一个上下文对象,故this应用隐式绑定原则,将this绑定到这个上下文对象中,而newObj实际上拥有的只是findNum这个函数引用。

更多的关于this绑定的原理请看我写的这篇文章,传送门->juejin.cn/post/706347…

2.4 对象的复制

function copyFn() {
  console.log(this.a); //基于对象上下文将this绑定到了newObj
}
var copyObj = { a: 1 };
var copyArr = [5, 7, 9];
var newObj = {
  a: copyObj.a,
  quoteObj: copyObj, //单纯引用了copyObj对象
  quoteArr: copyArr, //单纯引用了copyArr数组
  quoteFn: copyFn, //拥有了copyFn的函数引用
};
newObj.quoteFn(); //1
copyObj.a = 2;
console.log(newObj.a); //1
console.log(newObj.quoteArr); //[5, 7, 9]
copyArr[3] = 'abc';
console.log(newObj.quoteArr); //[5, 7, 9, 'abc']

我们在谈到对象的复制时,不可避免地要讨论到对象的浅拷贝和深拷贝,对于类似于a这样的基本数据类型,如果采取对象的复制我们都是直接复制旧对象的值,两个对象中的a都是独立的。

但是对数组、对象、函数的“复制”行为实际上只是引用,引用过来的对象和旧对象是完全一致的,操作也是完全同步的,所谓的“复制”只不过是对象的一种浅拷贝行为。

深拷贝

事实上,我们完全可以通过一种完全隔离的复制方法来复制对象,那就是深拷贝:

 var newObj = JSON.parse(JSON.stringify(someObj));

这是一种非常典型的深拷贝方法,使得旧对象被序列化为一种JSON字符串,然后再将这个字符串解析出一个结构和值完全一致的新对象。

var copyObj = { a: 1 };
var newObj = {};
newObj.quoteObj = JSON.parse(JSON.stringify(copyObj));
copyObj.a = 5;
console.log(newObj.quoteObj.a); //1

当然,这种应用于深拷贝的方法只适用于对象,而不适用于函数。

另外,大家要始终牢记:对象内部的“函数”自始自终只不过是一个函数引用,函数内部的this指向只由调用位置决定。

浅拷贝

function copyFn() {
  console.log(this.a); //基于对象上下文将this绑定到了newObj
}
var copyObj = { a: 1 };
var copyArr = [5, 7, 9];
var newObj = {
  a: copyObj.a,
  quoteObj: copyObj, //单纯引用了copyObj对象
  quoteArr: copyArr, //单纯引用了copyArr数组
  quoteFn: copyFn, //拥有了copyFn的函数引用
};
var targetObj = Object.assign({}, newObj);
copyArr[3] = 6;
console.log(targetObj.quoteArr[3]); //6

Oject.assign()在ES6被定义为浅拷贝的一种复制方法,第一个参数是目标对象,后面可携带多个源对象,它对遍历源对象中的多个可枚举的自由键,并将他们用最基础的复制方法(targetObj.quoteArr=...)复制到目标对象。

但是源对象属性的一些特性(例如writable)不会被复制到目标对象。

本质上,Object.assign()就是对源对象的一种引用,针对源对象内部的对象只是一种引用关系,源对象或目标对象所产生的修改会进行双向同步。

var copyObj = { a: 1 };
var copyArr = [5, 7, 9];
var newObj = {
  a: copyObj.a,
  quoteObj: copyObj, //单纯引用了copyObj对象
  quoteArr: copyArr, //单纯引用了copyArr数组
  quoteFn: copyFn, //拥有了copyFn的函数引用
};
var targetObj = Object.assign({}, newObj);
targetObj.quoteArr[3] = 6;
console.log(copyArr[3]); //6

在这里笔者推荐大家可以进阶性地了解一下深拷贝和浅拷贝,比如浪里行舟大佬所写的这篇《浅拷贝与深拷贝》,传送门->juejin.cn/post/684490…

3.对象的属性

3.1 属性描述符

从ES5开始,对象内部的属性都拥有了属性描述符。

来看一段平平无奇的代码:

var newObj = {
  a: 1,
};
console.log(Object.getOwnPropertyDescriptor(newObj, 'a'));

我们看一下打印结果:

明明只是一个对象属性的简单声明形式写法,内部为何会有那么多特性呢?

事实上,这些就是这个普通的对象属性a的特性描述,里面包含了四个特性:

  • writable(可写特性)
  • emumerable(可枚举特性)
  • configurable(可配置特性)
  • value(属性值)

这是创建普通属性时,对应属性描述符的默认配置,我们实际上可以通过Object.defineProperty()来新增一个属性或者修改一个属性的属性描述符。

var newObj = {};
Object.defineProperty(newObj, 'a', {
  value: 3,
  writable: true,
  configurable: true,
  enumerable: true,
});
console.log(newObj.a); //3

writable

writable的布尔值决定了我们能否修改该属性的值,若设置为false,后续如果直接对属性的值(value)进行修改,修改也会默认为失败。

var newObj = {};
Object.defineProperty(newObj, 'a', {
  value: 2,
  writable: false,
  configurable: true,
  enumerable: true,
});
newObj.a = 4;
console.log(newObj.a); //2

当然,我们可以通过Object.defineProperty()反复修改writable的值。

configurable

configurable描述符应用于决定Object.defineProperty()是否能够对属性描述符的修改,无论Object.defineProperty()在何处将属性值的属性描述符configurable修改为false,将会造成以下结果:

其一,该属性值的内部属性描述符都不可更改。其二,那将是永久性的单向不可变更操作,configurable一经修改无法再改为true。

你可以理解为configurable就是针对Object.defineProperty()而存在的,因为一旦将configurable改为false,value和其他属性描述符就会被锁定,基本不可变更(writable只有在false改为true会报错,true改为false是允许的),你甚至不能删除这个属性值了!

var newObj = {};
Object.defineProperty(newObj, 'a', {
  value: 2,
  writable: true,
  configurable: false,
  enumerable: true,
});
/* 改不了你的属性描述符,那我删了你行不行! */
delete newObj.a; //删除无效
console.log(newObj.a); /* 输出为2 只要改了configurable,那对应的值就是一块牛皮糖! */
/* TypeError报错,操作不合法 */
Object.defineProperty(newObj, 'a', {
  value: 2,
  writable: true,
  configurable: true,
  enumerable: true,
});

打印结果:

但是请注意,本例中用的delete只针对于直接删除对象的属性,如果对象的属性是对象/函数的最后一个引用者,利用delete执行删除该属性的操作后,对象内关于对象/函数的引用将会被回收,但源对象/函数依然存在。

注意!delete不等于垃圾回收,delete就是字面意义上的删除,不代表被delete处理的值被回收了。

下面给出的情况是针对对象内部引用对象/函数的特例,属于上面所讲解的特殊情况(不但删除了,还被回收了,但是源对象不受影响):

var newObj = {};
var copyObj = { a: 1 };
newObj.copyObj = copyObj; //浅拷贝
Object.defineProperty(newObj, copyObj, {
  writable: true,
  configurable: false,
  enumerable: true,
});
delete newObj.copyObj;
 //newObj.copyObj是源对象的引用,原本不会删除,但是基于delete操作被回收了 
console.log(newObj.copyObj); // undefined
console.log(copyObj);  //{a: 1}

3.2 遍历

enumerable这个访问描述符主要用于对象的枚举,使用场景多为循环,例如forEach、for...in循环,如果该属性设置为false,将无法对其进行枚举。

可枚举就意味着可以出现在对象属性的遍历中。

for...in循环用于遍历对象的可枚举属性列表,你可以简单地理解为能够遍历出对象内部属性的属性名,其遍历的是可枚举属性,而非可枚举属性的值。

function copyFn() {
  console.log(this.a); //基于对象上下文将this绑定到了newObj */
}
var copyObj = { a: 1 };
var copyArr = [5, 7, 9];
var newObj = {
  a: copyObj.a,
  quoteObj: copyObj, //单纯引用了copyObj对象 */
  quoteArr: copyArr, //单纯引用了copyArr数组 */
  quoteFn: copyFn, //拥有了copyFn的函数引用 */
};
for (var objName in newObj) {
  console.log(objName); // a quoteObj quoteArr quoteFn
}

打印结果:

枚举的专用检测方法

我们还可以利用以下方法判定对象内的属性是否可枚举:

1.对象实例.propertyIsENumerable(args) ->传递参数为对象内的属性,以字符串的形式写入。

2. Object.keys(objArgs) ->传递参数为对象,返回一个装载了对象中可枚举属性的属性名的数组。

3.Object.getOwnPropertyNames(objArgs) ->传递参数为对象,不管对应的元素能否被枚举,都会返回一个装载了对象属性名的数组。

我们通过下面的代码来看一下上述枚举检测方法的实现:

var newObj = {};
Object.defineProperty(newObj, 'a', {
  value: 2,
  enumerable: false,
});
Object.defineProperty(newObj, 'b', {
  value: 5,
  enumerable: true,
});
console.log(newObj.propertyIsEnumerable('a')); //false
console.log(newObj.propertyIsEnumerable('b')); //true
console.log(Object.keys(newObj)); //['b']
console.log(Object.getOwnPropertyNames(newObj)); //['a','b']

对象实例.propertyIsENumerable(args) 会检查enumerable为 true的真实存在于对象中的属性。

Object.keys(objArgs) 会返回一个装载了对象中可枚举属性的属性名的数组。

Object.getOwnPropertyNames(objArgs)会返回数组中所有的属性,无论是否可枚举。

Object.keys(objArgs)和 Object.getOwnPropertyNames(objArgs)只会检查对象直接包含的属性,不会去检查对象的[[prototype]]原型链。

3.2.2 数组的遍历

我们可以直接用for循环遍历出数组对应的值,但实际的操作是遍历数组的下标,通过读取数组的下标来获取数组的值。

var newObj = {};
Object.defineProperty(newObj, 'a', {
  value: 2,
  enumerable: false,
});
Object.defineProperty(newObj, 'b', {
  value: 5,
  enumerable: true,
});
console.log(newObj.propertyIsEnumerable('a')); //false
console.log(newObj.propertyIsEnumerable('b')); //true
console.log(Object.keys(newObj)); //"b"
console.log(Object.getOwnPropertyNames(newObj)); //"['a','b']"

亦或者我们可以使用ES5提供的遍历辅助迭代器,比如forEach()、some()、every()。

每个遍历辅助迭代器都会接受一个回调函数,并将其应用到数组的每个值中。

forEach会遍历数组,直到遍历完毕才停下来,并且会忽略回调函数。

some会遍历数组,直到回调函数返回false,后面的遍历会停止。

every会遍历数组,直到对调函数返回true,后面的遍历会停止。

var newArr = [5, 7, 9, 1, 0, -1];
newArr.some((item) => {
  if (item > 3) {
    console.log(item); //5 7 9
  }
});
newArr.every((item) => {
  if (item > 3) {
    console.log(item); //5 
  }
});

3.2.2.1 for...of的应用与移植

ES6为我们提供了for...of来遍历对象的可枚举属性的属性值,只适用于数组的遍历

var newArr = [5, 7, 9, 1, 0, -1];
for (var i of newArr) {
  console.log(i); //依次打印出所有的属性值
}

for...of的遍历原理是首先向被访问对象请求一个迭代器对象,而数组有内置的@@iterator,在执行for...of时,它会先利用内置的@@iterator来遍历数组,再返回一个迭代器对象,我们可以利用这个迭代器对象的next()方法来访问对应的返回值。

var newArr = [5, 7, 9];
var item = newArr[Symbol['iterator']](); // Symbol['iterator']是一个返回迭代器对象的函数
/*item为迭代器对象*/
console.log(item.next());
console.log(item.next());
console.log(item.next());
console.log(item.next());

输出结果:

@@iterator本身是一个返回迭代器对象的函数,我们在引用iterator属性时需要一个符号名,而非符号内部的值,而Symbol用于生成一个全局唯一的值,利用Symbol可以避免数组内部的属性名冲突,顺利地输出所有的属性值。

调用迭代器对象的next方法会返回以上打印结果的值,当next()方法返回{value:undefined,done:true}时,代表已经没有可遍历的值了。

普通对象没有数组对象这样内置的@@iterator,所以没有办法完成for...of遍历。

但是,我不能保证你会不会遇到以下对话场景:

面试官:我让你在普通对象上写一个for...of遍历。

小白:啊?for...of不能在普通对象上用你不知道吗?只能用在数......

面试官(怒):你在教我做事!?我就要你在普通对象上写一个能够生效的for...of遍历!

小白:......

不用慌!现在我们就可以参照《你不知道的JavaScript》上卷,将数组的for...of移植到普通对象上面去。

var myObject = { a: 1, b: 2 };
Object.defineProperty(myObject, Symbol.iterator, {
  enumerable: false, //不可枚举
  writable: false, //不可写
  configurable: true, //可配置
  /* 我们在这里手动撰写iterator方法 */ 
  value: function () {
    var o = this;
    var idx = 0;
    var ks = Object.keys(o); //返回数组["a","b"]
    return {
      next: function () {
        return { value: o[ks[idx++]], done: idx > ks.length };
      },
    };
  },
});
/* 手动遍历myObject */ 
var it = myObject[Symbol.iterator]();
console.log(it.next());
console.log(it.next());
console.log(it.next());

输出结果:

我们在调用var it = myObject[Symbol.iterator]();的时候,实际上相当于在 Symbol.iterator生成了一个闭包函数,使得o,idx,ks的值被保留,并在后续的next()调用中,next()每被调用一次,闭包里面的next方法都会保留上一次执行时idx的值,从而进行下一轮循环。

好了,现在我们已经在普通对象内创建了数组才拥有的迭代器对象和@@iterator函数,这意味着我们可以在普通对象内应用for...of遍历了。

for (var v of myObject) {
  console.log(v); //依次输出1 2
}

只要迭代器对象会返回{value: undefined, done: true},那就意味着for...of可以生效。

3.3 对象的不可变性

3.3.1 利用Object.defineProperty() 设置常量

var newObj = {};
Object.defineProperty(newObj, 'a', {
  writable: false,
  configurable: false,
  enumerable: true,
  value: 2,
});

我们先是将writable设定为不可写的状态,然后再通过设置configurable的值为false,彻底断绝了后续利用Object.defineProperty()来更改writable的机会,通过这样简单的操作令其变为一个常量。

3.3.2 禁止对象内部属性的扩展

我们可以利用Object.preventExtensions()来针对对象禁止扩展,只要把对象作为参数传入Object.preventExtensions(),那么对象将无法进行扩展。

var newObj = {};
Object.defineProperty(newObj, 'a', {
  writable: true,
  configurable: true,
  enumerable: true,
  value: 2,
});
Object.preventExtensions(newObj);
newObj.b = 5;
console.log(newObj.b); //undefined

3.3.3 对象的密封

var newObj = {};
Object.defineProperty(newObj, 'a', {
  writable: true,
  configurable: true,
  enumerable: true,
  value: 2,
});
Object.seal(newObj);

对象的密封Object.seal()实则是在现有对象上先调用Object.preventExtensions(),然后将对象内部所有的现有属性的configurable设置为false,意味着当前对象既不可新增,也不可再次配置(configurable变更为false)。

唯一能变更的就是属性值的修改(writable可配置)。

3.3.4 最终大杀器——对象的冻结

如果说以上的都是“常规武器”,那么对象的冻结Object.freeze()绝对是对象不变性的集大成者——“核武器”,但实际上也是在现有对象上先调用Object.seal(),再将对象内所有属性的writable设置为false的层层套娃。

然而Object.freeze()的应用也有对应的豁免权——那就是对象若引用了其他函数/对象,则源函数或源对象不受任何影响。

3.4 对象属性的Get和Put

我们在对象上访问某个属性时,实际上采取了[[Get]]操作,对象内置的[[Get]]操作会在对象中查找对应的属性,当找到这个属性时会返回这个属性的值。若是没有在“明面”上找到这个属性,则会执行对[[prototype]]链进行深一步的查找操作。

如果到最后都没找到这个值,则会返回一个undefined。

如何解决这个问题,后面的3.4.2章节会讲解这个问题,别急,我们继续往下走。

在这里,请区分一下对象内查找属性和访问常规变量的区别,那就是对象内查找属性找不到时,会默认返回undefined,而我们在访问常规变量查不到属性时,会返回一个RHS查询结果->抛出ReferenceError报错。

var newObj = {
  a: undefined, 
};
console.log(newObj.a); //undefined
console.log(newObj.b); //undefined->我都没有这个值,你怎么就给我默认创建了?
console.log(c); //常规变量在全局作用域不存在时,返回ReferenceError报错

同时,如果我们在对象查找找某一个属性时,你无法判断这个属性的值究竟是undefined还是并不存在,都会给你一箩筐地返回一个undefined的结果,坑爹呐!

很多人印象里面,对象的[[Put]]是用来设置或者创建属性的,比如newObj.a=3即为一个[[Put]]算法操作的结果,但是这种认知是不完全正确的。

对于[[Put]]来说,最关键的因素在于对象内部的属性是否存在,[[Put]]算法的操作如下:

  1. 先进行检查——属性是否通过访问描述符所构建的?如果是,那会查找访问描述符里面是否存在setter?只有在存在的情况下,调用setter,步骤2操作不再执行。
  2. 属性的数据描述符writable是否是false?若是,对应的操作则直接失败,严格模式下则返回TypeError报错,步骤3操作不再执行。
  3. 属性若不是访问描述符,且数据描述符的writable为true,那么将执行[[Put]]操作,把这个值设置为对象的属性值。

3.4.1 访问描述符:Getter和Setter

在ES5规范里面,我们可以针对对象的单个属性利用getter和setter来改写该属性的操作。

getter和setter都是对象内部的隐藏函数,getter会在获取对象内部的单个属性值时调用,而顾名思义,setter会在设置对象的单个属性值时使用。

我们定义对象内部属性的访问描述符时,并不是该属性存在getter和setter其中一种的情况下,我们就定义对象内部存在该属性的访问描述符,只有在该属性内都存在setter和getter的情况下,这个属性才会被定义为访问描述符。

JavaScript在遇到对象属性的访问描述符时,会自动忽略数据描述符内部的writable和value特性,只关心数据描述符的configurable(可配置)和enumerable(可枚举)特性,以及对象属性定义时的get和get特性。

上述这点特别重要,一定要紧紧牢记对象属性描述符的规则。我们在写代码的过程中,实际上就是对JavaScript规则的应用,这和选用哪种框架没有必然的联系,而是意味着我们必须要基于JavaScript的蓝图对项目“施工”。

我们对对象属性定义getter有两种操作,一个是基于声明形式的常规配置,一个是基于数据描述符的配置操作:

//声明形式构建
var newObj = {
  /* 给a定义getter特性 */
  get a() {
    return 3;
  },
};
//数据描述符构建
Object.defineProperty(newObj, 'b', {
  /* 描述符,给b定义值,利用隐式绑定的基于对象上下文原理,利用this访问a */
  get: function () {
    return this.a + 5;
  },
  enunmerable: true /* 使得该对象的属性b可枚举 */,
  configurable: true /* 使得该对象的属性b可配置,一般不用写 */,
});
console.log(newObj.a, newObj.b); //3 8

两种构建方式都不会直接创建属性的值,而是先创建一个属性,然后自动调用一个隐藏函数,利用其内部的返回值作为属性的值。

我们在前面说过,getter和setter只有“成双成对”时,才能构成访问描述符,如果我们只构建了getter,对对象属性值进行赋值操作时,赋值操作会被自动忽略。

而且诸位发现一件事情没有,那就是我们在上述的getter操作中——属性的值是直接被写死的。

这就意味着就算我们定义了setter,对应属性值只会按照getter里面写死的属性值进行返回,setter的操作是无效的。

var newObj = {
  /* 给a定义getter特性 */
  get a() {
    return 3;
  },
};
newObj.a = 2;
console.log(newObj.a);  //3 变更默认无效

我们在这里定义setter,当我们定义访问描述符的setter操作时,其优先级会覆盖单个属性的[[Put]]操作。

var newObj = {
  /* 给a定义getter特性 */
  get a() {
    return this.param;
  },
  set a(val) {
    /* 对传进来的值进行进一步处理 */
    this.param = val + 3;
  },
};
newObj.a = 2;
console.log(newObj.a); //5

3.4.2 对象属性的存在性

为了判断对象中是否存在属性值的问题,我们可以用两种方法来解决,看下述的代码:

var newObj = {
  a: 2,
};
console.log('a' in newObj); //true
console.log('b' in newObj); //false
console.log(newObj.hasOwnProperty('a')); //true
console.log(newObj.hasOwnProperty('b')); //false

in检查和hasOwnProperty()检查的区别在于in操作符会检查属性名是否在对象之中,如果不在则会继续深入[[prototype]]原型链进行检查,而hasOwnProperty()只会检查对象本身,不会深入去检查对象的[[prototype]]原型链。

事实上,我们也可以应用hasOwnProperty()来检查对象的原型链,即Object.prototype.hasOwnProperty.call(newObj, 'a');

利用对象顶层的构造函数Object的[[prototype]],这是基于[[prototype]]原型链上的hasOwnProperty()方法,通过显式绑定的方法将其绑定至newObj,然后查找上面对应的属性a。

如果in在普通对象中通过查找属性名来查找对应的属性是否存在,那么在数组中是如何操作的呢?对于数组来说,in操作中传入的“属性名”对应的是什么呢?

没错,正是数组的下标。

var newArr = [1, 3, 5];
console.log(2 in newArr); //true
console.log(5 in newArr); //false

for...in循环最好不要在数组上使用,因为for...in会循环展示出所有可枚举的属性,最好用for来直接遍历数组下标。

var newArr = [1, 3, 5];
Array.prototype.newFn = function () {
  console.log('我是来捣乱的!');
};
for (var i in newArr) {
  console.log(i);
}

输出结果:

使用for...in遍历会导致遍历的对象不仅仅包括newArr这个字面量,由于我们对newArr执行了for...in操作,JS引擎会“热心”地帮我们把字面量转换为Array这个内置对象进行后续的操作。

直接导致了我们继续查到了数组的原型上寻找属性值,坑爹啊!所以千万别用for...in来遍历实际的数组。

总结

  1. 基本数据类型并非是对象,只是字面量,但是基于字面量去调用对象的内置方法时,会暂时将字面量变更对对应类型的对象,但不会改变字面量本身。
  2. 对象的内容一般并没有存储在对象里面,存在里面的都是对象属性的名称(指针),指向属性的真正存储位置。
  3. 数组也是对象,如果直接以非数值的形式为数组添加属性,数组的内容并未被修改,长度不会发生变化。如果以类似数值的形式(字符串数值,newArr['3']),数组会自动将其转换为数值,并应用数组内容的修改,数组的长度发生变化。
  4. 对象的属性描述符包含了writable(可写),enumerable(可枚举),configurable(可配置),value(属性值)四个特性,我们可以通过Object.defineProperty()来定义上述四个特性。
  5. inhasOwnProperty()的区别在于是否会查找对象的[[prototype]]原型链。
  6. Object.keys()Object.getOwnPropertyNames()都只会查找对象内直接包含的属性,前者只会查找对象内可遍历的属性,后者则是全部展示对象的属性。
  7. for...of循环首先会向被访问对象请求一个迭代器对象,然后再通过迭代器对象的next()来依次遍历值,直到输出{value:undefined,done:true}才会“收手”。
  8. 普通对象是键值对的集合,数组对象是数值下标/值对的集合。
  9. 属性内部不一定包含值本身,他们可能是具备setter/getter的访问描述符,可以通过访问描述符对属性值进行查找和修改。