JavaScript 面向对象知识点总结

191 阅读12分钟

image.png

以往了解的对象都是在使用层面,当想补全基础的时候去看书籍学习和复习时,发现对象其实还有很多东西没有学懂,于是有了这篇笔记来对 JS 对象知识的补充。其实本文很多东西都能在书籍中和一些高赞的博文中看到,甚至更详细,我是个莫得感情的工具人,为了方便自己复习和记忆整理成了这篇笔记,希望对大家有帮助,大家看了能有点收获不甚荣幸 ( ̄▽ ̄)~*

不是对象的 null

虽然 typeof null的值是 'object',但 null 不是对象,而是 JavaScript 的 7 种基本数据类型之一。

Tips: 在最新的 ES10 中又给 JavaScript 新增了一种基本数据类型 BigInt,所以 JavaScript 现在已经是 7 种基本数据类型 Number, String, Boolean, null, undefined, Symbol, BigInt 和 1 种引用数据类型 Object。

可计算属性名

如果创建一个对象的时候,属性名是变量,我们可以这样:

let name = ’name’
const obj = {}

// 变量作属性名
obj[name] = ‘noko’
// 表达式作属性名
obj['your' + 'name'] = 'Jake'

定义对象的属性

一般我们不需要用到 属性描述符存取描述符 这些高级功能来配置和定义我们的属性。不过这些概念对于我们理解 JavaScript 的对象和一些框架的底层实现的源码很有帮助。

1. 常见的定义属性方法

let person = {
  name: 'noko',
  age: 18
}

// 添加属性和值
person.sex = 'man'

2. 使用属性描述符

属性描述符 作用
[[Configurable]] 能否 delete 属性。设置为 false 后,除了能将 writable 设为 false,其他不可再重洗配置
[[Enumerable]] 表示是否可被 for-in 遍历属性
[[Writable]] 表示是否能修改属性值
[[Value]] 表示属性的值
const person = {
  name: 'noko'
}

// 定义一个属性
// 如果没有指定的属性描述符会默认为 false
Object.defineProperty(person, 'age', {
  writable: false,
  value: 18
})

// 定义多个属性
Object.defineProperties(person, {
  name: {
    value: 'noko'
  },
  age: {
    value: 18
  }
})

// 获取属性描述符
const descriptor = Object.getOwnPropertyDescriptor(person, 'name')

console.log(descriptor.value) // 'noko'

3. 使用存取描述符

不包含 [[Writable]] 和 [[Value]],与之对应的是一对 getter 和 setter 函数,在访问对象的属性时,会调用 getter 函数,返回有效值;写入对象属性值时,调用 setter 函数,传入写入的值作为 setter 函数的参数。

属性描述符 作用
[[Configurable]] 能否 delete 属性。设置为 false 后,除了能将 writable 设为 false,其他不可再重洗配置
[[Enumerable]] 表示是否可被 for-in 遍历属性
[[Get]] 表示是否能修改属性值
[[Set]] 表示属性的值
const person = {
  name: 'noko',
}

Object.defineProperty(person, 'age', {
  get: function() {
    return 18
  },
  set: function(newValue) {
    if(newValue >= 18) {
      console.log('我未成年')
    }
  }
})

console.log(person.age) // 18
person.age = 19 // '我未成年'
console.log(person.age) // 还是打印 18

// 获取存取描述符的方法与属性描述符一样

不可变性

首先,以下的所有的方法都是 浅不可变的,只会影响到目标对象和对象的第一层的直接属性。如果目标对象的属性是引用的数据类型(Object、Array、Function 等),那么其他对象内的属性还是可变的。

myImmutableObject.foo; // [1,2,3] 
myImmutableObject.foo.push(4); 
myImmutableObject.foo; // [1,2,3,4]

假设我们实现了一个不可变对象 myImmutableObject,属性 foo 对象是无法被 delete 和重新复制的。但是我们仍能给 foo 对象修改它的属性。如果我们要使得 foo 也是不可变的,那么有以下的 4 个方法:

1. 对象常量

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

const myObject = {}

Object.defineProperty(myObject, 'FAVORITE_NUMBER', {
  value: 23,
  writbale: false,
  configurable: false
})

2. 禁止扩展

Object.preventExtensions(..) 可以禁止添加新的属性,保留原来已有属性

const myObject = {
  a:1
}

const myObject = {
  a:1
}

Object.preventExtensions(myObject)

myObject.b = 3
myObject.b // undefined(myObject)

myObject.b = 3
myObject.b // undefined

非严格模式下静默失败;严格模式下抛出 TypeError 错误。

3. 密封

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

