红宝书笔记之第八章:对象,类与面向对象编程

239 阅读18分钟

理解对象:

属性分两种:数据属性和访问器属性;

数据属性:

数据属性有四个特性描述他们的行为:

key功能默认值
confingurable属性是否可以通过delete删除true
enumberable属性是否可以通过for-in循环遍历true
writable属性值是否可以被修改true
value属性实际的值undefined

如果需要修改属性默认特性,就需要通过object.defineProperty方法,这个方法接受三个参数, 分别是:对象本身,属性的名称,一个描述对象;

访问器属性

访问器属性也有四个特性描述他们:

key功能默认值
configurable属性是否可以通过delete删除true
enumberable属性是否可以通过for-in循环遍历true
get读取函数undefined
set设置函数undefined

定义多个属性

如果需要定义多个属性那么久用Object.definedPropiries,

接受两个参数:第一个是要添加或者修改的对象本身,第二个参数是 描述符的对象;

读取属性的特性

使用Object.getOwnPropertyDescriptor()方法可以取得指定属性的属性描述符;

接受两个参数:第一个是对象本身,另一个是要获取描述符的属性名称;

返回值:一个对象

如果是访问器属性返回的对象包含configurable,enumerable,get,set属性

如果是数据属性返回的对象包含configurable,enumerable,value,witerable;

ECMAScript 2017新增了一个Object.getOwnPrototypeDescriptors()静态方法,可以一次返回多个属性描述信息;

合并对象

ECNAScript6 专门提供了一个合并对象Object.assign()方法来合并对象; 这个方法接受一个目标对象和一个或多个源对象作为参数,然后将每个源对象中可枚举和自有属性复制到目标对象;

强调:对于每一个符合条件的属性,这个方法会使用源对象上的[[get]]取得属性的值,然后使用目标对象上的[[set]]设置属性的值;

举个例子:

// 特殊的例子
var dest = {
    _a: 1,
    set a(val) {
        console.log('设置a的值:' + val);
        this._a = val
    }
}

var src = {
    get a() {
        console.log("获取a的值:");
        return 'foo'
    }
}

Object.assign(dest, src)
console.log(dest);

这个例子会依次打印属性Id的值:

var dest = {
    set id(x) {
        console.log(x);
    }
}
Object.assign(dest, { id: 'first' }, { id: 'second' }, { id: 'thrid' })
// 最后打印内容是
// first  
// second
// thrid

Object.assign()合并对象的特点:

  • 实际上对每个源对象执行的都是浅拷贝;
  • 如果多个源对象都有相同的属性,则使用最后一个复制的值;
  • 不能再两个对象间转移获取函数和设置函数;

如果合并对象的时候出错了,Object.assign()并不会回滚操作,可能只会完成部分复制的方法

看下面这个例子:

// 回滚测试代码
var dest = {}
var src = {
    a: 'foo',
    get b() {
        throw new Error("出错了")
    },
    c: 'bar'
}
try {
    Object.assign(dest, src)
} catch (err) {
    console.log(err);
}
console.log(dest);  // {a: "foo"}

对象标识及相等判断is

ECMAScrpt6规范新增了Objece.is(),这个方法与===很想,比===更加精确;

例如:

// 用三等号,下满三个都是true
console.log(Object.is(+0, -0))   // false
console.log(Object.is(+0, 0));   // true
console.log(Object.is(0, -0));   // false

// 之前判断NaN,只能用isNaN方法判断
console.log(Object.is(NaN, NaN));  // true

增强的对象语法

  • 1,属性值简写(key名字和值的变量名一样的话就可以省略)
  • 2,可计算属性 (可用中括号动态改变对象的属性名)
  • 3,简写方法名 (es6的方法简写)

对象解构

这个没有什么好说的,注意两点就行了:

1,使用解构赋值的时候可以起一个别名

比如:

let {name: personName, age: personAge} = person; 

2,赋值的时候可以给以默认值

例如:

let {name: persionName = '默认名字', age: persionAge} = person; 

重点来了:

解构在内部使用函数ToObject()把原数据结构转化为对象,这意味这在对象解构的上下文中,原始值会被当做对象,也意味着null和undefined不能被解构,会抛出错误;

看看下面的例子:


let { length } = "foobar";
console.log(length);   // 6

// 4的构造函数赋值给了c
let { constructor: c } = 4
console.log(c === Number);  // true

let { _ } = null;         // TypeError
let { _ } = undefined;    // TypeError

