3. 对象进阶
3.1. 引言
在对象的基础篇中,我们知道对象是一系列无序属性和方法的集合。在ECMA规范中,Object是派生其他对象的基类,Object类型的所有属性和方法在派生的对象上均是存在的,同时,我们也把这些派生的对象称为Object类型的实例。需要注意的是,宿主环境提供的对象,比如DOM,不受ECMA规范约束。因此它们不一定继承自Object。
💁 核心概念说明
对象整体是集合,由若干成员构成,每个成员则是通过属性名和属性值组成,称为键值对。属性值可以是任意类型的数据,属性名则只能是字符串或者symbol。我们通过字面量或者其他形式创建的对象的属性,在底层都会把属性名被转为字符串,从而构建起复杂的数据结构。
创建对象的方式是多样的,在早期,普遍使用new Object()来创建对象,而后面采用字面量的形式创建对象则越来越流行,因为字面量方式简洁又高效,使用字面量形式,代码在底层运行时,并不会调用Object函数,而是由引擎直接解析构建。无论怎样的方式来创建对象, 从类型和继承关系上来说,对象都是基于Object创建的。比如字面量,虽然通过引擎直接解析构建, 但是底层还是会被解析为一个具有原型链指向 Object.prototype 的对象。这个对象不仅继承了 Object 的属性和方法,还拥有 Object 的特性 ,比如 toString()、hasOwnProperty() 这段话在讲了原型后再梳理。
3.2. 属性描述符和属性类型概述 ❗
在底层,对象的属性实际上是有类型的,明白这一点非常重要!因为很多高级的功能需要利用到这个关键知识点才能实现。如同数据类型一样,不同类型的数据具有不同的特性。同理,不同类型的属性也具有不同的特性。
在ECMA规范中,提供了一些特定信息来描述对象属性的特性。底层引擎在处理对象属性时(例如读写操作),会使用到这些特定信息。这些特定信息是为了帮助JS引擎实现ECMA规范而特别定义的,并没有暴露给开发者。因此,无法通过代码访问,所以我们将这些特定信息称为属性的内部特性或者属性的元信息。专业术语则叫属性描述符。在ECMA中,属性的类型分两种:
数据属性类型访问器属性类型
这两种类型的属性的属性描述符(特定信息)是不一样的,但是有一定关联性。
ES5在Object上提供了一些方法,让我们可以操作属性的属性描述符,从而提升了开发者权限,使得开发者可以以一种更加细粒度的方式精准控制对象属性的行为,从而实现更强大的功能。知名前端框架Vue就是利用了这些特性从而发扬光大。在规范层面,使用双中括号表示内部特性 - [[ 内部特性 ]]。
对于属性描述符,我们可以当做是一个对象,这个对象主要是针对属性的特性做配置项的对象
3.3. 数据属性类型
数据属性类型的属性描述符具有四个信息:
[[ configurable ]] - 可配置
-
- 值:布尔类型,默认为
true - 可配置:表示属性是否可以被删除,是否可以把属性变为访问器属性
- 值:布尔类型,默认为
[[ enumerable ]] - 可枚举
-
- 值:布尔类型,默认为
true - 可枚举:表示属性是否可以通过
for in 循环进行枚举
- 值:布尔类型,默认为
[[ writable ]] - 可写
-
- 值:布尔类型,默认为
true - 可写:表示属性是否可以被修改
- 值:布尔类型,默认为
[[ value ]] - 值
-
- 值:任意
JS数据类型的值 - 值就是该对象属性名对应的值,我们读取和访问属性就在这个位置进行
- 值:任意
3.3.1. Object.defineProperty说明
let person = { name:'小明' }
请看如上代码,我们创建了person对象,给了一个name属性。实际上在底层,person对象的name属性的描述符:[[Configurable]]、[[Enumerable]]、[[Writable]]均会设为true,而[[value]]则是'小明',当我们修改name属性时,影响的就是[[value]],读取也是。
在ES5之前,开发者不能控制对象属性更底层的行为。ES5之后可以通过Object新增的defineProperty()方法控制对象属性的属性描述符,defineProperty顾名思义定义对象属性。该方法接收三个参数:
- 目标对象
- 属性的名称
- 属性描述符对象
语:Object.defineProperty(objName,propertyName,configObj)属性存在则修改,不存在则新增
3.3.2. 可写 writable
let person = {};
Object.defineProperty(person,'name',{
writable:false, // 属性是否可写
value:'小红'
})
person.name = '小明'
console.log(person) // { name:'小红' }
- 定义一个
person对象 - 通过
defineProperty方法给person对象定义name属性 - 将
name的属性描述符的value设置为'小红',并把writable可写设置为false,表示属性不可编辑
由于name被设为了不可编辑(只读),因此再次person.name = '小明'修改时,不会生效,name依然为小红
非严格模式下,修改只读属性会被引擎忽略,不会引发错误。
严格模式下,修改只读属性,会引发错误(即使修改的属性值和原值一样)。
3.3.3. 可配置 configurable
// 定义一个对象
let person = {
name:'小红',
age:20
}
// 通过defineProperty定义属性,修改默认属性描述符
Object.defineProperty(person,'age',{
configurable:false, // 设置可配置特性为false
})
delete person.age;// 失效
console.log(person) // 依然有name和age属性
上面定义了person对象,有两个属性name和age。然后将属性描述符中的可配置特性改为了false。这样的话,我们通过delete操作符删除对象就无法生效了。
我们可以重复调用definProperty()配置对象的属性描述符,但如果把configurable可配置设为false后,后续就不能将其再改为true了。其他特性也不能再配置了。
3.3.4. 小结
前面提到,属性描述符四个配置,默认情况下是true。但是,如果我们通过Object.defineProperty显示去创建或修改对象属性时,如果我们不指定属性的特性,那么他们默认为false。
const obj = {}
Object.defineProperty(obj,'name',{
value:'小明'
})
// 未指定 writable, enumerable, configurable 特性 那么它们将为false
3.4. 访问器属性类型
3.4.1. 对象的getter和setter
在ES5之前,对象的属性类型其实只有数据属性一种。ES5中针对对象引入了一种特殊的语法getter/setter,这种语法允许开发者通过函数控制属性的读取和设置,其目的就是为了在访问或者修改属性时引入额外的逻辑,以实现对象属性的精确控制。getter表示获取某个属性的值的方法,setter表示设定某个属性的值的方法。两者都是通过函数来实现。简单来说就是将对象属性的查询/修改绑定到函数上。一个getter表示一个获取某一个属性的值的方法,一个setter表示一个设定某个属性的值的方法。具体实现则通过get和set关键字。
getter/setter是直接集成到对象字面量的写法中的,语法结构支持两种,如下:
get prop() { ... }get [expression]() { ... }
let obj = {
a:10,
get b () {
return this.a + 30
},
set c (value) {
this.a = value / 2
}
}
console.log(obj.a) // 10
console.log(obj.b) // 30
obj.c = 100
console.log(obj.a) // 50
console.log(obj.b) // 80
当我们访问对象属性,例如obj.b时,实际上就是get b 函数被调用。设置对象属性,例如obj.c = 100时,就是set c函数调用。通过这种语法可以灵活控制对象属性的行为,加入额外的逻辑。
3.4.2. 访问器属性特性
在ES5之前,对象的属性类型实际上只有数据类型一种,在引入getter/setter后,随即就诞生了访问器属性类型。Object.defineProperty API也支持getter/setter定义,相较于字面量形式的getter/setter,访问器的方式更加灵活,之所以叫访问器属性,主要是因为get/set是基于函数动态执行的。
访问器属性类型的属性同样具有四个特性:
[[ get ]] - 获取值函数
-
- 值:是一个函数,用于获取值。在读取属性时调用,默认值是
undefined - 表示
getter函数
- 值:是一个函数,用于获取值。在读取属性时调用,默认值是
[[ set ]] -设置值函数
-
- 值:是一个函数,用于设置值。在设置属性值时调用,默认值
undefined - 表示
setter函数
- 值:是一个函数,用于设置值。在设置属性值时调用,默认值
[[ enumerable ]] - 可枚举
-
- 值:布尔值,默认为
true - 表示属性是否可以通过
for in循环进行枚举
- 值:布尔值,默认为
[[ configurable ]] - 可配置
-
- 值:布尔值,默认为
true - 表示属性是否可配置,比如是否可以通过
delete删除,是否可以修改其他特性、是否可以改为数据属性
- 值:布尔值,默认为
需要注意的是,getter/setter语法一般是基于外部变量或者说私有属性去计算得到一个新的属性。不论是对象字面量或者访问器属性的形式都是这样,这是最佳实践。
此外,对于对象字面量形式的getter/setter和访问器属性的getter/setter在抒写层面还有一个不同。对象字面量中使用该语法时支持两种标准语法格式,对于访问器属性而言,get/set关键字后只能接函数体。
let person = {
firstName:"John",
lastName:"Doe",
}
Object.defineProperty(person,'fullName',{
get(){
return `${this.firstName} ${this.lastName}`
},
set(value){
const nameList = value.split(' ')
this.firstName = nameList[0]
this.lastName = nameList[1]
}
})
person.fullName // John Doe
许多开发者容易混淆,get/set、getter/setter的概念。getter/setter只是一个描述性的术语,相当于概念规范,get/set相当于具体实现。当我们说某个属性的getter时,就是指这个属性的get函数。
3.5. 定义多个属性
除了通过Object.defineProperty()定义单个属性以外,还可以通过Object.defineProperties一次性定义多个属性,这个方法接受两个参数:
- 参数一是目标对象
- 参数二是一个大的描述符对象。
let person = {};
Object.defineProperties(person,{
year:{
value: 2007,
},
editon:{
value: 1
}
})
上面这个方法简单了解即可,实际开发中使用相对较少。
3.6. 读取属性描述符
属性的描述符信息除了除了设置,还能进行读取。Object提供了一个getOwnPropertyDescriptor()方法取得指定属性的属性描述符。
/*
* obj{ object }:目标对象
* propertyName{ string }:想要获取描述符的属性名
* return:返回一个对象,对于不同类型的属性(访问器、数据),返回不同的描述信息
*/
Object.getOwnPropertyDescriptor(obj,propertyName)
let person = {};
Object.defineProperties(person,{
name:{
value:'小红'
},
address:{
value:'成都'
},
age:{
get(){
return 40;
}
}
})
let descriptor = Object.getOwnPropertyDescriptor(person,'name')
let descriptor1 = Object.getOwnPropertyDescriptor(person,'age')
上面的descriptor和descriptor1就是获取出来的属性描述符对象。通过展开对象,可以佐证我们前面提到的特性,比如get/set不给定时,默认是undefined、通过API控制定义属性时,如果不给描述符信息进行赋值的话,比如enumerable -可枚举、configurable - 可配置这些都将得到false。
定义属性描述符,可以单个定义,批量定义。同样的,读取属性描述符除了单个读取,还能批量读取,这里暂不涉及,自行查询文档。
3.7. 属性类型和属性描述符小结
数据属性和访问器属性的核心差别在于,数据属性通过value来进行属性值的访问,而访问器属性则是通过getter来进行属性值的访问。对于引擎来说,如果属性描述符中包含value或者writable字段,那么该属性是数据属性。如果包含get、set字段,则该属性是访问器属性。
属性描述符中不能同时存在value和getter,会抛出错误。
3.8. 合并对象
在实际开发中,往往会出现合并对象的场景。例如,我们请求某个接口时,接口参数对象的构成可能来源于多个对象。这种时候使用合并对象来生成请求参数对象往往是便捷的。
合并对象简单来说,就是把源对象所有的本地属性复制到目标对象中,这种方式也称为对象的混入。
3.8.1. Object.assign
在ES6中提供了一个Object.assign()方法来进行对象的混入。该方法接收一个目标对象和一个或者多个源对象作为参数,然后将源对象中的属性复制到目标对象。
let person = {
name:'小红',
age:20
}
let pages = {
size:10,
current:1
}
let requestParams = {};
// 开始合并
Object.assign(requestParams,person,pages)
console.log(requestParams)
Object.assign细节事项:
- 源对象中的属性复制到目标对象,指的是将源对象中可枚举的属性以及自有属性复制到目标对象
-
- 可枚举指的是
Object.propertyIsEnumerable()返回true的属性 - 自有指的是
Object.hasOwnProperty()返回true的属性
- 可枚举指的是
- 如果多个源对象存在相同的属性,则使用最后一个值
- 复制操作是浅复制,浅复制指的就是如果有的属性是对象,复制的就是引用,如果修改属性会相互影响。
- 该方法支持多个源对象,因此复制操作相当于从右到左的链式复制,复制过程中影响源数据,最终返回源数据。
let a = {} // 源对象a
let b = { name:'小b' } // 源对象b
let c = { info:{address:'CD'}} // 源对象c
let result = {} // 目标对象
// 浅拷贝副作用示例
Object.assign(result,a,c)
console.log(result) // { info:{address:'CD'}}
console.log(result === c) // false
console.log(result.info === c.info) // true
一般来说,Object.assign可以正常放心使用,后面还会介绍更多的合并对象的方法
3.9. 对象语法增强
在ES6中,为定义和操作对象提供了很多及其有用的语法糖特性,所谓语法糖就是在老式语法的基础上包装一层内容,使其简化。就像一颗很苦的药丸,外层包裹了一层糖衣,服用时就不会很痛苦。因此语法糖不会改变JS引擎在底层的行为,但是可以极大提升开发者编写代码的效率。
3.9.1. 属性值简写
当我们给对象添加属性的时候,如果属性值是存于某个变量中,属性名和变量名一致的情况下可以简写
let name = '小红'
// 原始写法
let person = {
name:name
}
// 简化写法
let person = {
name
}
如果没有找到同名变量,在代码解析阶段就会报引用错误 ReferenceError
3.9.2. 方法名简写
对象中的函数属性称为对象的方法
let person = {
sayName:function(name){
console.log(`我的名字是${name}`)
}
}
let person = {
sayName(name){
console.log(`我的名字是${name}`)
}
}
// sayName 支持动态属性
let fnName = 'sayName'
let person = {
[fnName](name){
console.log(`我的名字是${name}`)
}
}
3.9.3. 对象解构
解构赋值是ES6中一个重要的特性,主要用于对象、数组。其目的就是把对象或者数组中的成员提取出来作为某个变量的值,这里先讲对象的解构。
let person = {
name:'小红',
age:20
}
let personName = person.name;
let personAge = person.age;
⏩使用自定义的变量名保存解构出来的属性值
let person = {
name:'小红',
age:20
}
let { name:personName, age:personAge } = person
console.log(personName) // '小红'
console.log(personAge) // 20
⏩使用对象自身的属性名作为变量名保存解构出来的属性值
let person = {
name:'小红',
age:20
}
let { name,age } = person;
console.log(name) // '小红'
console.log(age) // 20
⏩解构的属性不存在时,值为undefined
let person = {
name:'小红',
age:20
}
let { name, address } = person;
console.log(name) // '小红'
console.log(address) // undefined
⏩解构赋值时使用默认值,当属性值为undefined或者像上面的属性不存在都可以采用默认值
let person = {
name:'小红',
age:20
}
let { name, address='CD' } = person;
console.log(name) // '小红'
console.log(address) // CD
💁 注意
解构赋值在底层会把源数据结构转为对象,也就是说,即使是原始值也支持解构。而null和undefined不支持解构,会引发类型错误 TypeError。
⏩原始值解构
let str = 'hello'
let { length } = str
console.log(length) // 5
let { constrouctor:c } = 4
console.log(c) // Number -Function 这个代码在深入函数的原型后理解
⏩解构赋值不是必须把变量名写在解构的表达式中,可以事前声明,但是赋值时必须要用小括号包裹。
// 定义变量
let personAge,personName;
// 数据对象
let person = {
name:'小红',
age:20
}
// 解构
({name:personName, age:personAge} = person);
⏩嵌套的对象也可以结构
let person = {
name:'小红',
address:{
city:'成都',
zipCode:2000
}
}
let { name,address:{ city,zipCode } } = person;
console.log(name) // '小红'
console.log(city) // '成都'
console.log(zipCode) // 2000
let { address } = person;
console.log(address === person.address) // true
如果直接解构嵌套对象的整体,如上面的address,解构出来的实际上对象的引用,修改时会互相影响
⏩函数的参数结构
在函数参数列表中也可以进行解构赋值,不会影响函数的arguments对象
let person = {
name: '小红',
age: 20
}
function getPerson(custom,{name,age},length){
console.log(arguments)
console.log(name)
console.log(age)
console.log(length)
}
function getPerson2(custom,{ name:personName, age:personAge },length){
console.log(arguments)
console.log(personName)
console.log(personAge)
console.log(length)
}
getPerson('自定义',person,6)
getPerson2('自定义',person,6)
对象相关的基础和进阶部分的内容大概就是这些,还有一个最关键的部分就是面向对象,JS中的面向是基于原型的,我们将在学习函数后再深入面向对象的学习。