也就是不能添加新属性、不能重新配置和删除现有属性,但能修改现有属性的值。

4. 冻结

Object.freeze(..) 会创建一个“冻结”对象,这个方法调用了 Object.seal(..) 的基础上将所有已有属性标记为 writable:false ,使得这个对象不能做任何的操作。这是可以应用在对象的最高级的不可变,但这仍然是浅不可变,属性是引用类型的话还是不受影响。

如果要深度不可变,我们可以通过遍历对象将所有属性都冻结,但这同事被引用同一个对象也会造成影响。

最后说一下,JavaScript 中很少需要用到深不可变性,这可能会无意的冻结到其他(共享)的对象。

存在性

问题: 如何判断一个对象的属性是来源于自身对象还是继承于另外一个对象?

// 举一个栗子
function Person(name) {
  thia.name = name
}

Person.prototype.age = 18

const person = new Person('noko') 

// 判断 person 对象的属性值来源
person.name  // 'noko'
person.age // 18

解决办法: in 和 hasOwnProperty。

// in 可以判断对象和原型链中是否有该属性
('name' in person) // true
('age' in person) // true
('gender' in person) // false

// hasOwnProperty 可以只判断当前对象中是否有该属性
person.hasOwnProperty('name') // true
person.hasOwnProperty('age') // false
person.hasOwnProperty('gender') // false

整合:

// 我们的情况有三种,1.来源于本身;2.来源于原型链;3.既不在本身也不在原型链(undefined)
// 显然上面两个 API 单独使用是无法判断的,需要组合使用

function attributeSource(obj, propertyName) {
  if(propertyName in obj) {
    if(obj.hasOwnProperty(propertyName)) {
      console.log(`${propertyName}: 来源于自身`)
    } else {
      console.log(`${propertyName}: 来源于原型链`)
    }
  } else {
    console.log(`${propertyName}: 不存在对象和原型链`)
  }
}

attributeSource(person, 'name') // 'name: 来源于自身'
attributeSource(person, 'age') // 'age: 来源于原型链'
attributeSource(person, 'gender') // 'gender: 不存在对象和原型链'

遍历对象

先来创建一个对象,为了使得遍历的结果能清晰体现出遍历方法的不同,所以这个对象会有点复杂。当然可以拉到最后可以直接看到结论。

  1. father 对象是 Father 的实例,Father 继承 Grandpa ,所以 father 拥有着 Father 和 Grandpa 的属性和方法。
  2. father 中添加了 常规的属性、Symbol 属性、不可枚举属性
function Father() {}

// 添加 Father 属性
Father.prototype.fatherName = "noko";
Father.prototype[Symbol("Father")] = "Symbol Father";
Father.prototype.getFatherName = function() {
  console.log(this.fatheraName);
};
Object.defineProperty(Father.prototype, "gender", {
  writable: true,
  configurable: true,
  value: "man",
  enumerable: false // 不可枚举
});

// 实例化 Father
const father = new Father();

// 添加 father 属性
father.age = "40";
father.getAge = function() {
  console.log(this.age);
};
father[Symbol("father")] = "Symbol father";
Object.defineProperty(father, "profession", {
  writable: true,
  configurable: true,
  value: "developer",
  enumerable: false // 不可枚举
});

console.table(father);

看看我们的对象是什么样子的:

image.png

只能看到 father 本身的属性,但其实能访问到 Father 构造函数上的属性。

最后让我们看看各种对象的遍历方法都有些什么区别。

1. for...in

for (let key in father) {
  console.log(key, father[key]);
}

image.png

结论:遍历自身和原型链上除了 Symbol 和不可枚举的属性。遍历时查找了原型链,对性能有影响

2. Object.keys

Object.keys(father)

image.png

结论:遍历自身的属性,缺少了 Symbol 和不可枚举的属性

3. Object.getOwnPropertyNames

Object.getOwnPropertyNames(father)

image.png

**
结论:遍历自身的属性,包括了不可枚举的属性,缺少 Symbol

4. Object.getOwnPropertySymbols

Object.getOwnPropertySymbols(father)

image.png

结论:遍历自身属性,只返回 Symbol 属性

5. Object.entries

Object.entries(father)

image.png

结论:于 Object.keys 一致,但返回的是键值对数组

6. Object.values

Object.values(father)

image.png

结论:于 Object.keys 一致,但返回的是属性值数组,相当于手写 for 循环遍历对象,顺序于 for...in...一致

7. for...of

for...of 不能直接在没有 iterator 接口的对象上使用,所以放在了最后。我们可以自己在对象上部署 iterator 接口使对象可以用 for...of 进行遍历。