解构赋值还可以用在嵌套解构,部分解构,参数上下文匹配中;

创建对象

强调:ES6虽然引入了类和继承,但是本质还是对ES5的封装,只是一个语法糖而已;

创建一个对象下面几种模式:

  • 工厂模式;
  • 构造函数模式;
  • 原型模式;

工厂模式:

function createPersion(name, age, job) {
  let o = new Object();
  o.name = name;
  o.age = age;
  o.job = job;
  o.sayName = function() {
    console.log(this.name);
  }
  return o;
}


var p1 = createPersion('lsj',18,'web前端');
var p2 = createPersion('zm',19,'平面设计师');

这种工厂模式虽然可以解决创建多个类似对象的问题,但是没有解决对象标识的问题(即新创建的对象是什么类型)

构造函数模式

// 编码规范,只有是构造函数就需要首字母大写;
function Persion(name,age,job){
  this.name = name;
  this.age = age;
  this.job = job;
  this.sayName = function() {
    console.log("我的名字是" + this.name);
  }
} 

var p4 = new Persion('lsj',18,'web前端');
var p5 = new Persion('zm',19,'平面设计师');

p4.sayName();  // 我的名字是lsj
p5.sayName();  // 我的名字是zm

这里有个经典的面试题:

创建一个对象new内部都做了什么事情?

  • 1,在内存中创建了一个新的对象;
  • 2,这个新对象内部[[prototype]]指向了构造函数prototype属性;
  • 3,构造函数内部的this被赋值为这个新的对象;
  • 4,执行构造函数内部的代码(给新对象添加属性);
  • 5,如果构造函数返回的是个非空对象,则返回这个对象,否则返回刚创建的那个对象(也就是this);

在前面我们说过工厂函创建出来的对象不知道类型,那么构造函数可以解决上面问题,p4和p5分别保存着Persion的不同实力,这两个对象都有一个constructor属性指向了Persion;这个constructor就是标识对象类型的;

构造函数与普通函数的区别:

构造函数与普通函数唯一的区别就是调用方式不用。除此之外,构造函数也是函数。任何函数只要使用了new操作符调用就是构造函数,而不使用new操作符调用的函数就是普通函数;

构造函数创建出来的对象有什么问题呢?

创建出来的方法每一个对象都保存了一份占用空间,所以就出现了后面的原型模式;

理解原型:

无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个prototype属性(指向原型对象)。默认情况下,所有原型对象自动获得一个名为constructor的属性,指回与之关联的构造函数。然后因构造函数而异,可能会给原型对象添加其他属性和方法;

在自定义构造函数时,原型对象默认只会获得constructor属性,其他的所有方法都继承自Object,每次调用构造函数创建一个新的实例,这个实例内部[[prototype]]指针就会被赋值为构造函数原型对象,脚本中没有访问这个[[prototype]]特性的标准方式,但是浏览器会在每个对象上暴露__proto__属性,通过这个属性可以访问对象的原型。

关键在于理解这一点:实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。

下面看一些例子来理解:

// 声明之后构造函数就有了一个与之关联的原型对象;
function Persion() {}
console.log(typeof Persion.prototype);  // object
console.log(Persion.prototype);

// 构造函数有一个prototype属性,指向其原型对象,而这个原型对象也有一个constructor属性,指向这个构造函数;
// 换句话书就是循环引用
console.log(Persion.prototype.constructor === Persion);  // true

// 正常的原型链都会终止与Object的原型对象 Objecrt的原型指向的是null 
console.log(Persion.prototype.__proto__ === Object.prototype); // true
console.log(Persion.prototype.__proto__.constructor === Object); // true
console.log(Persion.prototype.__proto__.__proto__);   // null


// 1,实例对象通过__proto__链接到原型对象,他实际上指向隐藏属性[[prototype]]
// 2,构造函数通过prototype属性链接到原型对象;
// 3,实例与构造函数没有直接联系,与原型对象有直接的联系;
let persion1 = new Persion();
console.log(persion1.__proto__ === Persion.prototype);

// 同一个构造函数创建两个实例,共享一个原型对象(节省空间)
let persion2 = new Persion();
console.log(persion1.__proto__ === persion2.__proto__) // true

// instanceof 检查实例的原型中是否包含指定构造函数的原型
console.log(persion1 instanceof Persion)  // true
console.log(persion1 instanceof Object)   // true
console.log(Persion.prototype instanceof Object) //true

