0%

js对象之二

对象的声明

对象可以通过两种形式定义:声明形式和构造形式。

声明形式:

1
2
3
4
var obj = {
age: 18,
weight: 60,
};

构造形式:

1
2
3
var obj = new Object();
obj.age = 18;
obj.weight = 60;

声明形式和构造形式生成的对象是一样的,唯一的区别是在声明形式中可以添加多个键值对,但是在构造形式中必须逐个添加属性。

JavaScript中的类型

在JavaScript中一共有六中类型:

  • string
  • number
  • boolean
  • null
  • undefined
  • object

简单的基本类型都不是对象,但null有时会被当做一种对象类型。这是语言的一个bug。原理是,不同的对象在底层都表示为二进制,在JavaScript中二进制的前三位都为0的话会被判断为object类型,null的二进制全是0,自然前三位也是0,所以执行typeof的时候,会返回“object”。

JavaScript中有许多特殊的对象子类型。

函数就是对象的一个子类型。JavaScript中函数是“一等公民”,因为它们和普通的对象一样可以调用,所以可以像操作其他对象一样操作函数。

数组也是对象的一种类型,具备一些额外的行为。

内置对象

JavaScript中还有一些对象子类型,通常被称为内置对象。内置对象有:

  • String
  • Number
  • Boolean
  • Object
  • Function
  • Array
  • Date
  • RegExp
  • Error

这些内置对象看起来很像其他语言中的语言类型或者类,但在JavaScript中,他们实际上只是一些内置函数。这些内置函数可以当做构造函数使用,从而构造一个对应子类型的新对象。例如:

1
2
3
4
var strObj = new String('I am Iron Man');
var str = 'I am Iron Man';
console.log(strObj.length); // 13
console.log(str.length); // 13

上面我们使用构造方式创建了一个字符串对象strObj,在这个上面可以使用length()方法。然后我们直接使用了字符串定义了一个变量,而这个变量也具有字符串对象的一些方法。这是为啥?

原始字符串'I am Iron Man'并不是对象,只是个字面量,如果我们要在这个字面量上进行一些操作,那就需要将其转换为String对象。好处是,JavaScript引擎会自动将字面量转换为String对象,所以可以访问String对象的属性和方法。

引擎会简单帮助我们自动转换我们需要的变量类型,这样是很方便,但也会造成一些问题,比如一个字符串和一个数字相加,可能得到很意外的值,所以我们需要在特殊的地方进行手动转换,当然,这个后面自然会重点列出来。

null和undefined只有文字形式,没有对应的构造形式;相反,Date只有构造形式,没有文字形式。

对于Object、Array、Function和RegExp来说,无论使用文字形式还是构造形式,他们都是对象。相比用文字形式,使用构造形式可以提供一些额外的选项,当然,为了方便我们首选文字形式,只是建议在需要那些额外选项时使用构造形式。

内容

对象中的内容是存储在特定命名位置的(任意类型)值组成的,称为属性。

对象中的内容是多样性的,存储在对象中的只是这些属性的名称,就像指针,如果是存储的对象的话,那只是这个对象的引用;而这些指针,指向的才是内容的真正的存储位置。

访问对象的属性的时候,可以使用.操作符或者[]操作符。例如:

1
2
3
4
5
var obj = {
age: 18
};
console.log(obj.age); // 18
console.log(obj['age']); // 18

两种访问方式访问的是通一个位置,同一个值,这两种访问方式是等效的。常把.操作符方式成为“属性访问”,[]操作符方式称之为“键访问”。

这两种访问方式的主要区别在于:.操作符要求属性名满足标识符的命名规范,而[]操作符可以接受任意UTF-8/Unicode字符串为属性名。而且,由于[]操作符接受字符串来访问对象的属性,所以我们可以在程序中动态的访问,比如:

1
2
3
var obj = {a: 2};
var name = 'a';
console.log(obj[name]); // 2

在对象中,属性名永远都只能是字符串,如果使用string以外的其他值作为属性名,那么它首先会被转换为一个字符串,即使是数字也不例外。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
var obj = {
test: 33,
2: 'is number',
0: 0,
true: 'tony',
null: 'not null',
undefined: 'not undefined',
};
console.log(obj[0]); // 0
console.log(obj[2]); // is number
console.log(obj['null']); // not null
console.log(obj['undefined']); // not undefined
console.log(obj[null]); // not null