// 部署 iterator 接口
Object.defineProperty(father, Symbol.iterator, {
  enumerable: false,
  writable: false,
  configurable: true,
  value: function() {
    const o = this
    let index = 0
    const keys = Object.keys(o)
    
    return {
      next: function() {
        return {
          value: o[keys[index++]],
          done: (index > keys.length)
        }
      }
    }
  }
})

// 遍历
for(let value of father) {
  console.log(value)
}

image.png

结论:从实现就知道,因为基于 Object.keys 进行遍历,所以与 Object.keys 一致,遍历自身的属性。返回的是属性值,这点与 for...in 不同。

总结

  1. for...in 会遍历原型链上的属性,其他 6 种方法都不会。遍历原型链会带来性能的问题
  2. Object.getOwnPropertyNames() 能遍历到不可枚举属性
  3. 只有 Object.getOwnPropertySymbols() 只能遍历 Symbol 属性
  4. 如果没有特殊要求,Object.keys()Object.values() 就能满足我们的遍历需要

创建对象的方法的优缺点

我们常见的创建对象的两种方法是 对象字面量 const obj = {}构造形式 new Object(),我们这里来看看不常见的方法:

1. 工厂模式

function createPerson(name) {
  const o = new Object()
  
  o.name = name
  o.getName = () => {console.log(this.name)}
  
  return o
}

const person = createPerson('noko')

优点:解决了生成多个相似对象的麻烦问题
缺点:每个生成的对象都用相同的属性,并且无法识别对象的类型,不同于 Object、Array 和 Data 等特点类型的对象。

2. 构造函数模式

function Person(name) {
  this.name = name
  this.getName = getName
}

// 防止多次 new 重复生成函数
function getName() {
  console.log(this.name)
}

const person = new Person('noko')
console.log(person instanceof Person) // ture

优点:可以识别为特定的类型 Person 了
缺点:缺乏封装。Person 的成员函数 getName 放到了全局环境上,如果有多个成员函数声明在了全局环境上,但却只被 Person 使用,这是不合理的。

3. 原型模式

3.1 直接添加在原型

function Person(name) {}

Person.prototype.name = 'noko'
Person.prototype.getName = function () {
  console.log(this.name)
}

const person = new Person()

优点:解决了构造函数的成员方法被定义在全局环境下的问题
缺点:如果原型的属性是引用类型 object、array 等,那么修改属性的时候可能影响全部实例;另外也会造成原型污染,如果其他代码也支持该方法,会发生命名冲突

3.2 重写原型

function Person() {}

Person.prototype = {
  constructor: Person,
  name: 'noko',
  getName: () => { console.log(this.name) }
}

const person = new Person()

优点:写法上更好(= =!)
缺点:包括直接添加在原型上的方法的缺点,并且还覆盖了 Person 原来的原型;另外值得注意的是,后面再有重写同一个原型对象的操作,那么前面的实例的原型是旧的原型而不是新重写的原型。

4. 组合模式

function Person(name) {
  this.name = name
}

Person.prototype = {
  constructor: Person,
  getName: () => { consolo.log(this.name) }
}

const person = new Preson('noko')

优点:能实现属性私有和共享了,应用最广泛

5. 动态原型模式

function Person(name) {
  this.name = name
  
  if(typeof this.getName !== 'function') {
    Person.prototype.getName = () => { console.log(this.name) }
  }
}

const person = new Person()

// ---------------------- 上面已经能完成好需求了 ----------------------

// 如果要使用字面量
// 不能将上面 if 中的代码直接改成对象字面量
// 因为 new 的第二步先指向了原来 Person 的原型,然后第三步才执行 Person 的代码
// 所以导致第一个实例无法访问 Person 函数中重写的原型
// 我们可以这么做

function Car(carName) {
  this.carName = carName
  if(typeof this.getCarName !== 'function') {
    Car.prototype = {
      constructor: Car,
      getCarName: () => { console.log(this.carName) }
    }
    
    return new Car(carName) // 手动 return 实例对象使得 new 第四步返回我们制定的对象
  }
}

const car = new Car()

优点:组合模式的基础上,可以初始化原型

6. 寄生构造函数模式

// 注意:与工厂模式不同,使用 new
// 使用场景:如果用的 Array 或者 Date 等这些不能好修改原型的原生构造函数时,可以使用

function SpecialArray() {
  const arr = new Array()
  
  arr.push.apply(arr, arguments)
  arr.toPipedString = () => { 
    return this.join('|')
  }
  
  return arr
}

const color = new SpecialArray('red', 'blue', 'green') 