虽然不是所有实现都对外暴露了[[prototype]],但可以使用isPrototypeOf()方法判断两个对象之间的关系;

用法如下:

console.log(Persion.prototype.isPrototypeOf(persion2)) // true
console.log(Persion.prototype.isPrototypeOf(persion1)) // true

ECMAScript的object类型有一个方法叫Object.getPrototypeOf(),返回参数的内部特性[[prototype]]的值;在继承的时候很有用,后面介绍;

用法如下:

console.log(Object.getPrototypeOf(persion2))

setPrototypeOf()方法,设置一个对象的原型,但是性能不好!可以通过Object.create()方法来代替;

虽然可以通过实例访问原型上的属性,但是不能修改原型上的属性,如果再实例上添加一个与原型同名的属性,会遮住原型的上的属性;这是因为访问一个对象的属性时,先从对像自己属性上找,找不到才回去原型链上去找,一层一层向上寻找;

hasOwnPrototype()方法用于确定属性实在实例上是否有该属性,有返回true,否则返回false;

in操作符:可以通过对象访问属性时候返回true,无论这个属性在对象上还是在原型上;

可以根据上面两个api实现一个hasPrototypeProtype方法,判断竖向是否在原型上:

具体代码如下

function hasPrototypePrototype(obj, name) {
  return (name in obj) && !obj.hasOwnPrototype(name)
}

for in 循环注意事项:

只要能够被访问就可以被枚举出来,既包含实例属性,也包含原型属性;

如果只需要遍历对象属性,那么可以使用Object.key()方法,这个方法接受一个对象作为参数,返回包含该对象所欲可枚举属性名称的字符串数组;

下面举个例子看一下:

function Persion() { }
Persion.prototype.name = "lsj"
let persion1 = new Persion();
persion1.ag = 18

for (let key in persion1) {
  console.log(key)  // name age
}
let keys = Object.keys(persion1)
console.log(keys)  // [age]

如果想要获取实例上的所有属性无论是否可枚举(不包含原型上的属性),可以使用Object.getOwnPropertyNames(),如果以Symbol作为key的话,需要使用它的兄弟方法遍历Object.getOwnPropertySymbols(),用法一样的;

属性枚举的顺序:
  • for in
  • Object.keys()
  • Object.getOwnPropertyNames()
  • Object.getOwnPropertySymbols()
  • Object.assign()

for in 循环和Object.keys()的枚举顺序是不确定的,取决于js引擎,可能因浏览器而已;

Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()和Object.assign()的枚举顺序是确定的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键;

// 看下面的例子:
let key1 = Symbol("k1")
let key2 = Symbol("k2")

let o = {
  1: 1,
  first: "first",
  [key1]: 'key1',
  second: "second",
  0: 0
}

o[key2] = 'key2'
o[3] = 3
o.thrid = 'thrid'
o[2] = 2

console.log(Object.getOwnPropertyNames(o))  // ["0", "1", "2", "3", "first", "second", "thrid"]
console.log(Object.getOwnPropertySymbols(o))   // [Symbol(k1), Symbol(k2)]
对象迭代

ESMAScript2017新增了两个静态方法,Objedt.values()和Object.entries(),接受一个对象作为参数,前者返回的是值的数组,后者返回的是键值对的数组;

值得注意的是:

  • 如果值是引用类型,这两个方法拿到的都是引用,就是所谓浅复制;
  • 对于键是Symbol类型,两个方法都会忽略;
重写原型

还有一种方式使我们可以重新定义Prototype指向一个新的对象;

如下:

// 定义一个构造函数
function Persion () {}

// 将构造函数的原型重新指向一个对象
Persion.prototype = {
  name:'lsj',
  age:18,
  sayName() {
    console.log("我的名字是" + this.name)
  }
}

let p1  = new Persion()
p1.sayName()

但是这样写有一个问题:重写之后Persion.Prototype的consturctor属性就不知道指向Persion了;

console.log(Persion.prototype.constructor === Persion)  // false

所以就不能通过constructor属性来识别类型了,也需要使用instanceof操作符来判断了;

当然我们可以修正该指针,如下代码:

// 将构造函数的原型重新指向一个对象
Persion.prototype = {
  constructor:Persion,  // 指针修正
  name:'lsj',
  age:18,
  sayName() {
    console.log("我的名字是" + this.name)
  }
}

但是这种修正的方式和原生的有些区别(原生中constructor是不可以枚举的):

再次修改:

// 定义一个构造函数
function Persion() { }

