对象
1、创建对象
对象是由一系列的属性(Properties)构成的,而每个属性由属性名和值并以键值对的形式进行定义。创建对象可以直接使用字面值,只需编写一对大括号,然后在里边定义用逗号分隔的多个属性。属性名可以是数字、字符串或符号(Symbol)类型,如果遵守了标识符规范,则属性名可以省略引号;如果不符合标识符规范,例如有特殊字符,则必须使用带引号的字符串形式。属性的值则可以是任何数据类型,属性名和属性值之间使用冒号隔开。例如一个博客文章对象的定义方式,代码如下:
js
const blogPost={
id:1,
title:"JavaScript教程",
getSlug:function(){
return"/post/"+this.title;
},
"updated-at":"2020-10-26",
};
可以用对象表示各种各样的实体,例如坐标系中的点、用户信息等,代码如下:
js
const point={x:10,y:20};
const user={id:1,username:"john","created-at":"2020-08-07"};
使用字面值创建对象时还需要注意一点,属性值必须是表达式,不能是语句。如果在全局作用域下定义对象字面值,并在:后边使用了语句,则它整体会被当成一个语句块,里边则不是键值对,而是打了标签的语句,例如下方代码是合法的,程序也不会出错,但是定义的并不是对象,代码如下:
js
{
a:let b=2;
}
把字面值保存到一个变量中就能更明确地认识这个问题,因为JavaScript会提示错误,代码如下:
js
const obj={
a:let b=2;//语法错误:非法标识符
}
1.1、简化属性
在ES6中,如果属性值使用了变量的值,且属性名和变量一样,则可以直接写上变量名并省略冒号和属性值,例如如果博客文章的标题是从用户输入中获取的,并且保存在title变量中,那么在定义blogPost对象时,可以直接使用title变量名作为属性名,使用title的值作为属性值,代码如下:
js
const title=getUserInput();
const blogPost={
title, //相当于title:title
//其他属性
}
这种情况也适用于当函数有多个返回值的情况,可以把它们放到一个对象中,而对象中的属性可以直接使用保存了返回值的变量,代码如下:
js
function setup(){
let name="Button";
let onClick=()=>{/*事件处理过程*/};
return{name,onClick}
}
对于函数类型的属性,也可以省略function关键字和冒号,例如blogPost中的getSlug()方法,代码如下:
js
const blogPost={
getSlug(){
return"/post/"+this.title;
},
//其他属性
}
1.2、计算属性名
有时,对象在创建的时候,并不知道属性的名字,而是使用变量的值设置的动态属性名,这种情况下可以使用计算属性名(ComputedProperty Names),这样在创建对象的时候,属性名会根据变量的取值而定。例如假设博客文章对象中有用户自定义的描述信息,使 用计算属性名定义的代码如下:
js
const customProperty="price";
const customValue="12.00";
const blogPost={
[customProperty]:customValue,
//其他属性
}
这个blogPost对象中就有了price属性,并且它的值为"12.00"。同样地,方法名也可以动态生成,代码如下:
js
let prop="a";
const obj={
[prop]:1,
[`get${prop.toUpperCase()}`](){
return this[prop];
}
}
它最终生成的对象如下:
js
{
a:1,
getA(){
return this[prop];
}
}
对于计算属性,它会在定义的地方立即进行计算并得到属性名,而不是在访问对象属性的时候才去计算,代码如下:
js
let testArr=[];
let obj={
[testArr.push(0)]:testArr.push(1),
[testArr.push(2)]:testArr.push(3),
};
console.log(obj);//{'1':2,'3':4}
Array中的push方法会将新元素原地添加到数组中,并返回数组的最新长度,这里可以从结果中看出,属性名和属性值是按照从左到右和从上到下的顺序进行计算的,这就需要注意在开发中,如果计算属性名原地修改了一些数据,则需要考虑会不会影响属性的取值。
2、访问与添加对象属性
要访问对象中的属性值,可以使用.加上属性名的方式,它会返回该属性所保存的值,或调用该属性所保存的方法。如果访问了不存在的属性,则它会返回undefined。例如访问blogPost对象中的属性,代码如下:
js
const blogPost={
id:1,
title:"JavaScript教程",
getSlug:function(){
return"/post/"+this.title;
},
"update-at":"2020-10-26",
};
blogPost.title; //"JavaScript教程"
blogPost.getSlug(); ///post/JavaScript教程
blogPost.author; //undefined
不过,不能使用.访问数字类型、以数字开头或有非法字符的属性,要访问它们,需要使用[]语法,就像访问数组元素一样,而对于合法的属性名也可以使用它访问,代码如下:
js
blogPost["update-at"];//"2020-10-26"
blogPost["title"]; //"JavaScript教程"
给对象添加属性跟访问属性的语法相同,只需使用等号给属性赋值,例如给blogPost加上author和comments属性,代码如下:
js
blogPost.author="李明"; //使用.语法
blogPost["comments"]=["很好","受教了","加油"];//使用[]语法
2.1、对象与数组
在这里学到使用[]语法给对象添加属性或访问对象中的属性时,难免会想到它和数组元素的访问和赋值有什么关系。其实数组本身是一种特殊的对象,使用[]语法访问数组中的元素就等同于访问对象中的属性。因为以数字或以数字开头的属性不能直接使用.号访问,所以数组必须使用[]访问其中的元素,代码如下:
js
let obj={0:"a"}; //普通对象
obj.0; //语法错误
obj[0]; //"a"
let arr=["a","b"];
arr.1; //语法错误
arr[1]; //"b"
可以看到在对象和数组中,使用.号访问数字类型的属性都是不允许的,需要使用[],但是这里需要注意的是,虽然在[]中直接使用了数字类型的属性名,例如arr[1],但是它会隐式地转换为字符串类型,因为无论是对象中的属性还是数组中的索引最终都是以字符串的形式存储的,所以下方示例也可以访问对象的属性或数组的元素,代码如下:
js
obj["0"];//"a"
arr["1"];//"b"
要证明不是把字符串转换成了数字,可以使用布尔类型的true作为数组的索引进行访问,如果被转换成数字,则true应该可以被转换为1,对于arr[true]应该也会返回"b",但是事实并非如此,arr[true]会返回undefined,因为arr[true]会被转换成arr["true"]进行访问,而数组中并没有这样的属性或元素。
如果给数组赋值时使用了非数字的索引,则它会作为属性添加到数组对象中,而不是作为元素添加到数组中,代码如下:
js
let books=["JavaScript","Java","Rust"];
books["name"]="编程语言"; //或使用books.name="编程语言"
books; //["JavaScript","Java","Rust",name:"编程
//语言"]
2.2、Array-like类数组对象
一个普通对象也可以按数组的方式进行使用,使用数字类型的属性名,再自行维护一个length属性,这样就形成了一种类数组(Array-Like)结构,之前在介绍函数时,内部的arguments对象就是一个类数组的结构。由于类数组结构本身是一个对象,所以不包含与数组相关的API,例如map()、forEach()和filter()等,但是可以使用for循环进行遍历。下方示例展示了如何定义一个类数组结构、访问其中的元素及遍历,代码如下:
js
let arrLike={0:"a",1:"b",2:"c",length:3};
console.log(arrLike[0]); //"a"
for(let i=0;i<arrLike.length;i++){
console.log(arrLike[i]); //"a""b""c"
}
3、遍历对象属性
3.1、Object.keys()
JavaScript内置的Object对象中提供了keys()方法,用于获取一个对象的所有属性名,它接收一个对象作为参数,返回一个数组,里边保存了参数对象自身所有的属性。这里继续使用之前的博客文章对象来展示它的用法,获取博客文章对象的所有属性,代码如下:
js
const blogPost={
id:1,
title:"JavaScript教程",
getSlug:function(){
return"/post/"+this.title;
},
author:"李明",
comments:["很好","受教了","加油"],
"update-at":"2020-10-26",
};
console.log(Object.keys(blogPost));
Object.keys(blogPost).forEach((key)=>{
console.log(`${key}:${blogPost[key]}`);
});
js
['id','title','getSlug','author','comments','update-at']
id:1
title:JavaScript教程
getSlug:function(){
return"/post/"+title;
}
author:李明
comments:很好,受教了,加油
update-at:2020-10-26
3.2、for...in
除了使用Object.keys()获取属性外,还可以使用for...in循环,它与for...of循环的语法类似,在for后边的括号中,定义接收属性名的变量,后面使用in关键字跟上对象的名字。同样访问blogPost中的所有属性,代码如下:
js
//...省略blogPost定义
for(let key in blogPost){
console.log(`${key}:${blogPost[key]}`);
}
输出结果与上例一样。需要注意的是,Object.keys()和for...in循环只能获取可枚举(Enumerable)的属性,并且Object.keys()只能获取对象自身的属性,而for...in可以获取继承的属性。
3.3、Object.getOwnPropertyNames()
使用Object.getOwnPropertyNames()可以获取对象自身的所有属性,无论是否为可枚举的,它接收要获取的属性的对象作为参数,然后返回属性名数组。不过Object.getOwnPropertyNames()不能获取Symbol类型的属性。下面示例展示了使用Object.getOwnPropertyNames()遍历blogPost对象的方式,代码如下:
js
Object.getOwnPropertyNames(blogPost).forEach(key=>{
console.log(`${key}:${blogPost[key]}`);
})
3.4、Object.getOwnPropertySymbols()
如果要获取Symbol类型的属性,则可以使用Object.getOwnPropertySymbols(),它接收要获取属性的对象作为参数,然后返回Symbol类型的属性名数组,但是不包括普通字符串的属性名,代码如下:
js
const obj={
a:1,
[Symbol('b')]:2,
[Symbol('c')]:3,
}
Object.getOwnPropertySymbols(obj);//[Symbol(b),Symbol(c)]
4、删除对象属性
如果要删除对象中的某个属性,则可以使用delete运算符,在delete后边使用属性访问语法来指定要删除的属性,删除成功会返回true,失败则返回false,代码如下:
js
const obj={a:1};
delete obj.a; //true
obj.a; //undefined
上边的示例还可以使用[]形式:delete obj["a"],同样会删除对象中的a属性。delete只能删除对象本身的属性,不能删除继承的属性和设置了configurable为false的属性,试图删除这些属性时delete会返回false,其他情况则都返回true,即使是删除不存在的属性。
判断属性是否还存在于对象中,可以使用in运算符,in的左侧为要判断的属性名,右侧是对象的名字,如果该属性存在于该对象,包括继承下来的属性,in运算符则会返回true,否则返回false,例如上边示例中,在删除a属性后,可以使用in判断a是否还存在于obj对象中,代码如下:
js
"a"in obj;//false
另外,因为数组也是对象,所以同样可以使用delete删除数组中的元素,该元素会被设置为空白,并且位置会保留,代码如下:
js
const arr=[1,2,3];
delete arr[1]; //true
arr; //[1,empty,3]
需要注意的是,不能使用delete删除使用var、let、const定义的变量,也不能删除函数(但对象中的方法可以被删除)。
5、getters和setters
有时,对象中的属性并不是直接简单地进行赋值或获取,而是需要做一些计算,然后返回或者设置计算后的值,还可能需要在某些情况下,限制属性的访问和赋值,对于这些情况,可以使用getters和setters,它们统称为属性访问器(Property Accessor)。Getters是用于获取属性值的函数,使用get关键字加上属性名(函数名)进行定义,它不接收任何参数,在函数体里返回经过计算后的值,之后就能像访问普通的属性一样,获取它的值了,代码如下:
js
const cart={
items:["商品1","商品2","商品3"],
get total(){
return this.items.length;
},
};
cart.total; //3
上方示例使用cart模拟了购物车对象,里边使用数组保存了购物车的商品,然后使用get关键字定义了total()方法,在里边返回了商品数组的长度,this用于引用对象内部的其他属性,稍后会详细介绍。访问total的方式跟普通属性保持一致。不过,这里没有给total设置setters,所以total的值是不可变的。如果尝试给total赋值则可以发现并不会成功。
setters则是定义修改属性值的函数,使用set关键字加属性名,接收并且仅接收1个参数,即要设置的新值。setters函数没有返回值。例如,下方代码在修改user对象中的_username属性值时,先判断长度是否大于或等于5,是则赋值,不是则不作任何操作,代码如下:
js
const user={
_username:"",
set username(value){
if(value.length>=5){
this._username=value;
}
},
get username(){
return this._username;
},
};
user.username="testuser";
console.log(user.username); //testuser
一个对象中可以定义多个getters和setters,同一组get和set定义的函数可以使用相同的名字,这样就能够像普通属性一样设置和访问它们的值了。另外,函数的名字也可以是计算属性,例如,假设定义了变量x,值为"prop",那么可以使用getx{}来定义名为prop的属性。
如果仅仅使用了get定义了一个属性,则这个属性就是只读的,如果同时定义了get和set,则这个属性就是可读写的。
6、属性描述符
使用普通方式定义的对象属性,可以使用Object.keys()和for...in循环获取,并且可以使用delete运算符删除它们,这对于第三方库所暴露出来的对象来讲,是危险的操作,如果不小心删除了它的属性,或者添加了额外的属性,就很可能导致第三方库不能正常工作,假设某库需要遍历配置对象,默认均为字符串类型的值,但如果此时用户在使用的时候给配置对象添加了一个函数类型的属性,用于自己方便进行一些处理,当第三方库处理配置对象的时候,会把配置项的值作为字符串进行统一处理,且调用了字符串类型内置的方法,这时如果用户传递的函数类型的值并没有字符串类型所提供的API,就会出现错误。
6.1、配置属性描述符
为了避免这种情况,JavaScript提供了Object.defineProperty()方法用于给对象添加属性,并且提供了配置项用于控制该属性是否为可枚举的(Enumerable)、可配置的(Configurable)和可写的(Writable),它们分别用于设置新添加的属性是否可以被遍历、被删除及被重新赋值。这些配置项称为描述符(Descriptor),含义和取值分别为:
Enumerable(可枚举的),能够控制新添加的属性是否显示在for...in或Object.keys()等对属性的访问中,默认值为false。Configurable(可配置的),控制属性是否能被删除,或者是否可以修改描述符。默认值为false。Writable(可写的),控制属性是否能够重新被赋值。默认值为false。
Object.defineProperty()接收3个参数,第1个是要添加属性的对象,第2个是要添加的属性名,可以是字符串、Symbol或数字,第3个是属性的描述符。描述符是一个对象,可以设置configurable、enumberable、writable这3个描述属性,它还包括一个额外的value选项用于给属性添加默认值。如果给某个对象添加了一个不可枚举的、不可配置的且不可写的属性,则它就不会在遍历属性时显示出来,既不能删除也不能重新赋值,代码如下:
js
const obj={};
Object.defineProperty(obj,"a",{
value:1,
configurable:false,
enumerable:false,
writable:false,
});
obj.a; //1
obj.a=2; //obj.a的值仍然为1
delete obj.a; //obj.a仍然存在且值依旧为1
Object.keys(obj); //[],无法遍历出来,for...in同理
代码使用Object.defineProperty()给obj对象添加了一个a属性,将它的值设置为1,将configurable、enumerable和writable设置为false。这样对于obj中的a属性,它的值既不能改为2,也不能删除它,还不能通过Object.keys()遍历出来。在严格模式下,这些操作都会抛出TypeError异常。由于configurable、enumberable和writable默认值就是false,所以上边的Object.defineProperty()也可以进行简写,代码如下:
js
Object.defineProperty(obj,"a",{value:1});
上边配置项中的value和writable属于数值描述符(DataDescriptor),另一种描述符是访问器描述符(AccessorDescriptor),相当于给对象添加getters和setters,代码如下:
js
const counter={
count:1,
};
Object.defineProperty(counter,"current",{
get(){
return this.count;
},
set(value){
this.count+=value;
},
});
counter.current; //1
counter.current= 10;
counter.current; //10
需要注意的是,这里的get和set是函数的名字,而不是在对象中定义时的关键字。这里把current属性设置为了getters和setters的形式,可以通过它们控制属性值的读写。
数据描述符和访问器描述符只能设置一种,即要么配置value和writable,要么配置get和set,其中configurable和enumerable配置是两者共用的。如果既没有value和writable,也没有get和set,则默认使用了数据描述符,默认会把value设置为undefined,将writable设置为false。
如果使用Object.defineProperty()添加了对象原有的同名属性,则新配置的描述符会修改原有属性的描述符。如果原属性设置了configurable为false,则对于原属性描述符的修改有以下限制:
- 不能修改enumerable和configurable的值。
- 不能把数据描述符改成访问器描述符,或把访问器描述符改为数据描述符。
- 对于writable,可以把true改成false,但不能把false改为true。
上述情况均会抛出TypeError异常。下方示例展示了当configurable为false时,修改其他描述符的结果,代码如下:
js
const obj={};
Object.defineProperty(obj,"a",{
configurable:false,
enumerable:false,
writable:true,
});
//TypeError:Cannot redefine property:a
//类型错误:无法重新定义属性a
Object.defineProperty(obj,"a",{
configurable:true,
});
//同上
Object.defineProperty(obj,"a",{
enumerable:true,
});
//正常,可以将writable从true改为false
Object.defineProperty(obj,"a",{
writable:false,
});
//异常,改为false后,无法再改成true
Object.defineProperty(obj,"a",{
writable:true,
});
//异常,无法把数据描述符改成访问器描述符
Object.defineProperty(obj,"a",{
get(){
return 1;
},
set(value){
this.a=value;
},
});
6.2、配置多个属性描述符
Object对象中还提供defineProperties()方法用于一次性添加或修改多个属性,它接收2个参数,第1个是要配置属性描述符的对象,第2个是一个对象,属性名为要配置的属性名,值为描述符对象,例如下方示例展示了Object.defineProperties()的用法,代码如下:
js
const obj={};
Object.defineProperties(obj,{
a:{
enumerable:true,
writable:true,
value:2
},
b:{
enumerable:false,
configurable:true,
value:5
}
});
6.3、获取属性描述符
要获取一个属性的描述符,可以使用Object.getOwnPropertyDescriptor(),它接收2个参数,要获取属性描述符的对象和属性的名字,代码如下:
js
let obj={a:1};
Object.defineProperty(obj,"b",{
value:2
});
Object.getOwnPropertyDescriptor(obj,"a");
Object.getOwnPropertyDescriptor(obj,"b");
输出结果如下:
js
{
value:1,
writable:true,
enumerable:true,
configurable:true
}
{
value:2,
writable:false,
enumerable:false,
configurable:false
}
通过这个示例也能够看到使用字面值定义的对象属性和使用Object.defineProperty()定义的属性,它们的默认描述符是不同的。如果要获取对象所有属性的描述符则可以使用Object.getOwnPropertyDescriptors()方法,它接收一个对象作为参数,然后返回包含它的所有属性及描述符的对象,例如上例中获取所有属性的描述符可以写成Object.getOwnPropertyDescriptors(obj),它返回的结果如下:
js
{
a:{value:1,writable:true,enumerable:true,configurable:true}
b:{value:2,writable:false,enumerable:false,configurable:false}
}
在碰到对象的某个属性值无法修改时,可以通过这两种方法查询该属性是否为writable。通常第三方库的作者会把一些重要的属性保护起来,在自定义第三方库时,一定要注意这一点。
6.4、不可扩展对象
6.4.1、Object.seal()
Object.seal()接收一个参数,该参数为将要密封(Seal)的对象,密封之后的对象将不能添加和删除属性,且现有属性都会设置为不可配置的,即configurable为false,不过现有属性的值仍然可以修改。如果试图给密封对象添加或删除属性,在严格模式下则会抛出TypeError异常,普通模式下则无任何提示,即静默出错。检测一个对象是否为密封的,可以使用Object.isSealed()方法,参数为要检测的对象,如果是密封的则返回true,否则返回false。下面示例展示了Object.seal()方法的使用方法和效果,代码如下:
js
const obj={a:1};
Object.seal(obj);
Object.isSealed(obj);//true
obj.b=5; //无效
obj.b; //undefined
obj.a=10;
obj.a; //10
delete obj.a
obj.a; //10
6.4.2、Object.freeze()
Object.freeze()与Object.seal()的作用类似,但是更为严格,对象在冻结(Freeze)之后,除了configurable被设置为false,writable也被设置成了false,这样就不能修改现有属性的值了,并且也不能给原型对象添加和删除属性。不过,如果对象中还包括其他子对象,则子对象不会被冻结。
另外,调用Object.freeze()之后会直接把原对象进行原地冻结,而不是创建一个新的冻结后的对象。检测对象是否为冻结可以使用Object.isFrozen()方法。数组也可以被冻结,除了不能添加和删除元素,元素的值也不能修改了。
6.4.3、Object.preventExtensions()
Object.preventExtensions()则只阻止给对象添加新的属性,但还可以删除现有属性或给属性重新赋值,另外它不能阻止给原型对象添加新属性。检测对象是否可扩展可以使用Object.isExtensible()方法,对于调用了Object.preventExtensions()、Object.seal()和Object.freeze()的对象,该方法都会返回false。
7、原型
JavaScript是基于原型的编程语言,每个对象除了本身的属性之外还会有一个__proto__属性(两边分别为两个下画线),它指向的是一个对象,称为原型对象(Prototypical Object),每个对象都会继承原型对象中的属性和方法。
通过继承原型,新创建的对象可以直接使用继承下来的属性和方法,从而避免重复定义,达到代码复用的目的。另外新对象中仍然可以添加自己所需要的属性和方法,所有这些继承的和新定义的方法,都可以通过该新对象进行调用。
例如,在使用字面值创建对象时,它的原型对象默认指向的是JavaScript内置的Object构造函数的原型对象(见7.8节构造函数),所以字面值对象都继承了toString()、valueOf()和hasOwnProperty()等属性。同时,在创建字面值对象的时候还可以给这个对象添加新的属性和方法。
7.1、获取原型对象
要获取一个对象的原型对象,即__proto__属性值,可以使用Object.getPrototypeOf(),只需给它传递需要获取prototype的对象,代码如下:
js
const obj={a:1};
Object.getPrototypeOf(obj);
Object.getPrototypeOf(obj)===Object.prototype;
js
{
constructor:fObject()
hasOwnProperty:fhasOwnProperty()
isPrototypeOf:fisPrototypeOf()
propertyIsEnumerable:fpropertyIsEnumerable()
toLocaleString:ftoLocaleString()
toString:ftoString()
valueOf:fvalueOf()
__defineGetter__:f__defineGetter__()
__defineSetter__:f__defineSetter__()
__lookupGetter__:f__lookupGetter__()
__lookupSetter__:f__lookupSetter__()
get__proto__:f__proto__()
set__proto__:f__proto__()
}
代码中定义了字面值对象obj,使用Object.getPrototypeOf()获取它的原型对象,并打印了出来,可以看到obj继承了很多原型对象中的属性和方法,需要注意的是,这里获取的是原型对象,所以不包含obj本身的a属性。
后面让obj的原型对象跟Object构造函数的prototype属性作了比较,发现它们指向的是同一个对象,所以obj的原型对象中的内容,继承自Object构造函数的prototype属性。
另外需要注意的是,上述代码需要在浏览器执行才能看到Object的prototype属性,因为Node.js环境下,console.log()只打印对象本身可枚举的属性,而不打印继承的属性,所以会返回一个空的对象。
7.2、原型链
由于对象的原型也是一个对象,它可能也会有自己的原型,直到遇到原型为null时,整个原型关系就会结束,这种关系叫作原型链(Prototype Chain),一个对象可以继承整个原型链中所有能被继承的属性。
例如,使用字面值创建的数组,它的原型对象指向的是Array构造函数的原型对象,即Array.prototype属性值,所以它有map()、reduce()、concat()和fill()等方法,而Array构造函数的原型对象指向的是Object构造函数的原型对象,所以数组中也有toString()和valueOf()等方法。最后,Object构造函数的原型对象就不再指向任何原型对象了,它的值是null,因此就到了原型链的最顶端。这些可以通过代码来测试,代码如下:
js
let arr=[1,2,3]
let p1=Object.getPrototypeOf(arr);
p1; //[constructor:f,concat:f,copy W ithin:f,fill:f,find:f,...]
p1===Array.prototype; //true
let p2=Object.getPrototypeOf(p1);
p2;//{constructor:f,___defineGetter___:f,___defineSetter___:f,...}
p2===Object.prototype;//true
let p3=Object.getPrototypeOf(p2);
p3; //null
代码中定义了一个字面值数组[1,2,3],获取它的原型对象p1,这个原型对象比较特殊,也是一个数组,里边包含了数组常见的方法,判断它和Array.prototype的结果为true,所以arr的原型对象指向的是Array.prototype。下面又获取了p1的原型对象p2,即arr的原型对象的原型对象,可以发现它指向的是Object.prototype。最后在获取p2的原型对象时,返回了null,这里就到了原型链的顶层。
7.3、Object.create()
如果想让一个对象继承其他对象的原型,从而继承其属性,可以使用Object.create()方法。它接收一个对象作为参数,然后返回一个新的对象,新对象的原型对象就是这个参数对象。下方示例展示了Object.create()的用法和效果,代码如下:
js
const obj={
a:1,
f(){
return 5;
},
};
const newObj=Object.create(obj);
newObj.b=2;
console.log(newObj.b); //2,newObj自有属性
console.log(newObj.a); //1,继承自原型的属性
console.log(newObj.f()); //5,继承自原型的方法
console.log(Object.getPrototypeOf(newObj));//{a:1},原型对象
如果想在定义字面值对象的时候指定原型对象,则可以直接在字面值中使用__proto__属性来覆盖默认的原型对象,代码如下:
js
const obj={b:2};
const obj2={
a:1,
"__proto__":obj
};
obj2.a; //1
obj2.b; //2,继承自obj的属性
可以看到在使用__proto__给obj2设置原型对象之后,它也可以访问obj中的属性。需要注意的是,下画线不是合法的对象属性名,所以需要给__proto__加上双引号。
如果使用Object.create()的时候传递了null作为参数,则该对象就是原型链最顶层的原型对象,它不再继承Object构造函数中的属性和方法。
8、构造函数
8.1、定义
构造函数与普通函数的定义方式没有区别,但是为了区分,一般会把构造函数首字母大写。构造函数会返回新创建的对象,但在函数体中不必显式地写上return语句。要使用构造函数创建对象,可以使用new关键字加上构造函数的名字进行调用,这样就可以返回新创建的对象了。
js
function Message(message, sender) {
this.message = message;
this.sender = sender;
}
const msg = new Message("你好", "张三");
console.log(msg.message, msg.sender);
const msg2 = new Message("明天见", "李四");
console.log(msg2.message, msg2.sender);
//你好 张三
//明天见 李四
构造函数有特殊的prototype属性,使用构造函数创建出来的对象的prototype指向的是构造函数的prototype。构造函数的prototype对象中只有一个constructor属性,它的值是构造函数本身,例如Message构造函数的prototype为{constructor:∗ƒMessage(message,sender)∗}。通过给构造函数的prototype添加属性或方法,可以使用它创建出来的对象获得新的属性和方法。例如,给Message构造函数的prototype加上一个getMessage()方法,那么msg和msg2对象就都可以调用它了,代码如下:
js
Message.prototype.getMessage = function(){
return this.message + "发自:" + this.sender;
}
console.log(msg.getMessage());
console.log(msg2.getMessage());
//你好发自:张三
//明天见发自:李四
需要注意的是,只有构造函数才可以使用.直接访问prototype属性,对象只能通过Object.getPrototypeOf()访问。
有时,可能会直接把构造函数的prototype设置为另一个对象,以便于统一继承某个对象的属性和方法,代码如下:
js
Message.prototype={
msgType:"文本",
getMessage(){
return this.message+"发自:"+this.sender;
},
};
这样做有两个问题:
- 在修改Message.prototype之前所创建的对象,无法访问新的prototype中的属性和方法,因为这种写法相当于给Message.prototype设置了一个全新的对象,但是以前创建的对象指向的还是原来的Message.prototype。
- Message.prototype中的constructor属性会丢失,一般构造函数的prototype中都要有constructor属性来指向它本身,这样后续在程序中如果需要知道这个对象是由哪个构造函数创建的,则访问prototype中的constructor属性即可。
要解决这两个问题,可以在修改完prototype之后再创建对象,并且在修改prototype时,添加constructor属性,把它的值设置为构造函数本身,代码如下:
js
Message.prototype={
constructor:Message,
//...
};
const msg=newMessage("你好","张三");
const msg2=newMessage("明天见","李四");
console.log(msg.msgType); //文本
另一种推荐的解决方法是,像之前的示例一样,使用Message.prototype.getMessage这种形式,通过给prototype对象逐个添加新的属性,这样就能让修改prototype之前创建的对象继承新的属性,也能避免constructor属性丢失。
使用构造函数创建对象,相当于使用Object.create(ConstructorFn.prototype)创建对象,再针对创建出来的对象调用构造函数中的代码。
8.2、this
在JavaScript中,this的取值与它所处的上下文(Context)有关,并且在普通和严格模式下也有所不同。在全局作用域上下文中,this指向的是全局对象,全局对象在浏览器中是window对象,在Node.js中是当前模块,下方示例展示了全局作用域下的this的取值,代码如下:
js
this===window; //true,浏览器环境下
this===module.exports //true,Node.js环境下
关于不同环境下的全局对象,也可以使用globalThis来统一获取,但要注意Node.js中的globalThis指向的是global对象,与上例中全局作用域的this指向的module.exports不同,在浏览器下globalThis指向的对象为window,与全局作用域this所指向的对象相同。
对于在函数上下文中的this,它的值需要根据函数的调用方式决定。如果是普通的函数,且使用一般的方式进行调用(不使用new以构造函数进行调用),则函数体里的this指向的都是globalThis,即window(浏览器)或global(Node.js),可以通过下方示例进行验证,代码如下:
js
function func(){
console.log(this===globalThis);
}
func();//true
而在严格模式下,普通函数中this的值为undefined。如果以构造函数的方式调用函数,则this指向的是新创建的对象,代码如下:
js
function Func(){
this.a=5;
}
const obj=new Func();
obj.a; //5,obj即为Func()中this的指向
在对象的方法中,如果方法是使用普通函数定义的,则this指向的是当前对象,代码如下:
js
const obj={
a:1,
f(){
console.log(obj===this);
console.log(this.a);
},
};
obj.f();//true
//1
要判断普通函数中this的指向有一个简单直观的方法,即看它调用时左侧的代码,如果左侧没有任何代码,则this指向的是全局作用域中的对象,例如f()。如果为对象,则指向的是这个对象,例如obj.f(),f()中的this指向的是obj,又如obj.inner.f(),f()中的this指向的是inner对象。如果函数继承自prototype,则这个规则也保持一致,哪个对象调用的这种方法,则它里边的this就指向哪个对象,对于getters和setters所定义的函数也是如此。
如果对象方法是使用箭头函数定义的,则this的指向会有所不同。箭头函数中this的指向是根据定义时它所在的代码位置决定的,即词法上下文(LexicalContext),this的取值为包裹箭头函数的作用域中this的值。
js
const obj={
f:()=>{console.log(this)}
}
obj.f(); //Window
如果在构造函数中使用箭头函数,则箭头函数的this就是构造函数中的this,即指向创建的对象,代码如下:
js
function Func(){
const init=()=>{
this.a=5;
};
init();
}
const obj=new Func();
obj.a; //5
一般在对象中使用普通函数作为对象的方法,这样可以保留this的指向,但是有些特殊情况使用箭头函数会更合适,先来看一个例子,这个例子并不是真实的事件处理方式,不过可以解释this在回调函数中的问题,代码如下:
js
//chapter7/this3.js
function Button(label){
this.label=label;
this.handleClick=function(){
console.log(this.label);
};
}
//模拟触发单击事件
function emitClick(callback){
callback();
}
const btn=new Button("按钮");
emitClick(btn.handleClick);//undefined
代码中首先定义了Button构造函数,代表一个按钮组件,它有label属性和处理单击事件的方法handleClick(),方法里边简单地打印出来了按钮的label属性值。emitClick()函数简单地模拟了单击事件的触发,它接收一个回调函数,用于在单击事件触发后要执行的业务逻辑。接下来创建了按钮组件的实例,并触发了单击事件,把按钮中的handleClick()传递给了emitClick,这样就会执行它里边的代码。
看起来应该是打印出label属性的值:"按钮",但是结果却是undefined。这是因为handleClick()在传递给emitClick()的时候,this的指向已经发生了变化。可以看到在emitClick()中调用callback()时,也就是Button中的handleClick(),左边没有任何东西,那么此时this指向的是全局对象,它里边没有label属性,所以打印出了undefined。
要解决这个问题有3种方法,第1种解决方法是在Button构造函数中,把this的值保存到一个变量中,通常使用self作为变量名表示对象本身,然后在handleClick()中引用,代码如下:
js
function Button(label){
this.label=label;
var self=this;
this.handleClick=function(){
console.log(self.label);
};
}
这时,Button构造函数和handleClick()形成了一个闭包,handleClick()可以捕获self变量的值,后边无论在哪里调用,都可以访问它所指向的对象中的属性了。
第2种解决方法是使用箭头函数,代码如下:
js
this.handleClick=()=>{
console.log(this.label);
};
因为箭头函数中的this是根据箭头函数定义时的位置决定的,所以使用箭头函数定义handleClick()时,this已经确定为构造函数Button的this,所以最后成功地访问了label属性。
第3种解决方法是使用函数对象中的bind()方法,使用bind()可以给函数绑定运行时的this,并返回新的函数,这样在后边调用这个新函数时,它的this就是使用bind()所绑定的this,例如将handleClick()修改为使用bind(),代码如下:
js
this.handleClick=function(){console.log(this.label)}.bind(this);
//或者这样更清楚一些
//this.handleClick=function(){console.log(this.label)}
//this.handleClick=this.handleClick.bind(this)
bind()参数中的this就是给handleClick()绑定的this,由于是在Button构造函数中,所以this指向的是Button构造函数中的this,这样也能打印出label属性的值。
9、toString()和valueOf()
由于绝大多数对象的原型链中继承Object.prototype原型对象(除非手动改变prototype),所以它们都包含Object.prototype中的属性和方法,而toString()和valueOf()是比较重要的两个。toString()用于在需要字符串的地方,按方法内部的逻辑把对象转换成字符串,而valueOf()则用于在需要基本类型的地方,把对象按逻辑转换为基本类型。
toString()一般需要被覆盖,因为Object.prototype中的toString()只是单纯地返回[object Object],没有实际意义,通过覆盖toString()方法,在里边返回自定义的字符串,可以让它具有实际意义。下方示例展示了覆盖toString()的方法,代码如下:
js
const obj={
a:1,
b:2,
toString(){
return`a=${this.a},b=${this.b}`;
},
};
obj.toString(); //a=1,b=2
"对象字符串为"+obj; //对象字符串为a=1,b=2
valueOf()默认只会返回对象本身,它也需要通过覆盖来返回有意义的值。例如在上例的obj中,使用valueOf()返回a+b的数字基本类型的结果,代码如下:
js
const obj={
a:1,
b:2,
valueOf(){
return this.a+this.b;
},
};
obj.valueOf();//3
+obj; //3
obj-2; //1
注意:示例中的+obj同样返回了valueOf()的结果,因为一元加可以把非数字类型的值转换为数字类型,而在最后一行obj-2中,减法也需要操作数是数字类型,所以obj就调用了valueOf()方法隐式地转换成了数字。
10、call()、apply()和bind()
10.1、call()
函数中的call()方法用于调用该函数,它接收两个参数,第1个用于设置函数内部this的指向,第2个参数是一个变长参数,接收多个逗号分隔的参数并传递给原函数。例如使用call()改变this指向,代码如下:
js
function sum(prop1,prop2){
return this[prop1]+this[prop2];
}
const obj={a:1,b:2};
const result=sum.call(obj,"a","b");
result; //3
示例中首先定义了普通函数sum(),它用于给对象中的两个属性进行求和,两个参数为进行求和计算的属性名,因为属性名是使用变量动态表示的,所以这里使用了[]访问对象中的属性。如果直接调用该函数sum("a","b"),则会返回NaN,因为这样调用函数,里边的this指向的是全局对象,而全局对象中并没有a和b这两个属性,所以其结果是两个undefined相加。后面定义了obj对象,里边有a和b属性,然后通过调用sum()原型对象中的call()方法,把obj作为函数的this传递进去,这样就能成功地访问这两个属性,然后返回了正确的结果3。
10.2、apply()
apply()方法与call()方法的作用几乎一模一样,但是apply()的第2个参数接收的是一个数组,而不是变长参数,通过这个特性,可以把接收多个参数的函数转换成使用一个数组接收参数的函数。例如,数组中的push()方法接收多个参数,把这些参数作为新的元素追加到数组中,此时就可以使用apply()方法,把一个数组追加到当前数组中,代码如下:
js
const arr1=[1,2,3];
const arr2=[4,5,6];
arr1.push.apply(arr1,arr2);
arr1; //[1,2,3,4,5,6]
10.3、 bind()
bind()与call()类似,用于给函数绑定this,并通过变长参数给函数传递参数,不同之处在于,使用bind()会创建并返回一个新的函数,这个函数并不会立即被执行,而是需要在合适的地方进行调用。下方示例展示了使用bind()给函数绑定this指向的过程,代码如下:
js
const obj={
a:1,
f(b){
return this.a+b;
},
};
const f=obj.f;
console.log(f(10)); //NaN
const boundF=f.bind(obj);
console.log(boundF(10)); //11
11、对象复制
在使用JavaScript编程时,经常有将一个对象的属性复制(Copy)到另一个对象的需求,例如返回新的状态,合并配置项等。第1种方式可以使用Object中的assign()方法,它接收两个参数,第1个参数是目标对象,即要将属性复制到哪个对象,第2个参数是个变长参数,接收多个源对象,即从哪些对象中复制属性。该方法会原地修改并返回目标对象。下方示例展示了Object.assign()的基本使用方法,代码如下:
js
const obj1={a:1};
const obj2={b:2};
Object.assign(obj1,obj2);
obj1; //{a:1,b:2}
可以看到将obj2中的属性复制到了obj1中。这里需要注意的是:
- Object.assign()只会复制源对象中自有的且可枚举的属性,不会复制原型链中的属性。
- 如果有同名的属性,则源对象的属性值会覆盖目标对象的属性值。
- 如果源对象中有getters,则会复制getters所返回的结果,而不是getters本身。例如:Object.assign({},{get a(){return 10;}})会返回{a:10}。
- 如果目标对象中有setters,当源对象有同名的属性时,则会把属性的值传递给setters作为参数并调用,而不是覆盖setters。例如:Object.assign({a:1,set c(v){this.a+=v}},{c:5})会返回{a:6},即调用c(v)把a加上5之后的值。
如果不想原地修改目标对象,则可以把第1个参数改为空对象作为目标对象,把原目标对象作为源对象进行复制,代码如下:
js
const obj1={a:1};
const obj2={b:2};
Object.assign({},obj1,obj2);//{a:1,b:2}
这种形式也可以改为使用扩展运算符,在ES2018及以后,扩展运算符...增加了对对象字面值的支持,在后面加上对象的名字就可以把对象的属性都拆解出来,上方示例的Object.assign()可以改成下面这种形式,代码如下:
js
const obj={...obj1,...obj2};
obj; //{a:1,b:2}
代码中的大括号用于定义新的对象,它里边的属性是obj1和obj2的并集,如果有同名的属性,则后面对象中的会覆盖前边的。新对象中还可以定义自己的属性,例如:const obj={...obj1,...obj2,c:3}。要注意与Object.assign()不同的是,如果前边对象中有setters且与后边对象同名的普通属性,则它不会执行setters方法,而是直接把它覆盖掉,代码如下:
js
const obj1={a:1,set c(v){this.a+=v}};
const obj2={...obj1,...{c:5}};
obj2; //{a:1,c:5}
Object.assign()和扩展运算符只能进行浅复制(Shallow Cloning),如果对象中包含其他对象类型的属性(例如对象、数组、函数等),则只会复制它们的引用。如果在新对象中修改这些引用类型的值,则会引起原对象中对应属性值的改变。要实现深复制(DeepCloning)可以利用JSON内置对象或者使用递归的方式进行复制。
12、解构赋值与rest运算符(对象)
跟数组一样,对象也支持解构赋值和rest(剩余)运算符。解构赋值可以用于把对象中的属性拆解出来并同时赋给多个变量,只需要在=赋值语句左侧使用{},并且在里边写上要拆解的属性名,以及与原对象中的属性名保持一致,再在右边写上要解构的对象,代码如下:
js
const obj={a:1,b:2};
const{a,b}=obj;
a; //1
b; //2
如果为了防止因为对象属性不存在而发生错误,则可以在解构赋值的同时给属性设置默认值,当属性不存在或值为undefined时,就会取默认值,这里需要注意当属性值为null时,默认值并不会起作用。设置默认值的语法跟数组解构赋值中的一样,在属性名后边使用=,代码如下:
js
const{a,b=2,c}={a:1,c:null};
a; //1
b; //2
c; //null
当解构出来的属性名和已有的变量名同名时,或者当想给属性重命名时,可以在解构赋值语句中,在属性名的后边使用:加上新属性的名字实现,代码如下:
js
const{a:id}={a:1};
id; //1
解构赋值也可以同时对数组和对象进行操作,用于拆解复杂的对象,支持嵌套。假设有一个post对象保存了博客信息,其id、title和comments属性分别保存了博客的ID、标题和评论。comments是一个数组,里边保存了对该博客的2条评论信息,每条评论又是一个对象,包括id、content评论内容和user评论人信息,而user又是一个对象,此对象包括id和name名字信息,代码如下:
js
const post={
id:1,
title:"如何学好JavaScript",
comments:[
{
id:1,
content:"好!",
user:{
id:10,
name:"张三",
},
},
{
id:2,
content:"Very good!",
user:{
id:11,
name:"李四",
},
},
],
};
如果要获取博客的标题、第2条评论的内容及评论人的名字,则实现代码如下:
js
const{
title,
comments:[,{content:comment2Content,user:{name}}]
}=post;
title; //如何学好JavaScript
comment2Content;//Very good!
name; //李四
这里获取了post中的title属性并赋值给title变量,然后获取了comments属性,忽略了第1个元素,在第2个元素所指向的对象中,又取出了content属性并重新命名为comment2Content,最后取出user属性,并获取了它里边的name属性,即评论人的名字,这样就获取了博客的标题、第2条评论的内容和评论人的名字。
如果在解构赋值完部分属性后,还想获得剩余的属性所构成的子对象,则可以在解构赋值语句的最后使用rest(剩余)运算符...,加上自定义的子对象的名字,这样就可以得到除去参与解构赋值的属性之外的属性所形成的子对象,代码如下:
js
const obj={a:1,b:2,c:3};
const{a,...rest}=obj;
rest; //{b:2,c:3}
解构赋值和rest运算符也可以用于函数的参数中,它可以实现可选参数、默认参数和变长参数的效果,只需让函数接收一个对象作为参数,当然函数也可以同时包含其他参数。假设有一个函数,接收了一个配置项对象作为参数,host和port有默认值,剩余的参数整体传递给下一个函数进行处理,代码如下:
js
function init({host="localhost",port=3000,...rest}){
console.log(host,port);
next(rest);
}
function next(params){
console.log(params);
}
init({host:"example.com",username:"johnsmith"});
js
example.com 3000
{username:'johnsmith'}
init()原本接收一个对象,在解构赋值语法出现以前,需要使用这样的语法:init(options),然后通过options去访问host和port属性,例如options.host,如果没有值则需要使用if/else进行判断。有了解构赋值之后就可以直接把对象解构出来,并设置默认值,或者使用别名,而在给init()函数传递参数时,只需传递一个对象,属性名跟函数解构赋值语句中的属性名保持一致就可以了,顺序则可以自由调整。
13、with语句
JavaScript中有一个特殊的with语句,可以指定一个对象,在with语句块中,可以直接访问对象中的属性,无须反复使用对象名加.号访问,不过这个语句已经被标记为过时了,并且无法在严格模式下使用,本节将介绍一下它的基本用法和替代语法。
js
with(obj){
//语句
}
在with后边的小括号中接收一个对象作为参数,在{}中的代码可以直接访问它的属性,假如有一个员工对象,里边包含员工姓名和部门子对象,部门子对象中有部门名称和部门经理,代码如下:
js
let emp={
name:"张三",
dept:{
name:"信息技术部",
manager:"李四",
},
};
如果要访问emp中的name属性和dept中的name属性,使用普通方式编写的代码如下:
js
emp.name;
emp.dept.name;
使用with语句编写的代码如下:
js
with(emp){
console.log(name); //张三
console.log(dept.name);//信息技术部
}
14、值传递和引用传递
当对象作为函数参数时,传递是按引用(By Reference)进行传递的。对象在内存中创建好之后,会产生指向该内存地址的引用,然后保存在变量中,当使用赋值语句或传递参数时,只是把引用传递给了新的变量,这两个变量指向的还是同一个对象,任何一方修改对象的内容都会引起另一方的改变,对象、函数、数组等都是按引用传递的,因为它们本质都是对象,而基本类型是按值(By Value)进行传递的,在使用赋值语句或传递参数时,则会复制当前值并形成新的副本,这样的值是独立的,修改一方不会影响另一方。下方示例展示了按引用和按值传递的区别,代码如下:
js
//按值传递
function byValue(x){
x=10;
}
let x=5;
byValue(x);
console.log(x); //5
//按引用传递
function byRef(obj){
obj.x=12;
}
const obj={
x:8,
};
byRef(obj);
console.log(obj.x);//12