这个模式在红宝书上不建议被使用,能优先使用别的模式时不要使用这种模式,原因是使用 instanceof 无法判断实例的对象类型,这点和工厂模式一样

优点:不建议使用
缺点:无法缺点实例的对象类型

7. 稳妥构造函数模式

function Person(name, age) {
  const o = new Object()
  
  o.getName = () => { console.log(name) }
  
  return o
}

const person = Person('noko', '80')

person.getName()

所谓的稳妥对象,指的是没有公共属性,而且及不实用 this,实例也不使用 new 的对象。适合在一些安全的环境中,无法使用 this 和 new 的场景。

优点:成员属性私有,没有公共属性
缺点:与工厂模式和寄生构造函数模式一样,不能识别实例的对象类型

对象的深浅克隆

这部分就没什么好说的了,全在代码上。talk is cheap, show me the code!

需要拷贝的对象:

let obj = {
  name: "noko",
  info: {
    age: 18,
    gender: null,
    wx: undefined,
    qq: {}
  },
  getAge: function() {
    console.log(this.info.age);
  }
};

1. 浅拷贝

// 方法一:
const newObj = {...obj}

// 方法二:
const newObj2 = Object.assign({}, obj)

2. 深拷贝

方法一:

这个方法比较取巧,有很多的限制:

  1. 拷贝时忽略函数、undefined、Symbol;
  2. 不能拷贝循环引用的对象,会报错。
const newObj = JSON.parse(JSON.stringify(obj))
console.log(newObj)

image.png

方法二:

const deepClone = (function d(obj) {
  // 只克隆对象
  if (typeof obj !== "object" || !obj) return;
  
  // 判断类型
  const newObj = obj instanceof Array ? [] : {};

  for (let key in obj) {
    // 只克隆对象自身的属性
    if (obj.hasOwnProperty(key)) {
      if (typeof obj[key] === "object" && obj[key] !== null) {
        newObj[key] = d(obj[key]);
      } else {
        newObj[key] = obj[key];
      }
    }
  }

  return newObj;
})

继承

1. 原型链

function SuperType() {
  this.name = 'noko'
}
SuperType.prototype.getName = function() {
  console.log(this.name)
}

function SubType (){
  this.age = 18
}
SubType.prototype = new SuperType()
SubType.prototype.getAge = function() {
  console.log(this.age)
}

const instance = new SubType()

console.log(instance.getName())
console.log(instance.getAge())

2. 借用构造函数

JavaScript 经典的继承模式

function SuperType(name) {
  this.name = name
}

function SubType(age) {
  SurperType.call(this, 'noko')
  this.age = age
}

const instance = new SubType(18)

3. 组合继承

JavaScript 最常用的继承模式。

关键的步骤:

  1. 继承父级构造函数的内部属性(call)
  2. 继承父级构造函数的原型上的方法(new)
  3. 修复原型链继承的副作用(constructor)
function SuperType(name){
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function(){
  console.log(this.name);
};

function SubType(name, age){
  //继承属性 
  SuperType.call(this, name);
  this.age = age;
}

//继承方法
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function(){
  console.log(this.age);
};

var instance1 = new SubType("Nicholas", 29);
instance1.colors.push("black");
console.log('color1:' + instance1.colors);
instance1.sayName();
instance1.sayAge();

var instance2 = new SubType("Greg", 27);
console.log('color2:' + instance2.colors);
instance2.sayName();
instance2.sayAge();

4. 原型式继承

Object.create() 的模拟实现

function createObj(o) {
  function F() {}
  F.prototype = o
  return new F()
}

5. 寄生式继承

function createObj(o) {
  const newO = Object.create(o)
  o.prototype.name = 'noko'
  return o
}

6. 寄生组合式继承

function SuperType() {
  this.name = 'noko'
  this.color = ['green', 'red', 'blue']
}

Supertype.prototype.getColor = function() {
  console.log(this.color)
}

function SubType() {
  this.age = 18	
}

function object(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

// 继承方法
inherit(SubType, SuperType) {
  const prototype = object(SubType.prototype)
  prototype.construtor = SubType
  SubType.protptype = prototype
}

所有的仅基于原型的继承都有的通病: 原型上有引用类型的数据被修改的时候,所有的使用到该属性的实例都可能会受到影响。

所有的基于构造函数的继承都由的通病: 实例化构造函数时,被借用的构造函数都将会被执行一遍,也就是说多次实例化,则被借用的构造函数就会执行多次。

寄生组合式继承的优点: 实例化时只会执行一遍 inherit 函数和 object 函数,不会执行上层的构造函数 SuperType 性能更好