属性描述符

在ES5之前,JavaScript语言本身并没有提供可以直接检测属性特性的方法,比如判断属性是否是只读。但是从ES5开始,所有的属性具备了属性描述符。例如:

1
2
3
4
5
6
7
8
9
10
let myObj = {
a: 2,
};
console.log(Object.getOwnPropertyDescriptor(myObj, 'a'));
/**
configurable: true 可配置
enumerable: true 可枚举
value: 2 数据值
writable: true 可写
**/

可以看到,这个普通的对象属性对应的属性描述符除了包含数据值,还包含其他三个特性:writable(可写)、enumerable(可枚举)、configurable(可配置)。

在创建普通属性时属性描述符会使用默认值,我们可以使用Object.defineProperty()来添加一个新属性或者修改一个已有属性(如果它是configurable)。例如:

1
2
3
4
5
6
7
8
9
10
let myObj = {
a: 2,
};
Object.defineProperty(myObj, 'b', {
value: 'customer define property',
writable: true,
configurable: true,
enumerable: true,
});
console.log(myObj.b); // customer define property

我们使用了Object.defineProperty()方法给对象添加了一个属性。然而,一般不会使用这种方式创建属性,除非你想修改属性描述符。

需要注意的是,在ts中,这种手动创建的属性会通不过预检,无法使用.操作符的方式访问创建的属性,只能通过[]操作符访问。

1. writable 可写

writable属性描述符决定是否可以修改属性的值。例如我们将上面那个添加的属性配置为不可写:

1
2
3
4
5
6
7
Object.defineProperty(myObj, 'b', {
value: 'customer define property',
writable: false,
configurable: true,
enumerable: true,
});
myObj['b'] = 'test'; // TypeError 无法给只读属性分配值

2.configurable 可配置

只要属性是可配置的,就可以使用defineProperty()来修改属性描述符,例如:

1
2
3
4
5
6
7
8
9
10
let myObj = {
a: 2,
};

Object.defineProperty(myObj, 'a', {
value: 'customer define property',
writable: true,
configurable: false, // 不可配置
enumerable: false,
});

这里不禁有疑问,前面设置为不可配置,后面改回来不就可以了?然而,这个操作是不可逆的,前面设置为不可配置,后面再设置为可配置的时候就产生TypeError,所以这个操作是单向操作,是不可逆的。

除了无法修改,configurable: false还会禁止删除这个属性。

3.enumerable 可枚举

这个描述符控制属性是否会出现在对象的属性枚举中,比如for...in循环。如果将enumerable设置为false,仍然可以正常访问,但是无法出现在枚举中。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let obj = {
a: 'tony',
b: 'is',
c: 'super',
d: 'hero',
e: '!',
};

Object.defineProperty(obj, 'c', {
enumerable: false,
});

for (let i in obj) {
console.log(obj[i] + ' ');
}
/**
最后输出:tony is hero!
属性c被设置为不可枚举,所以无法出现在for...in循环中。
**/

在数组上应用for...in循环有时候会产生出人意料的结果,因为这种枚举不仅仅会包含所有数值索引,还会包含所有可枚举属性。最好只在对象上应用for...in循环,如果要遍历数组就是用传统的for循环来遍历数值索引。

对象内容不可变

有时候会希望属性或者对象是不可变的,一般很少需要这种深不可变性,但有些特殊 情况可能需要这样做。在ES5中有多种方式可以实现。

1.对象常量

结合writable:falseconfigurable:false就可以创建一个真正的常量属性(不可修改、重定义或删除)。例如:

1
2
3
4
5
6
let myObj = {};
Object.defineProperty(myObj, 'FAVORITE_NUMBER', {
value: 42,
writable: false,
configurable: false,
});

2.禁止扩展

我们需要禁止对一个对象添加新的属性并保留已有属性,可以使用Object.preventExtensions()来禁止扩展:

1
2
3
4
5
let myObj = {
a: 2,
};
Object.preventExtensions(myObj);
myObj['c'] = 1; // TypeError

3.密封

使用Object.seal()会创建一个密封的对象,这个方法实际上会在一个现有对象上调用Object.preventExtensions()并把所有属性标记为configurable:false

所以,密封后的对象不可以添加新属性,也不可以重新配置或删除任何现有属性(可以修改属性的值)。例如:

1
2
3
4
let myObj = {
a: 2,
};
Object.seal(myObj); // 查看属性描述符可以看到configurable被设置为false。

很奇怪的一个现象是,我的测试环境是:angular5,使用了es6的环境,手动将一个对象的configurable设置为false的时候会不成功。但是调用了密封方法后,configurable是变成了false,很奇怪。

4.冻结

Object.freeze()会创建一个冻结对象,这个方法实际上会在一个对象上调用Object.seal(),并把所有数据访问属性标记为writable:false,这样就无法修改他们的值。

这个方法是可以作用在对象上的级别最高的不可变性,它会禁止对于对象本身及其任意直接属性的修改,但是对于这个对象引用的其他对象是不受影响的。

Getter和Setter

在了解Getter和Setter之前,先了解下对象默认的[[Put]]和[[Get]]操作。

比如我们遇到代码myObj.a;的时候,看起来好像是在myObj中查找名字为a的属性。但实际上,myObj.amyObj上实际上是实现了[[get]]操作。对象默认的[[get]]操作首先在对象中查找是否有名称相同的属性,如果找到就返回这个属性的值;如果没找到就去遍历可能存在的[[prototype]]原型链上查找;如果无论如何都没找到,那么[[get]]操作就返回undefined。

既然有获取属性的[[get]]操作,那么也就有赋值的[[put]]操作。

[[put]]被触发时的实际行为取决于许多因素。

属性存在的情况下,[[put]]操作会检查下面这个这些内容:

  1. 属性是否是访问描述符?如果是并且存在setter就调用setter。
  2. 属性的行为描述符中writable是否为false,如果是false,会抛出TypeError异常。
  3. 如果都不是,将该值设置为属性的值。

如果对象中不存在这个属性,[[put]]操作会更复杂。这个涉及到原型链查找,暂时先搁置。

相对于对象默认的[[get]]和[[put]],ES5中可以使用setter和getter部分改写默认操作,但是只能应用在单个属性上。它们都是隐藏函数,setter会在设置属性值的时候调用,getter会在获取属性值的时候调用。getter被称为“访问描述符”,setter被称为“数据描述符”。

getter,访问描述符

当给一个属性定义了访问修饰符时,JavaScript会忽略它们的value和writable特性,取而代之关心的是configurable和enumerable特性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let myObj = {
// 给a定义一个getter
get a() {
return 2;
}
};
console.log(myObj.a); // 2
Object.defineProperty(myObj, 'b', {
// 显示给a定义getter
get: function() {
return this.a * 2;
}
});

console.log(myObj['b']); // 4

可以看到,不管是在文字语法中的get a(){},还是在defineProperty()中显示定义,都会在对象中创建一个不包含值的属性,对于这个属性的访问会自动调用一个隐藏函数,它的返回值会被当做属性访问的返回值。

setter,数据描述符

如果只定义了getter,那么将出现无法赋值的情况。一般setter和getter是成对出现的。我们给上面的a属性定义一个setter:

1
2
3
4
5
6
7
8
9
10
let myObj = {
get a() {
return this._a_;
},
set a(v) {
this._a_ = v * 2;
}
};
myObj.a = 3;
console.log(myObj.a); // 6

特别要注意,当定义setter时需要定义一个私有变量来存储对应的值,而我们的这个属性就变成了一个访问符号。

存在性

直接访问对象属性的时候,如果属性不存在会返回undefined。但是一种极端的情况是有可能属性返回的就是undefined。那么如何判断属性是否存在于对象中呢?

我们可以使用in操作符或者对象的内置方法hasOwnProperty来判断。例如:

1
2
3
4
5
var obj = { a: 1};
('a' in obj); // true
('b' in obj); // false
obj.hasOwnProperty('a'); // true;
obj.hasOwnProperty('b'); // false;

他们两个会有一点点区别,in操作符会检查属性是否在对象及其原型链中,相比之下,hasOwnProperty只会检查是否在当前对象中,而不会检查对象的原型链。例如:

1
2
3
4
var obj1 = {a: 1};
var ob2 = Object.create(obj1);
('a' in obj2); // true
obj2.hasOwnProperty('a'); // false

枚举

属性的可枚举性相当于“可以出现在对象属性的遍历中”。可枚举性是在defineProperty中定义的,比如:

1
2
3
4
5
6
7
8
var obj = {a: 1, b: 2};
Object.defineProperty(
obj,
'a',
{enumerable: false}
);
console.log(obj.b) // 2
console.log(Object.keys(obj)); // ['a']

可以看到,obj.b确实有值,但是在Object.keys()返回的对象的键中看不到b这个属性。这是因为b属性已经被我们设置为不可枚举了。相同的还有for...in循环,不可枚举的属性也不会出现在循环中。Object.keys()会返回一个数组,包含所有可枚举属性,Object.getOwnPropertyNames()会返回包含所有属性的数组,无论它是否可枚举。

1
console.log(Object.getOwnPropertyNames(obj)); // ['a', 'b']

可以通过内置方法propertyIsEnumerable()方法来检查给定的属性是否直接存在于对象中(而不是原型链中)并且满足enumerable: true

遍历

for...in循环可以用来遍历对象的可枚举属性列表,常见的标准for循环可以遍历数值索引的数组,但这实际上是在遍历下标来指向值。ES5中增加了一些数组的辅助迭代器,包括forEach()every()some,每种辅助迭代器都可以接受一个回调函数并把它应用到数组的每个元素上,唯一的区别就是他们对于回调函数返回值的处理方式不同。具体的可以看这篇文章:数组迭代器操作

那么有什么方法可以直接遍历值呢?

ES6增加了一种用来遍历数组的for...of循环语法,for...or循环首先会向被访问对象请求一个迭代器对象,然后通过调用迭代器的next()方法来遍历所有返回值。

对象复制

为了测试方便,我们先构造一个复杂对象,里面有原始值、数组、对象、方法:

1
2
3
4
5
6
7
8
var x = {
a: 1,
b: { f: { g: 1 } },
c: [ 1, 2, 3 ],
d: function(){
return 1;
}
};

json复制

对于JSON安全(可以被序列化为一个JSON字符串并且可以根据这个字符串解析出一个结构和值完全一样的对象)的对象来说,有一种很巧妙的复制方法:

1
var newObj = JSON.parse(JSON.stringify(obj));

Json对象可以借助parse方法可以将JSON字符串反序列化为一个js对象,而JSON.stringify()方法可以将js对象序列化为一个Json字符串,借助这个两个方法,可以实现对象的深拷贝。但是,这样只拷贝数据结构和对应的值,会忽略对象的方法,也会忽略不可枚举的属性。例如:

1
2
3
4
5
6
7
8
9
10
11
function jsonClone(obj) {
return JSON.parse(JSON.stringify(obj));
}

Object.defineProperty(x, 'e', {
enumerable: false,
value: 3,
});
var y = jsonClone(x);
x.b.f.c = 1;
console.log(y); // y为包含a,b,c的属性和值的对象,同时y.b.f.c不存在

浅复制

通过for..in循环对象的属性来达到简单的浅复制,因为这种拷贝对对象里面的对象只是引用复制,修改拷贝的对象还是会拷贝后的对象造成影响:

1
2
3
4
5
6
7
8
9
10
function shallowClone(copyObj) {
var obj = {};
for ( var i in copyObj) {
obj[i] = copyObj[i];
}
return obj;
}
var y = jsonClone(x);
x.b.f.c = 1;
console.log(y); // y为包含a,b,c,d属性的对象,同时y.b.f.c存在,并且等于1

可以看到浅拷贝的对象在修改了父对象后,还是会对自己产生影响。

深拷贝

深拷贝是分类型递归调用了浅拷贝的方法。深拷贝的时候,前提的是需要确定属性的类型,我们知道js有五种数据类型:number、string、boolean、undefined、object、function。那我们如何精确的知道属性的类型呢?

我们需要借助原生原型扩展函数:Object.prototype.toString.call(),这个函数可以明确的输出类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
var a = [null, undefined, false, 1, '',  [], {}, function(){ return 1}];

a.forEach(function(item){
console.log(Object.prototype.toString.call(item));
});
// [object Null]
// tony:4 [object Undefined]
// tony:4 [object Boolean]
// tony:4 [object Number]
// tony:4 [object String]
// tony:4 [object Array]
// tony:4 [object Object]
// tony:4 [object Function]

后续更新脑阔疼,不想搞了考虑分离出一个单独的博客来写深拷贝!

码字辛苦,打赏个咖啡☕️可好?💘