// 将构造函数的原型重新指向一个对象
Persion.prototype = {
  name: 'lsj',
  age: 18,
  sayName() {
    console.log("我的名字是" + this.name)
  }
}

Object.defineProperty(Persion.prototype, "constuctor", {
  enumerable: false,
  value: Persion
})
原型的动态性

表现在创建一个对象实例之后,对原型所做的修改在实例上也会反应出来;

比如:

// 定义一个构造函数
function Persion() { }
//创建实例
let p1 = new Persion()
// 修改原型
Persion.prototype.sayHello = function() {
  console.log("hello")
}
// 调用原型方法
p1.sayHello()

之所以这样是因为实例和原型之间就是简单的指针关系,而不是保存副本,所以能在原型中找到sayHello方法;

需要注意一点就是创建实例之后,然后重写原型,那么之前重建实例不会动态改变了,因为指针变掉了;

一般情况:原型上定义一些公用的方法,属性最好通过传参的方式传入;

继承

原型继承

ECMS-262把原型链定义为ECMASCript的主要继承方式;基本思路就是通过原型继承多个引用类型的属性和方法;

==默认原型:==

原型链的最顶层就是Object,这就是为什么自定义的类型能够继承包括toString()、valueOf()等在内的所有默认方法的原因;

==继承和原型之间的关系==:

原型和实例的关系可以通过两种方式来确认:第一个是instanceof,第二个是isPrototypeOf(),具体用法可以参考P241页;

==关于方法:==

子类有时候需要覆盖父类的方法,或者重写父类的方法,为此,这些方法必须原型赋值之后再添加到原型上;

==原型链的问题:==

原型中包含引用类型的时候,会在实例中共享;

盗用构造函数

在子类构造函数中调用call()或者apply()方法,并且把this作为上下文,执行父类的构造函数;

  • 优势:可以传参给父类
  • 缺点:父类原型上的方法不能继承
组合继承

组合继承就是将上面两种方式结合起来的继承,同是也保留了instanceof操作符合isPrototypeOf()方法识别合成对象的能力;

缺点:原型对象上定了很多不必要的属性;

原型式继承

使用场景:你有一个对象,想在它的基础上在创建一个新的对象,这个时候你就可以把这个对象先传给object(),然后对返回的对选哪个做适当的修改。

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

这个object()函数会创建一个临时构造函数,将传入的对象赋值给这个构造函数原型,然后返回这个临时类型的一个实例;本质上,object()是对传入的对象执行了一次浅复制;

ECMAScript5新增了一个Object.create()方法将原型式继承的概念规范化了,接受两个参数:第一个是作为新对象原型的对象,第二个给新对象定义额外属性的对象(可选),用法和Object.defineProperties()的第二个参数一样的;

寄生式继承

思路:类似于寄生构造函数和工厂模式,创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。(一般没有什么用,可以忽略)

寄生式组合继承

这里只调用了一次SuperType构造函数,避免了subType.prototype上不必要也用不到的属性;而且instanceof操作符合isPrototypeOf()方法正常有效;

寄生式组合继承可以算是引用类型继承的最佳模式

看代码:

// 实现寄生式组合的核心逻辑
function inHeritPrototype(subType,superType) {
  let prototype = Object.create(superType.prototype)
  // 修正constructor
  prototype.constructor = subType
  subType.prototype = prototype
}
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
}
// 继承实现
inHeritPrototype(SubType,SuperType)
SubType.prototype.sayAge = function() {
  console.log(this.age)
}
let instance = new SubType('lsj',18)
instance.sayName()  // lsj 调用父类的方法
instance.sayAge()  // 18 调用子类的方法

类是ECMAScript6中新的基础性语法糖结构,实际上它的背后是的仍然是原型和构造函数的;

类的定义

和函数一样主要有两种定义方式:类声明和类表达式;

与函数的不用点:

  • 函数生命可以提前,但是类定义不行;
  • 函数受作用域限制,而类受块作用域限制;

类的构成:

  • 构造方法
  • 实例方法
  • 获取函数
  • 设置函数
  • 静态类方
类的构造函数

方法名字constructor会告诉解析器在使用new操作符创建类的实例时,应该调用真函数;构造函数不是必须的,不定义构造函数就相当于将构造函数定义为空函数;

==实例化==

实例化的过程和前面介绍的构造方法实例化基本一致,只是执行了构造方法为对象添加属性;实例化时候传入的参数给了constructor这个方法,如果不需要参数,则类名后面的括号也是可选的;(一般还是带上吧)

