JS语言高级-对象篇

85 阅读15分钟

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中,属性的类型分两种:

  • 数据属性类型
  • 访问器属性类型

这两种类型的属性的属性描述符(特定信息)是不一样的,但是有一定关联性。

ES5Object上提供了一些方法,让我们可以操作属性的属性描述符,从而提升了开发者权限,使得开发者可以以一种更加细粒度的方式精准控制对象属性的行为,从而实现更强大的功能。知名前端框架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:'小红' }
  1. 定义一个person对象
  2. 通过defineProperty方法给person对象定义name属性
  3. 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对象,有两个属性nameage。然后将属性描述符中的可配置特性改为了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表示一个设定某个属性的值的方法。具体实现则通过getset关键字。

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/setgetter/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')

上面的descriptordescriptor1就是获取出来的属性描述符对象。通过展开对象,可以佐证我们前面提到的特性,比如get/set不给定时,默认是undefined、通过API控制定义属性时,如果不给描述符信息进行赋值的话,比如enumerable -可枚举configurable - 可配置这些都将得到false


定义属性描述符,可以单个定义,批量定义。同样的,读取属性描述符除了单个读取,还能批量读取,这里暂不涉及,自行查询文档。

3.7. 属性类型和属性描述符小结

数据属性和访问器属性的核心差别在于,数据属性通过value来进行属性值的访问,而访问器属性则是通过getter来进行属性值的访问。对于引擎来说,如果属性描述符中包含value或者writable字段,那么该属性是数据属性。如果包含getset字段,则该属性是访问器属性。

属性描述符中不能同时存在valuegetter,会抛出错误。

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

💁 注意

解构赋值在底层会把源数据结构转为对象,也就是说,即使是原始值也支持解构。而nullundefined不支持解构,会引发类型错误 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中的面向是基于原型的,我们将在学习函数后再深入面向对象的学习。