默认情况下,类构造函数会在执行之后返回this对象,也就是实例对象;不过返回的不是this对象而是其他对象,那么这个对象不会通过instanceof操作符检测出跟类有关联;

类构造函数与普通的构造函数主要的区别是:调用构造函数必须使用new操作符;

把类当做特殊函数

可以通过typeof操作符检测类标识符;类型是function

类标签符有prototype属性,原型上面也有一个constructor属性指向自身,也可以使用instanceof操作符检查构造函数原型是否存在实例的原型链中;

重点在于:类定义的constructor方法不会被当成构造函数,在对它使用instanceof操作符会返回false(P253页)

类是javascript的一等公民,可以像函数一样作为参数,也可以像函数一样立即实例化;

实例、原型和类成员

类语法糖可以更方便的定义应该存在于实例上的成员、应该存在于原型上的成员、以及应该存在于类本身的成员。

class Persion {
  constructor() {
    // 添加到this的所有内容都会存在于不同的实例上
    this.loacate = () => {
      console.log("instance", this)
    }
  }
  // 定义在原型上
  loacate() {
    console.log("prototype", this)
  }
  // 定义在类本身上
  static locate() {
    console.log("class", this)
  }
}
let p = new Persion()
p.loacate()
Persion.prototype.loacate()
Persion.locate()

注意:

  • 不能在类块中给原型添加原始值或者对象作为成员数据;
  • 类方法等同于对象属性,因此可以使用字符串、符号(symbol)或者计算的值作为键
  • 类定义支持获取和设置访问器(get,set方法)
  • 类中支持书写生成器方法,可以通过添加一个默认的迭代器,把类变成可迭代对象;
  • 虽然类定义不支持显示在原型或类中添加成员数据,但在类定义外部,可以手动添加;
继承

ES6类仅支持单继承,使用extends关键字,就可以继承任何拥有[[constructor]]和原型的对象。不仅可以继承一个类,也可以继承普通的构造函数(保持向后兼容);

使用super()的注意事项:

  • 1,super只能在派生类构造函数和静态方法中使用;
  • 2,不能单独使用super关键字,要么用它调用构造函数,要么用它调用静态方法;
  • 3,调用super回调用父类构造函数,并将返回的实例赋值给this;
  • 4,super的行为如同调用构造函数,如果需要给父类构造函数传入参数,则需要手动传入;
  • 5,如果么有定义构造函数,在实例化派生类时会调用super,而且会传入所有传给派生类参数;
  • 6,在类构造函数中,不能再调用super之前应用this;
  • 7,如果在派生类中显示定义了构造函数,则要么必须在其中调用super,要么必须在其中返回一个对象;
抽象基类:

假设需要这样一个类,可以供给其他类继承,但是本身不会被实例化;

ECMAScript并没有实现专门支持这种的语法,但是可以通过new.target容易实现。new.target保存通过new关键字调用的类或函数。

可以通过抽象类构造函数进行检查,要求派生类必须实现某一个方法;(P262页)

继承内置类型

ES6类为继承内置引用类型提供了顺畅的时机;

可以这样写:

class SuperArray extends Array {
    ...
    // 自己实现很多方法
}

有些内置类型方法会返回新的实例。默认情况下,返回的实例类型与原始实例的类型是一致的;

如果想覆盖这个默认行为,则可以覆盖Symbol.species访问器,这个访问器决定在创建返回的是实例时使用;

类的混入

看下面代码:

class Vehicle { }
// 定义一个foo混合
let FooMixin = (SuperClass) => {
  return class extends SuperClass {
    foo() {
      console.log("foo")
    }
  }
}

// 定义一个bar混合
let BarMixin = (SuperClass) => {
  return class extends SuperClass {
    bar() {
      console.log("bar")
    }
  }
}
// 定义一个bar混合
let BazMixin = (SuperClass) => {
  return class extends SuperClass {
    baz() {
      console.log("baz")
    }
  }
}

class Bus extends FooMixin(BarMixin(BazMixin(Vehicle))) { }
let b = new Bus()
b.foo();  // foo
b.bar();  // bar
b.baz();  // baz

FooMixin(BarMixin(BazMixin(Vehicle)))这样子嵌套调用,不够优雅。

精进一下:

function mix(baseClass, ...Mixins) {
  return Mixins.reduce((accumulator, current) => current(accumulator), baseClass)
}